"""患者単位分割のテスト（SPEC_02「データ分割」・SPEC_01「patient_id について」）.

最重要: 同一 patient_id が複数の分割に跨らないこと．1 患者に複数枚ある manifest でも同様．
"""

import logging
import random
from collections import Counter

import pytest

from tias_judge_model.data.classes import (
    ALL_DIR_NAMES,
    CLASS_SLUGS,
    VALID_SEVERITIES,
    parse_dir_name,
)
from tias_judge_model.data.manifest import ManifestRecord
from tias_judge_model.data.split import (
    Fold,
    SplitResult,
    count_by_class,
    count_by_class_severity,
    count_by_group,
    filter_by_severity,
    make_kfold_splits,
    patient_groups,
    patient_labels,
    select_records,
    split_records,
    split_train_val_test,
)

SEED = 42


def make_records(
    patients_per_class: int | dict[str, int], images_per_patient: int = 1, prefix: str = "pt"
) -> list[ManifestRecord]:
    """クラスごとに指定人数の患者を持つ合成 manifest を作る（1 患者 images_per_patient 枚）."""
    if isinstance(patients_per_class, int):
        patients_per_class = dict.fromkeys(CLASS_SLUGS, patients_per_class)
    records = []
    for label, n_patients in patients_per_class.items():
        severities = VALID_SEVERITIES[label]
        for p in range(n_patients):
            patient_id = f"{prefix}_{label}_{p:04d}"
            for k in range(images_per_patient):
                severity = severities[(p + k) % len(severities)]
                records.append(
                    ManifestRecord(
                        image_path=f"{label}_{severity}/{patient_id}_{k}.png",
                        patient_id=patient_id,
                        label=label,
                        severity=severity,
                    )
                )
    return records


def make_group_records(
    patients_per_group: dict[str, int], images_per_patient: int = 1, prefix: str = "g"
) -> list[ManifestRecord]:
    """層化グループ（`<label>_<severity>`）ごとに指定人数の患者を持つ合成 manifest を作る."""
    records = []
    for group, n_patients in patients_per_group.items():
        label, severity = parse_dir_name(group)
        for p in range(n_patients):
            patient_id = f"{prefix}_{group}_{p:04d}"
            for k in range(images_per_patient):
                records.append(
                    ManifestRecord(
                        image_path=f"{group}/{patient_id}_{k}.png",
                        patient_id=patient_id,
                        label=label,
                        severity=severity,
                    )
                )
    return records


def shuffled(records: list[ManifestRecord], seed: int) -> list[ManifestRecord]:
    out = list(records)
    random.Random(seed).shuffle(out)
    return out


def all_patients(records: list[ManifestRecord]) -> set[str]:
    return {r.patient_id for r in records}


def labels_in(patient_ids: tuple[str, ...], records: list[ManifestRecord]) -> Counter[str]:
    mapping = patient_labels(records)
    return Counter(mapping[p] for p in patient_ids)


# --- 単一分割 (train / val / test) ---


@pytest.mark.parametrize("images_per_patient", [1, 3])
def test_single_split_keeps_patients_disjoint(images_per_patient: int) -> None:
    records = make_records(20, images_per_patient=images_per_patient)
    result = split_train_val_test(records, val_ratio=0.15, test_ratio=0.15, seed=SEED)

    train, val, test = set(result.train), set(result.val), set(result.test)
    assert not train & val
    assert not train & test
    assert not val & test
    assert train | val | test == all_patients(records)
    # 各分割は患者 ID の重複を含まない
    for part in (result.train, result.val, result.test):
        assert len(part) == len(set(part))


def test_single_split_puts_all_images_of_a_patient_in_same_split() -> None:
    records = make_records(12, images_per_patient=4)
    result = split_train_val_test(records, val_ratio=0.2, test_ratio=0.2, seed=SEED)

    selected = {
        name: select_records(records, ids)
        for name, ids in (("train", result.train), ("val", result.val), ("test", result.test))
    }
    assert sum(len(v) for v in selected.values()) == len(records)
    for name, recs in selected.items():
        for r in recs:
            images_of_patient = [x for x in records if x.patient_id == r.patient_id]
            assert all(x in recs for x in images_of_patient), name


def test_single_split_with_uneven_images_per_patient_stays_disjoint() -> None:
    # 1 患者の枚数がばらつく manifest（将来の複数枚追加を想定）
    base = make_records(10, images_per_patient=1, prefix="one")
    many = make_records(10, images_per_patient=5, prefix="many")
    records = base + many
    result = split_train_val_test(records, val_ratio=0.15, test_ratio=0.15, seed=SEED)

    groups = [set(result.train), set(result.val), set(result.test)]
    for patient in all_patients(records):
        assert sum(patient in g for g in groups) == 1


def test_single_split_is_reproducible_with_same_seed() -> None:
    records = make_records(20)
    a = split_train_val_test(records, val_ratio=0.15, test_ratio=0.15, seed=SEED)
    b = split_train_val_test(records, val_ratio=0.15, test_ratio=0.15, seed=SEED)
    assert a == b


def test_single_split_changes_with_different_seed() -> None:
    records = make_records(30)
    a = split_train_val_test(records, val_ratio=0.15, test_ratio=0.15, seed=0)
    b = split_train_val_test(records, val_ratio=0.15, test_ratio=0.15, seed=1)
    assert a != b


@pytest.mark.parametrize("images_per_patient", [1, 3])
def test_single_split_does_not_depend_on_input_order(images_per_patient: int) -> None:
    records = make_records(20, images_per_patient=images_per_patient)
    expected = split_train_val_test(records, val_ratio=0.15, test_ratio=0.15, seed=SEED)
    for shuffle_seed in range(3):
        actual = split_train_val_test(
            shuffled(records, shuffle_seed), val_ratio=0.15, test_ratio=0.15, seed=SEED
        )
        assert actual == expected
    assert (
        split_train_val_test(list(reversed(records)), val_ratio=0.15, test_ratio=0.15, seed=SEED)
        == expected
    )


def test_single_split_is_stratified_by_class() -> None:
    # 実データに近い不均衡な分布（TEST_01 の 1/10 程度）
    per_class = {"pale": 10, "light_red": 34, "red": 12, "dark_red": 38, "purple": 15}
    records = make_records(per_class)
    result = split_train_val_test(records, val_ratio=0.15, test_ratio=0.15, seed=SEED)

    for part in (result.train, result.val, result.test):
        counts = labels_in(part, records)
        assert set(counts) == set(CLASS_SLUGS), "各分割に全クラスが含まれること"

    # 各クラスで val / test の人数が比率どおり（端数は 1 人以内）
    for part in (result.val, result.test):
        counts = labels_in(part, records)
        for label, n in per_class.items():
            assert abs(counts[label] - n * 0.15) <= 1, label


def test_single_split_sizes_follow_ratios() -> None:
    # SPEC_02: 層化グループ（クラス × 程度の 9 グループ）ごとに「患者数 × 比率」を四捨五入する．
    # make_records(20) は light_red_normal が 20 人，他の 8 グループが 10 人ずつ．
    records = make_records(20)
    result = split_train_val_test(records, val_ratio=0.15, test_ratio=0.15, seed=SEED)
    # light_red_normal: 20 × 0.15 = 3 人．他の 8 グループ: 10 × 0.15 = 1.5 → 四捨五入で 2 人
    expected = 3 + 8 * 2
    assert len(result.val) == expected
    assert len(result.test) == expected
    assert len(result.train) == 100 - 2 * expected


def test_single_split_rounds_half_up_per_group() -> None:
    # 2.5 → 3（偶数丸めの 2 ではない）．各グループ 10 人 × 0.25 = 2.5
    records = make_group_records(dict.fromkeys(ALL_DIR_NAMES, 10))
    result = split_train_val_test(records, val_ratio=0.25, test_ratio=0.25, seed=SEED)
    groups = patient_groups(records)
    for part in (result.val, result.test):
        counts = Counter(groups[p] for p in part)
        assert counts == dict.fromkeys(ALL_DIR_NAMES, 3)


def test_single_split_to_dict_contains_all_parts() -> None:
    result = split_train_val_test(make_records(10), val_ratio=0.2, test_ratio=0.2, seed=SEED)
    d = result.to_dict()
    assert set(d) == {"train", "val", "test"}
    assert d["train"] == list(result.train)
    assert isinstance(result, SplitResult)


def test_single_split_rejects_empty_records() -> None:
    with pytest.raises(ValueError):
        split_train_val_test([], val_ratio=0.15, test_ratio=0.15, seed=SEED)


@pytest.mark.parametrize(
    ("val_ratio", "test_ratio"), [(0.5, 0.5), (0.6, 0.5), (0.0, 0.2), (0.2, -0.1), (1.0, 0.1)]
)
def test_single_split_rejects_invalid_ratios(val_ratio: float, test_ratio: float) -> None:
    with pytest.raises(ValueError):
        split_train_val_test(make_records(20), val_ratio, test_ratio, seed=SEED)


def test_single_split_rejects_patient_with_conflicting_labels() -> None:
    records = make_records(10)
    conflict = ManifestRecord(
        image_path="purple_mild/extra.png",
        patient_id=records[0].patient_id,
        label="purple",
        severity="mild",
    )
    assert records[0].label != "purple"
    with pytest.raises(ValueError):
        split_train_val_test([*records, conflict], val_ratio=0.15, test_ratio=0.15, seed=SEED)


def test_split_logs_have_no_patient_id(caplog: pytest.LogCaptureFixture) -> None:
    records = make_records(10, prefix="SECRETPT")
    with caplog.at_level(logging.DEBUG):
        split_train_val_test(records, val_ratio=0.2, test_ratio=0.2, seed=SEED)
        make_kfold_splits(records, n_folds=5, seed=SEED)
    assert "SECRETPT" not in caplog.text


# --- K-fold 交差検証 ---


@pytest.mark.parametrize("images_per_patient", [1, 3])
def test_kfold_held_out_covers_all_patients_exactly_once(images_per_patient: int) -> None:
    records = make_records(12, images_per_patient=images_per_patient)
    folds = make_kfold_splits(records, n_folds=5, seed=SEED)

    assert len(folds) == 5
    assert [f.index for f in folds] == list(range(5))
    held_out_all = [p for f in folds for p in f.held_out]
    assert len(held_out_all) == len(set(held_out_all)), "held_out が fold 間で重複しない"
    assert set(held_out_all) == all_patients(records)


@pytest.mark.parametrize("images_per_patient", [1, 3])
def test_kfold_train_and_held_out_are_disjoint(images_per_patient: int) -> None:
    records = make_records(12, images_per_patient=images_per_patient)
    for fold in make_kfold_splits(records, n_folds=5, seed=SEED):
        assert isinstance(fold, Fold)
        assert not set(fold.train) & set(fold.held_out)
        assert set(fold.train) | set(fold.held_out) == all_patients(records)


def test_kfold_with_uneven_images_per_patient_stays_disjoint() -> None:
    # 各層化グループの患者数が fold 数（5）以上になる構成（SPEC_02: 満たないグループがあれば停止）．
    # "one" は 1 患者 1 枚（strong / mild が 5 人ずつ），"many" は 1 患者 4 枚（strong と mild が
    # 混在し，最も強い strong で層化される）
    records = make_records(10, prefix="one") + make_records(10, images_per_patient=4, prefix="many")
    for fold in make_kfold_splits(records, n_folds=5, seed=SEED):
        train_recs = select_records(records, fold.train)
        held_recs = select_records(records, fold.held_out)
        assert not {r.patient_id for r in train_recs} & {r.patient_id for r in held_recs}
        assert len(train_recs) + len(held_recs) == len(records)


def test_kfold_is_reproducible_with_same_seed() -> None:
    records = make_records(12)
    assert make_kfold_splits(records, 5, seed=SEED) == make_kfold_splits(records, 5, seed=SEED)


def test_kfold_changes_with_different_seed() -> None:
    records = make_records(12)
    assert make_kfold_splits(records, 5, seed=0) != make_kfold_splits(records, 5, seed=1)


@pytest.mark.parametrize("images_per_patient", [1, 3])
def test_kfold_does_not_depend_on_input_order(images_per_patient: int) -> None:
    records = make_records(12, images_per_patient=images_per_patient)
    expected = make_kfold_splits(records, 5, seed=SEED)
    for shuffle_seed in range(3):
        assert make_kfold_splits(shuffled(records, shuffle_seed), 5, seed=SEED) == expected


def test_kfold_is_stratified_by_class() -> None:
    per_class = {"pale": 10, "light_red": 34, "red": 12, "dark_red": 38, "purple": 15}
    records = make_records(per_class)
    for fold in make_kfold_splits(records, n_folds=5, seed=SEED):
        counts = labels_in(fold.held_out, records)
        assert set(counts) == set(CLASS_SLUGS), "各 fold の held_out に全クラスが含まれること"
        for label, n in per_class.items():
            assert abs(counts[label] - n / 5) <= 1, label


@pytest.mark.parametrize("n_folds", [1, 0, -1])
def test_kfold_rejects_n_folds_below_two(n_folds: int) -> None:
    with pytest.raises(ValueError):
        make_kfold_splits(make_records(10), n_folds=n_folds, seed=SEED)


def test_kfold_rejects_empty_records() -> None:
    with pytest.raises(ValueError):
        make_kfold_splits([], n_folds=5, seed=SEED)


def test_kfold_rejects_patient_with_conflicting_labels() -> None:
    records = make_records(10)
    conflict = ManifestRecord(
        image_path="purple_mild/extra.png",
        patient_id=records[0].patient_id,
        label="purple",
        severity="mild",
    )
    with pytest.raises(ValueError):
        make_kfold_splits([*records, conflict], n_folds=5, seed=SEED)


# --- 集計 ---


def test_patient_labels_maps_each_patient_once() -> None:
    records = make_records(3, images_per_patient=2)
    mapping = patient_labels(records)
    assert len(mapping) == 15
    for r in records:
        assert mapping[r.patient_id] == r.label


def test_count_by_class_lists_all_classes_in_id_order() -> None:
    records = make_records({"red": 2, "purple": 1}, images_per_patient=2)
    counts = count_by_class(records)
    assert list(counts) == list(CLASS_SLUGS)
    assert counts == {"pale": 0, "light_red": 0, "red": 4, "dark_red": 0, "purple": 2}


def test_count_by_class_severity_counts_pairs() -> None:
    records = make_records({"red": 3, "light_red": 2})
    counts = count_by_class_severity(records)
    assert counts == {("light_red", "normal"): 2, ("red", "strong"): 2, ("red", "mild"): 1}
    # クラス ID 順に並ぶ
    assert [label for label, _ in counts][0] == "light_red"


# --- 層化（クラス × 程度の 9 グループ．SPEC_02「データ分割」） ---

# 実データの 1/5 程度で，グループごとに人数が異なる分布
UNEVEN_GROUPS = {
    "pale_strong": 12,
    "pale_mild": 11,
    "light_red_normal": 40,
    "red_strong": 14,
    "red_mild": 13,
    "dark_red_strong": 30,
    "dark_red_mild": 20,
    "purple_strong": 16,
    "purple_mild": 15,
}


def _round_half_up(value: float) -> int:
    return int(value + 0.5)


def test_single_split_is_stratified_by_nine_groups() -> None:
    records = make_group_records(UNEVEN_GROUPS)
    result = split_train_val_test(records, val_ratio=0.15, test_ratio=0.15, seed=SEED)
    groups = patient_groups(records)

    for part in (result.train, result.val, result.test):
        counts = Counter(groups[p] for p in part)
        assert set(counts) == set(ALL_DIR_NAMES), "各分割に 9 グループすべてが含まれること"
    # val / test の人数はグループごとに「患者数 × 比率」の四捨五入
    for part in (result.val, result.test):
        counts = Counter(groups[p] for p in part)
        for group, n in UNEVEN_GROUPS.items():
            assert counts[group] == _round_half_up(n * 0.15), group


def test_kfold_is_stratified_by_nine_groups() -> None:
    records = make_group_records(UNEVEN_GROUPS)
    groups = patient_groups(records)
    for fold in make_kfold_splits(records, n_folds=5, seed=SEED):
        counts = Counter(groups[p] for p in fold.held_out)
        assert set(counts) == set(ALL_DIR_NAMES), "各 fold の held_out に 9 グループが含まれること"
        for group, n in UNEVEN_GROUPS.items():
            assert abs(counts[group] - n / 5) < 1, group


def test_single_split_with_nine_groups_is_reproducible() -> None:
    records = make_group_records(UNEVEN_GROUPS)
    a = split_train_val_test(records, val_ratio=0.15, test_ratio=0.15, seed=SEED)
    b = split_train_val_test(shuffled(records, 3), val_ratio=0.15, test_ratio=0.15, seed=SEED)
    assert a == b
    assert make_kfold_splits(records, 5, seed=SEED) == make_kfold_splits(
        shuffled(records, 3), 5, seed=SEED
    )


def test_kfold_stops_when_a_group_has_fewer_patients_than_folds() -> None:
    per_group = dict.fromkeys(ALL_DIR_NAMES, 10)
    per_group["red_mild"] = 4
    with pytest.raises(ValueError):
        make_kfold_splits(make_group_records(per_group), n_folds=5, seed=SEED)
    # fold 数以上ならちょうど通る
    per_group["red_mild"] = 5
    assert len(make_kfold_splits(make_group_records(per_group), n_folds=5, seed=SEED)) == 5


def test_single_split_stops_when_a_group_leaves_no_train_patient() -> None:
    per_group = dict.fromkeys(ALL_DIR_NAMES, 10)
    per_group["purple_mild"] = 2  # val 1・test 1（2 × 0.3 = 0.6 → 1）で train が 0 人
    with pytest.raises(ValueError):
        split_train_val_test(
            make_group_records(per_group), val_ratio=0.3, test_ratio=0.3, seed=SEED
        )


def test_patient_with_multiple_severities_uses_strongest() -> None:
    records = [
        ManifestRecord("red_mild/a_0.png", "a", "red", "mild"),
        ManifestRecord("red_strong/a_1.png", "a", "red", "strong"),
        ManifestRecord("pale_mild/b_0.png", "b", "pale", "mild"),
        ManifestRecord("light_red_normal/c_0.png", "c", "light_red", "normal"),
    ]
    assert patient_groups(records) == {
        "a": "red_strong",
        "b": "pale_mild",
        "c": "light_red_normal",
    }
    # 行の順序に依存しない
    assert patient_groups(list(reversed(records)))["a"] == "red_strong"


def test_kfold_stratifies_mixed_patients_by_strongest_severity() -> None:
    # red_mild だけの患者は 0 人で，mild と strong の両方を持つ患者が 3 人いる．
    # 最も強い strong で層化されれば red_mild グループは存在せず 5-fold が成り立つ
    # （mild で層化されると 3 人 < 5 fold で停止する）
    per_group = dict.fromkeys(ALL_DIR_NAMES, 10)
    del per_group["red_mild"]
    records = make_group_records(per_group)
    for i in range(3):
        pid = f"mixed_{i}"
        records.append(ManifestRecord(f"red_mild/{pid}_0.png", pid, "red", "mild"))
        records.append(ManifestRecord(f"red_strong/{pid}_1.png", pid, "red", "strong"))
    folds = make_kfold_splits(records, n_folds=5, seed=SEED)
    held = [p for f in folds for p in f.held_out]
    assert {f"mixed_{i}" for i in range(3)} <= set(held)
    # 単一分割でも止まらず，1 患者の画像は同じ分割に入る
    result = split_train_val_test(records, val_ratio=0.15, test_ratio=0.15, seed=SEED)
    parts = [set(result.train), set(result.val), set(result.test)]
    for i in range(3):
        assert sum(f"mixed_{i}" in part for part in parts) == 1


# --- 分割後の程度による除外（SPEC_02「程度（ややあり）の扱い」） ---

WITHOUT_MILD = ("strong", "normal")


def _mixed_records() -> list[ManifestRecord]:
    # グループ別の 1 患者 1 枚の患者に加え，1 患者に strong と mild が混在する患者を含める
    records = make_group_records(UNEVEN_GROUPS)
    for i in range(6):
        pid = f"mixed_{i}"
        label = ("pale", "red", "dark_red", "purple")[i % 4]
        records.append(ManifestRecord(f"{label}_strong/{pid}_0.png", pid, label, "strong"))
        records.append(ManifestRecord(f"{label}_mild/{pid}_1.png", pid, label, "mild"))
    return records


def _assert_exclusion_is_subset(
    records: list[ManifestRecord], split: SplitResult | Fold, part_names: list[str]
) -> None:
    everything = split_records(records, split)
    with_mild = split_records(records, split, ("strong", "mild", "normal"))
    without_mild = split_records(records, split, WITHOUT_MILD)
    assert list(everything) == part_names
    assert list(without_mild) == part_names
    # 全程度を指定したときは除外しないときと同じ
    assert with_mild == everything
    for name in part_names:
        kept, full = without_mild[name], everything[name]
        # 部分集合であり，除かれたのは mild の画像だけ，残ったのは mild 以外のすべて
        assert set(kept) <= set(full), name
        removed = set(full) - set(kept)
        assert all(r.severity == "mild" for r in removed), name
        assert set(kept) == {r for r in full if r.severity != "mild"}, name


def test_single_split_exclusion_is_subset_of_full_split() -> None:
    records = _mixed_records()
    split = split_train_val_test(records, val_ratio=0.15, test_ratio=0.15, seed=SEED)
    _assert_exclusion_is_subset(records, split, ["train", "val", "test"])
    # 全画像がいずれか 1 つの分割に入る
    everything = split_records(records, split)
    assert sum(len(v) for v in everything.values()) == len(records)


def test_kfold_exclusion_is_subset_of_full_split() -> None:
    records = _mixed_records()
    for fold in make_kfold_splits(records, n_folds=5, seed=SEED):
        _assert_exclusion_is_subset(records, fold, ["train", "held_out"])


def test_exclusion_keeps_strong_images_of_mixed_patient_in_same_split() -> None:
    records = _mixed_records()
    split = split_train_val_test(records, val_ratio=0.15, test_ratio=0.15, seed=SEED)
    without_mild = split_records(records, split, WITHOUT_MILD)
    for i in range(6):
        pid = f"mixed_{i}"
        homes = [name for name, rs in without_mild.items() if any(r.patient_id == pid for r in rs)]
        assert len(homes) == 1
        (home,) = homes
        assert pid in split.parts()[home]
        assert [r.severity for r in without_mild[home] if r.patient_id == pid] == ["strong"]


def test_mild_exclusion_keeps_all_five_classes() -> None:
    # SPEC_02: 「ややあり除外」でも 5 クラスすべてが残る
    records = make_group_records(UNEVEN_GROUPS)
    split = split_train_val_test(records, val_ratio=0.15, test_ratio=0.15, seed=SEED)
    for name, rs in split_records(records, split, WITHOUT_MILD).items():
        assert all(n > 0 for n in count_by_class(rs).values()), name


@pytest.mark.parametrize("severities", [(), ("weak",), ("strong", "unknown")])
def test_split_records_rejects_invalid_severities(severities: tuple[str, ...]) -> None:
    records = make_group_records(UNEVEN_GROUPS)
    split = split_train_val_test(records, val_ratio=0.15, test_ratio=0.15, seed=SEED)
    with pytest.raises(ValueError):
        split_records(records, split, severities)


def test_filter_by_severity_keeps_only_given_severities() -> None:
    records = _mixed_records()
    assert filter_by_severity(records, WITHOUT_MILD) == [r for r in records if r.severity != "mild"]
    assert filter_by_severity(records, ("mild",)) == [r for r in records if r.severity == "mild"]


@pytest.mark.parametrize("severities", [(), ("weak",)])
def test_filter_by_severity_rejects_invalid(severities: tuple[str, ...]) -> None:
    with pytest.raises(ValueError):
        filter_by_severity(make_group_records(UNEVEN_GROUPS), severities)


def test_count_by_group_lists_all_nine_groups() -> None:
    records = make_group_records({"red_mild": 2, "light_red_normal": 3}, images_per_patient=2)
    counts = count_by_group(records)
    assert list(counts) == list(ALL_DIR_NAMES)
    assert counts["red_mild"] == 4
    assert counts["light_red_normal"] == 6
    assert sum(counts.values()) == 10
