"""配布用ビルド設定の検証（PLAN_02 ステップ 8「配布」・ENV_04「配布用ビルド」）．

ステップ 8 の完了条件は「単体 exe で起動・診断できる」であり，ENV_04 に記載された
ビルド手順（`uv run pyinstaller TIASjudge.spec --noconfirm` → `dist/TIASjudge.exe`）が
成立するための設定を検証する．

※ exe の実ビルドは数分かかるためテストしない（人間が ENV_04 の手順で確認する）．
ここでは spec・エントリスクリプト・同梱リソースの静的な整合性のみを対象とする．
※ 絶対パスのハードコード禁止（ENV_02「環境再現性のルール」・coding-style.md）も検証する．
"""

from __future__ import annotations

import ast
import re
import subprocess
import tomllib
from pathlib import Path

import pytest

SPEC_FILE_NAME = "TIASjudge.spec"
ENTRY_SCRIPT = "packaging/entry.py"
ICON_FILE = "packaging/TIASjudge.ico"

#: Windows / POSIX の絶対パスらしき記述（環境依存の埋め込みを検出する）
ABSOLUTE_PATH_PATTERN = re.compile(r"[A-Za-z]:[\\/]|(?<![\w.])/(?:home|Users)/")


@pytest.fixture(scope="session")
def spec_text(repo_root: Path) -> str:
    return (repo_root / SPEC_FILE_NAME).read_text(encoding="utf-8")


@pytest.fixture(scope="session")
def entry_text(repo_root: Path) -> str:
    return (repo_root / ENTRY_SCRIPT).read_text(encoding="utf-8")


@pytest.fixture(scope="session")
def pyproject(repo_root: Path) -> dict:
    return tomllib.loads((repo_root / "pyproject.toml").read_text(encoding="utf-8"))


@pytest.fixture(scope="session")
def env04_text(repo_root: Path) -> str:
    return (repo_root / "docs/02_ENV/ENV_04_開発コマンド.md").read_text(encoding="utf-8")


class TestPackagingFiles:
    """配布に必要なファイルが揃っていること（ENV_04「配布用ビルド」）．"""

    @pytest.mark.parametrize("relative_path", [SPEC_FILE_NAME, ENTRY_SCRIPT, ICON_FILE])
    def test_file_exists(self, repo_root: Path, relative_path: str) -> None:
        assert (repo_root / relative_path).is_file()

    @pytest.mark.parametrize("relative_path", [SPEC_FILE_NAME, ENTRY_SCRIPT, ICON_FILE])
    def test_file_is_not_git_ignored(self, repo_root: Path, relative_path: str) -> None:
        """ビルドに必要なファイルは Git 管理対象（.gitignore で除外されていない）．"""
        result = subprocess.run(
            ["git", "check-ignore", relative_path],
            cwd=repo_root,
            capture_output=True,
            text=True,
        )
        assert result.returncode != 0, f"{relative_path} が .gitignore で除外されている"

    @pytest.mark.parametrize("relative_path", ["build", "dist"])
    def test_build_outputs_are_git_ignored(self, repo_root: Path, relative_path: str) -> None:
        """ビルド成果物は Git 管理外（ENV_04「配布用ビルド」）．"""
        result = subprocess.run(
            ["git", "check-ignore", relative_path],
            cwd=repo_root,
            capture_output=True,
            text=True,
        )
        assert result.returncode == 0, f"{relative_path} が .gitignore で除外されていない"

    def test_icon_is_a_windows_icon_file(self, repo_root: Path) -> None:
        """アイコンは実体のある ICO ファイル（先頭 4 バイトが ICO のシグネチャ）．"""
        header = (repo_root / ICON_FILE).read_bytes()[:4]
        assert header == b"\x00\x00\x01\x00"


class TestPyInstallerDependency:
    """PyInstaller は開発用依存として宣言する（実行時依存にしない）．"""

    def test_pyinstaller_is_a_dev_dependency(self, pyproject: dict) -> None:
        dev = [
            dependency.split(">")[0].split("<")[0].split("=")[0].strip().lower()
            for dependency in pyproject["dependency-groups"]["dev"]
        ]
        assert "pyinstaller" in dev

    def test_pyinstaller_is_not_a_runtime_dependency(self, pyproject: dict) -> None:
        runtime = [
            dependency.split(">")[0].split("<")[0].split("=")[0].strip().lower()
            for dependency in pyproject["project"]["dependencies"]
        ]
        assert "pyinstaller" not in runtime


class TestSpecFile:
    """`TIASjudge.spec` の内容（ENV_04「配布用ビルド」の記載と一致すること）．"""

    def test_is_valid_python_syntax(self, spec_text: str) -> None:
        """spec は Python として解釈される（PyInstaller が exec する）．"""
        assert ast.parse(spec_text) is not None

    def test_entry_script_is_referenced(self, spec_text: str) -> None:
        assert "entry.py" in spec_text

    def test_executable_name_is_tiasjudge(self, spec_text: str) -> None:
        """成果物は `dist/TIASjudge.exe`．"""
        assert re.search(r'name\s*=\s*["\']TIASjudge["\']', spec_text)

    def test_console_is_disabled(self, spec_text: str) -> None:
        """GUI アプリのためコンソールを出さない．"""
        assert re.search(r"console\s*=\s*False", spec_text)

    def test_is_onefile_build(self, spec_text: str) -> None:
        """onefile（`COLLECT` を作らず EXE にバイナリを含める）．"""
        assert "COLLECT(" not in spec_text
        assert "a.binaries" in spec_text

    def test_bundles_training_data(self, spec_text: str) -> None:
        """`data/data.csv` を `data` として同梱する（実行時に `_MEIPASS/data` に展開）．"""
        assert "data.csv" in spec_text
        assert re.search(r'["\']data["\']\s*\)', spec_text)

    def test_bundles_icon(self, spec_text: str) -> None:
        """アイコンを同梱する（実行時のウィンドウアイコン用）．"""
        assert "TIASjudge.ico" in spec_text
        assert re.search(r'["\']packaging["\']', spec_text)

    def test_sets_the_executable_icon(self, spec_text: str) -> None:
        """exe のファイルアイコンにも同じ ico を設定する．"""
        assert re.search(r"icon\s*=\s*", spec_text)

    def test_uses_specpath_relative_paths(self, spec_text: str) -> None:
        """パスは spec の位置（`SPECPATH`）からの相対で組み立てる．"""
        assert "SPECPATH" in spec_text

    def test_has_no_absolute_path(self, spec_text: str) -> None:
        """絶対パスのハードコード禁止（ENV_02）．"""
        assert ABSOLUTE_PATH_PATTERN.search(spec_text) is None


class TestEntryScript:
    """`packaging/entry.py` は `tiasjudge.__main__.main` を呼ぶ薄いラッパであること．"""

    def test_is_valid_python_syntax(self, entry_text: str) -> None:
        assert ast.parse(entry_text) is not None

    def test_imports_main_from_the_package_entry_point(self, entry_text: str) -> None:
        """`python -m tiasjudge` と同じ入口を使う（実装が二重化しない）．"""
        imported = {
            (node.module, alias.name)
            for node in ast.walk(ast.parse(entry_text))
            if isinstance(node, ast.ImportFrom)
            for alias in node.names
        }
        assert ("tiasjudge.__main__", "main") in imported

    def test_calls_freeze_support(self, entry_text: str) -> None:
        """onefile exe が子プロセスを再起動しないようにする（PyInstaller 推奨）．"""
        assert "freeze_support" in entry_text

    def test_exits_with_the_return_code_of_main(self, entry_text: str) -> None:
        """`main()` の戻り値をプロセスの終了コードにする．"""
        assert re.search(r"sys\.exit\(\s*main\(\s*\)\s*\)", entry_text)

    def test_runs_only_under_the_main_guard(self, entry_text: str) -> None:
        """import しただけでは起動しない（`if __name__ == "__main__"` で囲う）．"""
        tree = ast.parse(entry_text)
        calls_at_module_level = [
            node
            for node in tree.body
            if isinstance(node, ast.Expr) and isinstance(node.value, ast.Call)
        ]
        assert calls_at_module_level == []
        assert '__name__ == "__main__"' in entry_text or "__name__ == '__main__'" in entry_text

    def test_has_no_absolute_path(self, entry_text: str) -> None:
        assert ABSOLUTE_PATH_PATTERN.search(entry_text) is None


class TestPackagingDocumentation:
    """ENV_04 に配布手順が記載されていること（CLAUDE.md「開発コマンド」）．"""

    @pytest.mark.parametrize(
        "expected",
        [
            "pyinstaller TIASjudge.spec",
            "dist/TIASjudge.exe",
            "--verify-data",
            "TIASJUDGE_DATA_CSV",
        ],
    )
    def test_documents_the_build_commands(self, env04_text: str, expected: str) -> None:
        assert expected in env04_text
