"""remote/files.py の仕様ベーステスト（push で送るファイルの選定）．

仕様出典:
- docs/02_ENV/ENV_04_開発コマンド.md「研究室サーバでの実行」
- docs/02_ENV/ENV_02_環境構築手順.md「コードの転送」（送らないもの / 送るもの）
- 実装サマリー 仕様 1（除外規則・output/ の拒否・Windows 絶対パスの警告・--prune の対象）

テスト方針:
- tmp_path に小さな疑似プロジェクトを実ファイルで作る（ファイル I/O はモックしない）
"""

from __future__ import annotations

import os
from pathlib import Path

import pytest

from sm_tias_lightsim.remote import files

# 疑似プロジェクトのファイル構成（arcname → 内容）
_PROJECT_FILES: dict[str, str] = {
    "pyproject.toml": "[project]\nname = 'x'\n",
    "src/sm_tias_lightsim/__init__.py": "",
    "src/sm_tias_lightsim/remote/commands.py": "x = 1\n",
    "src/sm_tias_lightsim/__pycache__/__init__.cpython-312.pyc": "pyc",
    "src/sm_tias_lightsim/output/keep.py": "# 直下以外の output は送る\n",
    "tests/test_a.py": "def test_a(): pass\n",
    "tests/.pytest_cache/v/cache": "c",
    "scripts/run.py": "print(1)\n",
    ".venv/Scripts/python.exe": "exe",
    ".git/HEAD": "ref: refs/heads/main\n",
    ".ruff_cache/0.1/x": "r",
    ".pytest_cache/README.md": "p",
    "output/offaxis/luminance.npy": "npy",
    "output/_remote_logs/a.log": "log",
    "configs/scene.yaml": "name: a\n",
    "configs/meshes/前側.stl": "solid 前側\n",
    "configs/_tmp_offaxis/scene_cube_L120.yaml": "name: b\n",
    "configs/_tmp_offaxis/meshes/立方体_L120.stl": "solid\n",
    "configs/_tmp_offaxis/__pycache__/z.pyc": "pyc",
    "configs/_tmp_fn_full_base.yaml": "name: c\n",
    "lightsim/luminance_maps/a.npy": "npy",
    "reference_measurement/r.png": "png",
    "REFERENCE/paper.pdf": "pdf",
    "docs/a.md": "# a\n",
}

# 既定の push で送られるべきもの（ENV_02「送らないもの」を除いた全体）
_EXPECTED_BASE = {
    "pyproject.toml",
    "src/sm_tias_lightsim/__init__.py",
    "src/sm_tias_lightsim/remote/commands.py",
    "src/sm_tias_lightsim/output/keep.py",
    "tests/test_a.py",
    "scripts/run.py",
    "configs/scene.yaml",
    "configs/meshes/前側.stl",
    "lightsim/luminance_maps/a.npy",
    "reference_measurement/r.png",
    "REFERENCE/paper.pdf",
    "docs/a.md",
}


@pytest.fixture
def project(tmp_path: Path) -> Path:
    root = tmp_path / "proj"
    for rel, content in _PROJECT_FILES.items():
        path = root / rel
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(content, encoding="utf-8")
    return root


def _arcnames(entries) -> list[str]:
    return [e.arcname for e in entries]


# ---------------------------------------------------------------------------
# is_excluded
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    "rel",
    [
        ".venv",
        ".venv/Scripts/python.exe",
        ".git/HEAD",
        "__pycache__/x.pyc",
        "src/sm_tias_lightsim/__pycache__/a.pyc",
        "tests/.pytest_cache/v",
        "scripts/.ruff_cache/x",
        "scripts/sub/.venv/lib/x.py",  # どの階層でも除外
        "src/a/.git/config",
        "output",
        "output/offaxis/luminance.npy",
        "configs/_tmp_offaxis",
        "configs/_tmp_offaxis/scene.yaml",
        "configs/_tmp_offaxis/meshes/x.stl",
        "configs/_tmp_fn_full_base.yaml",  # ファイルでも除外
        "configs/_tmp_",
    ],
)
def test_is_excluded_true(rel):
    assert files.is_excluded(rel) is True


@pytest.mark.parametrize(
    "rel",
    [
        "",
        "pyproject.toml",
        "src/sm_tias_lightsim/cli/remote.py",
        "src/sm_tias_lightsim/output/keep.py",  # 直下以外の output は送る
        "tests/output/x.py",
        "outputs/x",
        "output_old/x",
        "configs/scene.yaml",
        "configs/meshes/前側.stl",
        "configs/sub/_tmp_x.yaml",  # configs 直下の _tmp_* だけが対象
        "configs/tmp_x.yaml",
        "lightsim/luminance_maps/a.npy",
        "reference_measurement/r.png",
        "REFERENCE/paper.pdf",
        ".gitignore",
        ".github/workflows/ci.yml",
        "venv/x",
    ],
)
def test_is_excluded_false(rel):
    assert files.is_excluded(rel) is False


# ---------------------------------------------------------------------------
# collect_push_entries
# ---------------------------------------------------------------------------


class TestCollectPushEntries:
    def test_base_push_matches_spec(self, project):
        """既定の push は .venv/.git/キャッシュ/output/configs/_tmp_* を除く全体を送ること．"""
        entries = files.collect_push_entries(project, include_base=True)
        assert set(_arcnames(entries)) == _EXPECTED_BASE

    def test_entries_are_sorted_posix_and_sized(self, project):
        entries = files.collect_push_entries(project, include_base=True)
        names = _arcnames(entries)
        assert names == sorted(names)
        for entry in entries:
            assert "\\" not in entry.arcname
            assert entry.path.is_file()
            assert entry.path == (project / entry.arcname).resolve()
            assert entry.size == entry.path.stat().st_size

    def test_japanese_file_names_are_kept(self, project):
        entries = files.collect_push_entries(project, include_base=True)
        assert "configs/meshes/前側.stl" in _arcnames(entries)

    def test_include_adds_excluded_tmp_dir(self, project):
        """--include で configs/_tmp_* を一緒に送れること（キャッシュ類は除く）．"""
        entries = files.collect_push_entries(
            project, include_base=True, extra_paths=["configs/_tmp_offaxis"]
        )
        names = set(_arcnames(entries))
        assert names == _EXPECTED_BASE | {
            "configs/_tmp_offaxis/scene_cube_L120.yaml",
            "configs/_tmp_offaxis/meshes/立方体_L120.stl",
        }

    def test_include_single_file(self, project):
        entries = files.collect_push_entries(
            project, include_base=True, extra_paths=["configs/_tmp_fn_full_base.yaml"]
        )
        assert "configs/_tmp_fn_full_base.yaml" in _arcnames(entries)

    def test_only_sends_just_the_given_paths(self, project):
        """--only は指定したパスだけを送る（コード本体は送らない）．"""
        entries = files.collect_push_entries(
            project, include_base=False, extra_paths=["configs/_tmp_offaxis"]
        )
        assert _arcnames(entries) == [
            "configs/_tmp_offaxis/meshes/立方体_L120.stl",
            "configs/_tmp_offaxis/scene_cube_L120.yaml",
        ]

    def test_only_with_nothing_returns_empty(self, project):
        assert files.collect_push_entries(project, include_base=False) == []

    def test_overlapping_paths_are_deduplicated(self, project):
        entries = files.collect_push_entries(
            project, include_base=True, extra_paths=["configs", "configs/scene.yaml"]
        )
        names = _arcnames(entries)
        assert len(names) == len(set(names))
        assert names.count("configs/scene.yaml") == 1

    def test_absolute_path_inside_project_is_accepted(self, project):
        entries = files.collect_push_entries(
            project,
            include_base=False,
            extra_paths=[str(project / "configs" / "_tmp_fn_full_base.yaml")],
        )
        assert _arcnames(entries) == ["configs/_tmp_fn_full_base.yaml"]

    @pytest.mark.skipif(os.name != "nt", reason="Windows の区切り文字の扱い")
    def test_windows_backslash_path_is_accepted(self, project):
        entries = files.collect_push_entries(
            project, include_base=False, extra_paths=["configs\\_tmp_offaxis"]
        )
        assert "configs/_tmp_offaxis/scene_cube_L120.yaml" in _arcnames(entries)

    @pytest.mark.parametrize(
        "extra",
        [
            "output/offaxis",  # output/ 配下は送信拒否
            "output",
            "output/offaxis/luminance.npy",
            "configs/../output/offaxis",
            "../outside",  # プロジェクト外
            ".",  # プロジェクト全体
            "",
            "configs/_tmp_does_not_exist",  # 存在しない
        ],
    )
    def test_invalid_extra_paths_raise(self, project, extra):
        (project.parent / "outside").mkdir(exist_ok=True)
        with pytest.raises(ValueError):
            files.collect_push_entries(project, include_base=True, extra_paths=[extra])

    @pytest.mark.skipif(os.name != "nt", reason="Windows の区切り文字の扱い")
    def test_windows_backslash_output_is_rejected(self, project):
        with pytest.raises(ValueError):
            files.collect_push_entries(project, include_base=False, extra_paths=["output\\offaxis"])

    def test_absolute_path_outside_project_is_rejected(self, project):
        outside = project.parent / "elsewhere.yaml"
        outside.write_text("x", encoding="utf-8")
        with pytest.raises(ValueError):
            files.collect_push_entries(project, include_base=False, extra_paths=[str(outside)])


# ---------------------------------------------------------------------------
# find_project_root
# ---------------------------------------------------------------------------


class TestFindProjectRoot:
    def test_finds_root_from_nested_dir(self, project):
        start = project / "src" / "sm_tias_lightsim" / "remote"
        assert files.find_project_root(start) == project.resolve()

    def test_finds_root_from_root_itself(self, project):
        assert files.find_project_root(project) == project.resolve()

    def test_raises_outside_project(self, tmp_path):
        lonely = tmp_path / "lonely"
        lonely.mkdir()
        with pytest.raises(ValueError):
            files.find_project_root(lonely)

    def test_pyproject_alone_is_not_enough(self, tmp_path):
        other = tmp_path / "other"
        other.mkdir()
        (other / "pyproject.toml").write_text("", encoding="utf-8")
        with pytest.raises(ValueError):
            files.find_project_root(other)


# ---------------------------------------------------------------------------
# find_yaml_with_windows_paths（送る YAML に Windows 絶対パスがあれば警告）
# ---------------------------------------------------------------------------


class TestFindYamlWithWindowsPaths:
    def _entry(self, root: Path, rel: str, content: str | bytes) -> files.PushEntry:
        path = root / rel
        path.parent.mkdir(parents=True, exist_ok=True)
        if isinstance(content, bytes):
            path.write_bytes(content)
        else:
            path.write_text(content, encoding="utf-8")
        return files.PushEntry(path=path, arcname=rel, size=path.stat().st_size)

    @pytest.mark.parametrize(
        "content",
        [
            "geometry:\n  meshes:\n  - id: a\n    path: C:\\Users\\r\\meshes\\前側.stl\n",
            "geometry:\n  meshes:\n    - path: 'D:/data/a.stl'\n",
            'geometry:\n  meshes:\n    - id: a\n      path: "c:\\\\x\\\\a.stl"\n',
        ],
    )
    def test_detects_windows_absolute_paths(self, tmp_path, content):
        entry = self._entry(tmp_path, "configs/_tmp_x/scene.yaml", content)
        assert files.find_yaml_with_windows_paths([entry]) == ["configs/_tmp_x/scene.yaml"]

    @pytest.mark.parametrize(
        "content",
        [
            "geometry:\n  meshes:\n  - id: a\n    path: meshes/前側.stl\n",
            "geometry:\n  meshes:\n  - id: a\n    path: ../meshes/前側.stl\n",
            "geometry:\n  meshes:\n  - id: a\n    path: ../../../../home/x/configs/meshes/a.stl\n",
            "geometry:\n  meshes:\n  - id: a\n    path: /home/hasegawa/a.stl\n",
            "description: 'C:\\Users は path ではない'\n",
        ],
    )
    def test_ignores_portable_paths(self, tmp_path, content):
        entry = self._entry(tmp_path, "configs/scene.yaml", content)
        assert files.find_yaml_with_windows_paths([entry]) == []

    def test_yml_extension_is_checked(self, tmp_path):
        entry = self._entry(tmp_path, "configs/a.yml", "path: C:\\a.stl\n")
        assert files.find_yaml_with_windows_paths([entry]) == ["configs/a.yml"]

    def test_non_yaml_files_are_ignored(self, tmp_path):
        entry = self._entry(tmp_path, "docs/a.md", "path: C:\\a.stl\n")
        assert files.find_yaml_with_windows_paths([entry]) == []

    def test_undecodable_yaml_is_skipped_without_error(self, tmp_path):
        entry = self._entry(tmp_path, "configs/bin.yaml", b"\xff\xfe\x00path: C:\\a\x80")
        assert files.find_yaml_with_windows_paths([entry]) == []

    def test_returns_only_offending_files(self, tmp_path):
        good = self._entry(tmp_path, "configs/good.yaml", "path: meshes/a.stl\n")
        bad = self._entry(tmp_path, "configs/_tmp_a/bad.yaml", "  - path: C:\\a.stl\n")
        assert files.find_yaml_with_windows_paths([good, bad]) == ["configs/_tmp_a/bad.yaml"]


# ---------------------------------------------------------------------------
# compute_prune_targets（--prune: src/ tests/ scripts/ だけ．output/・configs/ は消さない）
# ---------------------------------------------------------------------------


class TestComputePruneTargets:
    def test_prune_dirs_are_code_only(self):
        assert tuple(files.PRUNE_DIRS) == ("src", "tests", "scripts")

    def test_returns_remote_files_missing_locally(self):
        remote = [
            "src/sm_tias_lightsim/a.py",
            "src/sm_tias_lightsim/old.py",
            "tests/test_old.py",
            "scripts/gone.py",
            "scripts/run.py",
        ]
        local = {"src/sm_tias_lightsim/a.py", "scripts/run.py", "configs/scene.yaml"}
        assert files.compute_prune_targets(remote, local) == [
            "scripts/gone.py",
            "src/sm_tias_lightsim/old.py",
            "tests/test_old.py",
        ]

    def test_cache_files_are_not_targeted(self):
        remote = [
            "src/sm_tias_lightsim/__pycache__/a.cpython-312.pyc",
            "tests/.pytest_cache/v/x",
            "scripts/.ruff_cache/y",
        ]
        assert files.compute_prune_targets(remote, set()) == []

    def test_nothing_to_prune(self):
        remote = ["src/a.py"]
        assert files.compute_prune_targets(remote, {"src/a.py"}) == []

    @pytest.mark.parametrize(
        "bad",
        [
            "output/offaxis/luminance.npy",
            "configs/_tmp_offaxis/scene.yaml",
            "configs/scene.yaml",
            "/etc/passwd",
            "src/../output/x",
            "../src/x",
            "src",  # ディレクトリそのもの
            "lightsim/a.npy",
        ],
    )
    def test_never_targets_outside_prune_dirs(self, bad):
        """output/・configs/ 等のパスを受け取っても削除対象にせず中断すること．"""
        with pytest.raises(RuntimeError):
            files.compute_prune_targets(["src/a.py", bad], set())
