"""配布 exe の «同梱レイアウト» と «実行時のパス解決» の整合（PLAN_02 ステップ 8）．

`tests/test_packaging.py` が spec・エントリスクリプトの静的な体裁を見るのに対し，
本ファイルは «ビルド時に何処へ同梱されるか（`TIASjudge.spec`）» と
«実行時に何処を見るか（`default_training_data_path` / `default_icon_path`）» が
一致することを検証する．ここがずれると，テストは通るのに exe だけが動かない．

- ステップ 8 の完了条件は「単体 exe で起動・診断できる」（PLAN_02）
- 学習データは SPEC_02 の診断に必須（`data/data.csv`）
- 絶対パスのハードコード禁止（ENV_02「環境再現性のルール」）

※ 実ビルド（数分）は行わず，PyInstaller が spec を exec する動作を模して設定値を取り出し，
frozen 実行は `sys.frozen` / `sys._MEIPASS` を差し替えて模す．
"""

from __future__ import annotations

import importlib.util
import shutil
import sys
from pathlib import Path
from typing import Any

import pytest

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

#: exe 実行時に学習データ・アイコンを探す場所（`_MEIPASS` 配下の相対パス）
BUNDLED_DATA_RELATIVE = Path("data") / "data.csv"
BUNDLED_ICON_RELATIVE = Path("packaging") / "TIASjudge.ico"


# --- spec の evaluate（PyInstaller が exec する動作を模す） ---


class _Recorder:
    """`Analysis` / `PYZ` / `EXE` の呼び出し引数を記録するスタブ．"""

    def __init__(self, name: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> None:
        self.name = name
        self.args = args
        self.kwargs = kwargs
        # Analysis の戻り値として参照される属性（`a.scripts` 等）を用意する
        self.scripts = f"{name}.scripts"
        self.pure = f"{name}.pure"
        self.binaries = f"{name}.binaries"
        self.datas = f"{name}.datas"


@pytest.fixture(scope="module")
def evaluated_spec(repo_root: Path) -> dict[str, _Recorder]:
    """`TIASjudge.spec` を PyInstaller と同じように exec し，各呼び出しを記録して返す．"""
    calls: dict[str, _Recorder] = {}

    def make(name: str):
        def factory(*args: Any, **kwargs: Any) -> _Recorder:
            recorder = _Recorder(name, args, kwargs)
            calls[name] = recorder
            return recorder

        return factory

    namespace: dict[str, Any] = {
        "SPECPATH": str(repo_root),
        "DISTPATH": str(repo_root / "dist"),
        "workpath": str(repo_root / "build"),
        "Analysis": make("Analysis"),
        "PYZ": make("PYZ"),
        "EXE": make("EXE"),
        "COLLECT": make("COLLECT"),
        "BUNDLE": make("BUNDLE"),
        "__file__": str(repo_root / SPEC_FILE_NAME),
    }
    source = (repo_root / SPEC_FILE_NAME).read_text(encoding="utf-8")
    exec(compile(source, SPEC_FILE_NAME, "exec"), namespace)  # noqa: S102 - spec は Python
    return calls


def _analysis_kwarg(evaluated_spec: dict[str, _Recorder], key: str) -> Any:
    analysis = evaluated_spec["Analysis"]
    return analysis.kwargs.get(key)


class TestSpecEvaluates:
    """spec が PyInstaller の実行手順（Analysis → PYZ → EXE）どおりに評価できること．"""

    def test_calls_analysis_pyz_and_exe(self, evaluated_spec: dict[str, _Recorder]) -> None:
        assert set(evaluated_spec) == {"Analysis", "PYZ", "EXE"}

    def test_entry_script_exists(self, evaluated_spec: dict[str, _Recorder]) -> None:
        """エントリスクリプトは実在するファイルを指す（ビルドが失敗しない）．"""
        scripts = evaluated_spec["Analysis"].args[0]
        assert [Path(script).name for script in scripts] == ["entry.py"]
        assert all(Path(script).is_file() for script in scripts)

    def test_search_path_includes_the_source_root(
        self, evaluated_spec: dict[str, _Recorder], repo_root: Path
    ) -> None:
        """`src` レイアウトのため `pathex` に `src/` が必要（無いと `tiasjudge` を辿れない）．"""
        pathex = [Path(entry).resolve() for entry in _analysis_kwarg(evaluated_spec, "pathex")]
        assert (repo_root / "src").resolve() in pathex

    def test_every_referenced_path_is_inside_the_repository(
        self, evaluated_spec: dict[str, _Recorder], repo_root: Path
    ) -> None:
        """パスは `SPECPATH` 相対で組む（環境依存の絶対パスを埋め込まない．ENV_02）．"""
        referenced = [Path(script) for script in evaluated_spec["Analysis"].args[0]]
        referenced += [Path(entry) for entry in _analysis_kwarg(evaluated_spec, "pathex")]
        referenced += [Path(source) for source, _ in _analysis_kwarg(evaluated_spec, "datas")]
        referenced.append(Path(evaluated_spec["EXE"].kwargs["icon"]))
        for path in referenced:
            assert path.resolve().is_relative_to(repo_root.resolve()), path


@pytest.fixture(scope="module")
def bundled(evaluated_spec: dict[str, _Recorder]) -> dict[Path, Path]:
    """`{展開後の相対パス: 元ファイル}` を返す．"""
    mapping: dict[Path, Path] = {}
    for source, destination in _analysis_kwarg(evaluated_spec, "datas"):
        source_path = Path(source)
        mapping[Path(destination) / source_path.name] = source_path
    return mapping


class TestBundledResources:
    """同梱先（`datas` の展開先）が実行時の探索先と一致すること．"""

    def test_sources_exist(self, bundled: dict[Path, Path]) -> None:
        for source in bundled.values():
            assert source.is_file(), source

    def test_training_data_is_bundled_where_the_app_looks_for_it(
        self, bundled: dict[Path, Path], data_csv_path: Path
    ) -> None:
        """`_MEIPASS/data/data.csv`（`default_training_data_path` の探索先）に展開される．"""
        assert BUNDLED_DATA_RELATIVE in bundled
        assert bundled[BUNDLED_DATA_RELATIVE].resolve() == data_csv_path.resolve()

    def test_icon_is_bundled_where_the_app_looks_for_it(self, bundled: dict[Path, Path]) -> None:
        """`_MEIPASS/packaging/TIASjudge.ico`（`default_icon_path` の探索先）に展開される．"""
        from tiasjudge.gui.main_window import ICON_DIR_NAME, ICON_FILE_NAME

        assert Path(ICON_DIR_NAME) / ICON_FILE_NAME == BUNDLED_ICON_RELATIVE
        assert BUNDLED_ICON_RELATIVE in bundled

    def test_the_executable_icon_is_the_bundled_icon(
        self, evaluated_spec: dict[str, _Recorder], bundled: dict[Path, Path]
    ) -> None:
        """exe のファイルアイコンとウィンドウアイコンは同一ファイル（旧実装と同じ 1 つ）．"""
        exe_icon = Path(evaluated_spec["EXE"].kwargs["icon"]).resolve()
        assert exe_icon == bundled[BUNDLED_ICON_RELATIVE].resolve()


class TestExecutableOptions:
    """ENV_04「配布用ビルド」に記載した成果物の形（onefile・コンソール非表示）．"""

    def test_is_named_tiasjudge(self, evaluated_spec: dict[str, _Recorder]) -> None:
        assert evaluated_spec["EXE"].kwargs["name"] == "TIASjudge"

    def test_console_is_disabled(self, evaluated_spec: dict[str, _Recorder]) -> None:
        assert evaluated_spec["EXE"].kwargs["console"] is False

    def test_is_onefile(self, evaluated_spec: dict[str, _Recorder]) -> None:
        """binaries・datas を EXE に渡す（`COLLECT` を作らない）＝ 単体 exe．"""
        exe_arguments = evaluated_spec["EXE"].args
        assert "Analysis.binaries" in exe_arguments
        assert "Analysis.datas" in exe_arguments
        assert "COLLECT" not in evaluated_spec

    def test_does_not_exclude_the_gui_toolkit(self, evaluated_spec: dict[str, _Recorder]) -> None:
        """PySide6・OpenCV・NumPy を除外していない（除外すると exe が起動しない）．"""
        excludes = {name.lower() for name in _analysis_kwarg(evaluated_spec, "excludes")}
        assert not ({"pyside6", "cv2", "opencv-python", "numpy"} & excludes)


# --- エントリスクリプト（`packaging/entry.py`） ---


def _load_entry_module(repo_root: Path) -> Any:
    """`packaging/entry.py` を通常の import と同じように読み込む．"""
    spec = importlib.util.spec_from_file_location(
        "tiasjudge_entry_under_test", repo_root / ENTRY_SCRIPT
    )
    assert spec is not None and spec.loader is not None
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    return module


class TestEntryScriptBehaviour:
    """エントリは `python -m tiasjudge` と同じ入口を呼ぶこと（実装の二重化を防ぐ）．"""

    def test_main_is_the_package_entry_point(self, repo_root: Path) -> None:
        from tiasjudge.__main__ import main

        assert _load_entry_module(repo_root).main is main

    def test_import_does_not_start_the_application(
        self, monkeypatch: pytest.MonkeyPatch, repo_root: Path
    ) -> None:
        """import しただけでは起動しない（`__main__` ガードの中でのみ実行する）．"""
        import tiasjudge.__main__ as entry_point

        def fail() -> int:
            raise AssertionError("import 時にアプリを起動してはならない")

        monkeypatch.setattr(entry_point, "main", fail)
        _load_entry_module(repo_root)

    def test_running_as_a_script_exits_with_the_return_code_of_main(
        self, monkeypatch: pytest.MonkeyPatch, repo_root: Path
    ) -> None:
        """スクリプトとして実行すると `main()` の戻り値がプロセスの終了コードになる．"""
        import tiasjudge.__main__ as entry_point

        monkeypatch.setattr(entry_point, "main", lambda: 7)
        source = (repo_root / ENTRY_SCRIPT).read_text(encoding="utf-8")
        namespace: dict[str, Any] = {
            "__name__": "__main__",
            "__file__": str(repo_root / ENTRY_SCRIPT),
        }
        with pytest.raises(SystemExit) as exit_info:
            exec(compile(source, ENTRY_SCRIPT, "exec"), namespace)  # noqa: S102
        assert exit_info.value.code == 7


# --- frozen（exe）実行時のふるまい ---


def _freeze(monkeypatch: pytest.MonkeyPatch, meipass: Path) -> None:
    monkeypatch.setattr(sys, "frozen", True, raising=False)
    monkeypatch.setattr(sys, "_MEIPASS", str(meipass), raising=False)


class TestFrozenRuntime:
    """exe と同じ配置（`_MEIPASS` 配下）だけで起動・診断できること（ステップ 8 完了条件）．"""

    @pytest.fixture(autouse=True)
    def _clear_override(self, monkeypatch: pytest.MonkeyPatch) -> None:
        from tiasjudge.gui.main_window import TRAINING_DATA_ENV_VAR

        monkeypatch.delenv(TRAINING_DATA_ENV_VAR, raising=False)

    @pytest.fixture
    def bundle(self, tmp_path: Path, repo_root: Path, data_csv_path: Path) -> Path:
        """spec の `datas` どおりに展開された `_MEIPASS` を作る．"""
        root = tmp_path / "meipass"
        (root / BUNDLED_DATA_RELATIVE).parent.mkdir(parents=True, exist_ok=True)
        shutil.copy(data_csv_path, root / BUNDLED_DATA_RELATIVE)
        (root / BUNDLED_ICON_RELATIVE).parent.mkdir(parents=True, exist_ok=True)
        shutil.copy(repo_root / BUNDLED_ICON_RELATIVE, root / BUNDLED_ICON_RELATIVE)
        return root

    def test_resolves_both_resources_from_the_bundle(
        self, monkeypatch: pytest.MonkeyPatch, bundle: Path
    ) -> None:
        from tiasjudge.gui.main_window import default_icon_path, default_training_data_path

        _freeze(monkeypatch, bundle)
        assert default_training_data_path() == bundle / BUNDLED_DATA_RELATIVE
        assert default_icon_path() == bundle / BUNDLED_ICON_RELATIVE

    def test_diagnosis_works_with_the_bundled_data_only(
        self, monkeypatch: pytest.MonkeyPatch, bundle: Path
    ) -> None:
        """同梱データだけで診断が成立する（「単体 exe で起動・診断できる」の中身）．"""
        from tiasjudge.__main__ import _verify_bundled_data

        _freeze(monkeypatch, bundle)
        assert _verify_bundled_data() == 0

    def test_reports_failure_when_the_bundle_lacks_the_data(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        """同梱漏れは終了コード 1 で分かる（`--verify-data` の用途）．

        ※ 非 frozen 用のフォールバック（リポジトリ直下）に拾われないよう，
        パッケージの探索起点も `_MEIPASS` 配下へ差し替える．
        """
        import tiasjudge.gui.main_window as main_window
        from tiasjudge.__main__ import _verify_bundled_data

        empty_bundle = tmp_path / "meipass_empty"
        empty_bundle.mkdir()
        _freeze(monkeypatch, empty_bundle)
        monkeypatch.setattr(
            main_window, "default_training_data_path", lambda: empty_bundle / BUNDLED_DATA_RELATIVE
        )
        assert _verify_bundled_data() == 1


# --- `--verify-data`（配布 exe の動作確認オプション） ---


class TestVerifyDataIsGuiFree:
    """`--verify-data` は画面も GUI ツールキットも要らないこと（ENV_04 の記載）．"""

    def test_does_not_import_pyside6(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """PySide6 が import できない環境でも成功する（QApplication を作らない）．"""
        from tiasjudge.__main__ import main

        monkeypatch.setitem(sys.modules, "PySide6.QtWidgets", None)
        assert main(["--verify-data"]) == 0

    def test_wins_over_the_preview_option(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """`--preview-result` と併用しても画面は開かない（データ確認が優先）．"""
        import tiasjudge.__main__ as entry_point

        def fail(*args: object, **kwargs: object) -> None:
            raise AssertionError("--verify-data では画面を開いてはならない")

        monkeypatch.setattr(entry_point, "_run_preview", fail)
        assert entry_point.main(["--verify-data", "--preview-result"]) == 0

    def test_returns_zero_with_the_check_option(self) -> None:
        """`--check` と併用しても «データ確認して 0» の挙動は変わらない．"""
        from tiasjudge.__main__ import main

        assert main(["--verify-data", "--check"]) == 0
