"""回収ホワイトリストの判定のテスト（SPEC_01「回収ホワイトリスト」・SPEC_03「pull」）.

最重要: 名前が一致するファイルだけが回収対象になり，分割結果・個票の予測・画像・manifest 等は
回収されないこと．testing ルール必須対象 4．
"""

import io
import tarfile

import pytest

from tias_judge_model.remote.whitelist import (
    check_pull_members,
    is_pull_allowed,
    parse_find_listing,
    plan_pull,
    validate_run_id,
)

RUN_ID = "20260927-120000_newton_abc1234"

ROOT_FILES = ["config.yaml", "history.csv", "metrics.json", "confusion_matrix.csv", "log.txt"]

# 列挙外（回収してはならない）ファイル．患者 ID・画像名を含みうる名前も混ぜる
NOT_ALLOWED = [
    "split.json",
    "predictions.csv",
    "per_image_predictions.csv",
    "manifest.csv",
    "confusion_matrix.png",  # figures/ 以外にある *.png
    "P0001_tongue.png",
    "P0001_tongue.jpg",
    "images/pale_1/P0001.png",
    "misclassified/P0001.png",
    "data/manifest.csv",
    "config.yml",
    "config.yaml.bak",
    "metrics.json.tmp",
    "sub/config.yaml",
    "figures/config.yaml",
    "figures/cm.jpg",
    "figures/sub/cm.png",
    "checkpoints/best.pth",
    "checkpoints/sub/best.pt",
    "best.pt",
]


class TestIsPullAllowed:
    @pytest.mark.parametrize("name", ROOT_FILES)
    def test_root_files_are_allowed(self, name: str) -> None:
        assert is_pull_allowed(name, with_checkpoints=False)

    @pytest.mark.parametrize("name", ["figures/confusion_matrix.png", "figures/learning_curve.png"])
    def test_figures_png_is_allowed(self, name: str) -> None:
        assert is_pull_allowed(name, with_checkpoints=False)

    @pytest.mark.parametrize("name", NOT_ALLOWED)
    def test_names_outside_whitelist_are_not_allowed(self, name: str) -> None:
        assert not is_pull_allowed(name, with_checkpoints=False)
        # --with-checkpoints は checkpoints/*.pt 以外の判定を変えない
        assert not is_pull_allowed(name, with_checkpoints=True)

    def test_checkpoints_only_with_flag(self) -> None:
        assert not is_pull_allowed("checkpoints/best.pt", with_checkpoints=False)
        assert is_pull_allowed("checkpoints/best.pt", with_checkpoints=True)

    @pytest.mark.parametrize(
        "name",
        [
            "../config.yaml",
            "figures/../config.yaml",
            "/config.yaml",
            "C:config.yaml",
            "C:/config.yaml",
            "figures\\cm.png",
            "..\\config.yaml",
        ],
    )
    def test_unsafe_paths_are_not_allowed(self, name: str) -> None:
        assert not is_pull_allowed(name, with_checkpoints=True)


class TestValidateRunId:
    def test_accepts_normal_run_id(self) -> None:
        assert validate_run_id(RUN_ID) == RUN_ID

    @pytest.mark.parametrize(
        "run_id", ["a/b", "a\\b", "..", "../x", "x/..", "a..b", "/abs", "runs/x", ""]
    )
    def test_rejects_run_id_with_separator_or_dotdot(self, run_id: str) -> None:
        with pytest.raises(ValueError):
            validate_run_id(run_id)


class TestPlanPull:
    def _listing(self, names: list[str], kind: str = "f") -> list[tuple[str, str]]:
        return [(kind, f"{RUN_ID}/{name}") for name in names]

    def test_selects_only_whitelisted_files(self) -> None:
        allowed = [*ROOT_FILES, "figures/cm.png"]
        listing = self._listing(allowed + NOT_ALLOWED + ["checkpoints/best.pt"])
        plan = plan_pull(listing, RUN_ID, with_checkpoints=False)
        assert sorted(plan.files) == sorted(f"{RUN_ID}/{name}" for name in allowed)
        assert plan.skipped == len(NOT_ALLOWED) + 1

    def test_with_checkpoints_includes_pt(self) -> None:
        listing = self._listing(["config.yaml", "checkpoints/best.pt", "split.json"])
        plan = plan_pull(listing, RUN_ID, with_checkpoints=True)
        assert sorted(plan.files) == [f"{RUN_ID}/checkpoints/best.pt", f"{RUN_ID}/config.yaml"]
        assert plan.skipped == 1

    def test_links_are_not_selected_even_with_whitelisted_name(self) -> None:
        listing = self._listing(["config.yaml"], kind="l")
        plan = plan_pull(listing, RUN_ID, with_checkpoints=False)
        assert plan.files == ()
        assert plan.skipped == 1

    def test_files_of_other_run_are_not_selected(self) -> None:
        plan = plan_pull([("f", "other_run/config.yaml")], RUN_ID, with_checkpoints=False)
        assert plan.files == ()

    def test_parse_find_listing_round_trip(self) -> None:
        data = f"f {RUN_ID}/config.yaml\0f {RUN_ID}/figures/a b.png\0l {RUN_ID}/x\0".encode()
        assert parse_find_listing(data) == [
            ("f", f"{RUN_ID}/config.yaml"),
            ("f", f"{RUN_ID}/figures/a b.png"),
            ("l", f"{RUN_ID}/x"),
        ]


def _regular(name: str) -> tarfile.TarInfo:
    info = tarfile.TarInfo(name)
    info.size = 0
    return info


def _link(name: str, target: str, kind: bytes) -> tarfile.TarInfo:
    info = tarfile.TarInfo(name)
    info.type = kind
    info.linkname = target
    return info


class TestCheckPullMembers:
    def test_accepts_only_whitelisted_regular_files(self) -> None:
        members = [_regular(f"{RUN_ID}/{name}") for name in [*ROOT_FILES, "figures/cm.png"]]
        assert check_pull_members(members, RUN_ID, with_checkpoints=False) == len(members)

    @pytest.mark.parametrize(
        "bad",
        [
            *(_regular(f"{RUN_ID}/{name}") for name in NOT_ALLOWED),
            _regular(f"{RUN_ID}/checkpoints/best.pt"),
            _regular("other_run/config.yaml"),
            _regular("config.yaml"),
            _regular(f"{RUN_ID}/../config.yaml"),
            _regular(f"/{RUN_ID}/config.yaml"),
            _regular(f"C:/{RUN_ID}/config.yaml"),
            _regular(f"{RUN_ID}\\config.yaml"),
            _link(f"{RUN_ID}/config.yaml", "/etc/passwd", tarfile.SYMTYPE),
            _link(f"{RUN_ID}/metrics.json", f"{RUN_ID}/config.yaml", tarfile.LNKTYPE),
        ],
    )
    def test_rejects_whole_archive_if_any_member_is_bad(self, bad: tarfile.TarInfo) -> None:
        members = [_regular(f"{RUN_ID}/config.yaml"), bad, _regular(f"{RUN_ID}/log.txt")]
        with pytest.raises(ValueError):
            check_pull_members(members, RUN_ID, with_checkpoints=False)

    def test_error_message_does_not_contain_member_names(self) -> None:
        secret = "P0001_yamada_tongue"
        members = [
            _regular(f"{RUN_ID}/{secret}.png"),
            _link(f"{RUN_ID}/{secret}_link", "/etc/passwd", tarfile.SYMTYPE),
            _regular(f"../{secret}.csv"),
        ]
        with pytest.raises(ValueError) as excinfo:
            check_pull_members(members, RUN_ID, with_checkpoints=False)
        assert secret not in str(excinfo.value)

    def test_checkpoints_accepted_only_with_flag(self) -> None:
        members = [_regular(f"{RUN_ID}/checkpoints/best.pt")]
        assert check_pull_members(members, RUN_ID, with_checkpoints=True) == 1
        with pytest.raises(ValueError):
            check_pull_members(members, RUN_ID, with_checkpoints=False)

    def test_members_from_real_tar_file(self) -> None:
        buffer = io.BytesIO()
        with tarfile.open(fileobj=buffer, mode="w") as tar:
            tar.addfile(_regular(f"{RUN_ID}/config.yaml"), io.BytesIO(b""))
            tar.addfile(_link(f"{RUN_ID}/log.txt", "/etc/shadow", tarfile.SYMTYPE))
        buffer.seek(0)
        with tarfile.open(fileobj=buffer, mode="r") as tar, pytest.raises(ValueError):
            check_pull_members(tar.getmembers(), RUN_ID, with_checkpoints=False)
