"""共通層のテストが team 層ファイルに依存しないことのテスト（Issue #43）.

実行（リポジトリルートで）:
    python -B -m unittest discover -s .claude/tests -v

仕様の正本:
    1. docs/01_GUIDE/GUIDE_01_プロジェクト立ち上げフロー.md「開発モードとファイル構成」のレイヤ表
       （共通層は solo・team 両方に存在する／team 層は team のみ）
    2. .claude/skills/sync-template/SKILL.md「モード依存ファイル」＋ reference.md の
       `TEAM_LAYER_FILES`（team 層は solo プロジェクトに配置・更新・削除いずれもしない）
       および「同期対象外ファイル」＝ `SKIP_FILES`（`.claude/tests/` のみ．`.claude/hooks/tests/` は同期される）
    3. Issue #43「solo モードのプロジェクトで test_hooks.py の TestCheckSync が必ず失敗する」
       （期待する挙動: solo では 3 件が skip，team では従来どおり実行される）

仕様から導かれる検証対象の性質:
    `.claude/hooks/tests/test_hooks.py` は共通層として solo プロジェクトにも配布されるため，
    team 層ファイル（`check_sync.sh`）を前提とするテストは，そのファイルが無い環境では
    「失敗」ではなく「skip」にならなければならない．逆にファイルがある環境（team・本テンプレート）
    では skip されず実行されなければならない．

検証方法:
    一時ディレクトリに solo 構成（team 層ファイル無し）と team 構成（有り）の疑似プロジェクトを作り，
    そこで `test_hooks.py` を子プロセスで走らせて振る舞いを観測する．実装（skipUnless か skipTest か）
    ではなく，外から見える結果（skip されるか／失敗しないか）を確認する．

`.claude/tests/` はテンプレート自身のメタテスト置き場であり，`/sync-template` の同期対象外
（`SKIP_FILES`）として派生プロジェクトには配布しない．
"""

from __future__ import annotations

import ast
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import unittest

TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
REPO_ROOT = os.path.dirname(os.path.dirname(TESTS_DIR))

HOOKS_DIR = os.path.join(REPO_ROOT, ".claude", "hooks")
HOOKS_TESTS_DIR = os.path.join(HOOKS_DIR, "tests")
TEST_HOOKS = os.path.join(HOOKS_TESTS_DIR, "test_hooks.py")
SYNC_SKILL = os.path.join(REPO_ROOT, ".claude", "skills", "sync-template", "SKILL.md")
SYNC_REFERENCE = os.path.join(REPO_ROOT, ".claude", "skills", "sync-template", "reference.md")
GUIDE_01 = os.path.join(REPO_ROOT, "docs", "01_GUIDE", "GUIDE_01_プロジェクト立ち上げフロー.md")

TIMEOUT = 300


def read(path: str) -> str:
    with open(path, "r", encoding="utf-8") as f:
        return f.read()


def team_layer_files() -> list[str]:
    """仕様（sync-template/reference.md の TEAM_LAYER_FILES）から team 層ファイルの一覧を読む."""
    text = read(SYNC_REFERENCE)
    m = re.search(r"TEAM_LAYER_FILES=\((.*?)\)", text, flags=re.DOTALL)
    assert m is not None, "reference.md に TEAM_LAYER_FILES の定義が見つからない"
    return re.findall(r'"([^"]+)"', m.group(1))


def team_layer_hook_files() -> list[str]:
    """team 層のうち `.claude/hooks/` 配下のもの（共通層テストが触れうる対象）."""
    return [f for f in team_layer_files() if f.startswith(".claude/hooks/")]


def sim_env() -> dict:
    """疑似プロジェクトで test_hooks.py を動かすための環境変数.

    test_hooks.py はモジュール読み込み時に bash を探し，見つからないと SkipTest を投げる
    （= 観測したい skip 件数がマシン依存になる）．HOOK_TEST_BASH はその探索を上書きする
    テスト用フックなので，ダミー値を渡して結果を決定的にする．solo・team いずれの検証でも
    実際に bash を起動するテストは実行しないため，値は使われない．
    """
    env = dict(os.environ)
    env["HOOK_TEST_BASH"] = os.path.join("no-such-dir", "bash")
    env["PYTHONIOENCODING"] = "utf-8"
    return env


def make_project(root: str, include_team_layer: bool) -> str:
    """疑似プロジェクトを作る．共通層（hooks のテスト等）を配置し，team 層は指定に応じて置く.

    戻り値はプロジェクトルート（`.claude/` の親）．
    """
    project = os.path.join(root, "team" if include_team_layer else "solo")
    dest_hooks = os.path.join(project, ".claude", "hooks")
    shutil.copytree(HOOKS_DIR, dest_hooks, ignore=shutil.ignore_patterns("__pycache__"))
    if not include_team_layer:
        for rel_path in team_layer_hook_files():
            target = os.path.join(project, *rel_path.split("/"))
            if os.path.exists(target):
                os.remove(target)
    return project


def run_check_sync_tests(project: str) -> subprocess.CompletedProcess:
    """疑似プロジェクトで check_sync 関連のテストだけを実行する."""
    tests_dir = os.path.join(project, ".claude", "hooks", "tests")
    return subprocess.run(
        [sys.executable, "-B", "-m", "unittest", "discover", "-s", tests_dir, "-k", "CheckSync", "-v"],
        capture_output=True,
        cwd=project,
        env=sim_env(),
        timeout=TIMEOUT,
    )


INSPECT_SCRIPT = """
import json, os, sys
sys.path.insert(0, sys.argv[1])
import test_hooks

cls = getattr(test_hooks, "TestCheckSync", None)
print(json.dumps({
    "found": cls is not None,
    "skipped": bool(getattr(cls, "__unittest_skip__", False)),
    "reason": str(getattr(cls, "__unittest_skip_why__", "")),
    "check_sync_path_exists": os.path.exists(test_hooks.CHECK_SYNC),
}))
"""


def inspect_check_sync_class(project: str) -> dict:
    """疑似プロジェクトで test_hooks を読み込み，TestCheckSync の skip 状態を観測する."""
    tests_dir = os.path.join(project, ".claude", "hooks", "tests")
    result = subprocess.run(
        [sys.executable, "-B", "-c", INSPECT_SCRIPT, tests_dir],
        capture_output=True,
        cwd=project,
        env=sim_env(),
        timeout=TIMEOUT,
    )
    stdout = result.stdout.decode("utf-8", errors="replace")
    stderr = result.stderr.decode("utf-8", errors="replace")
    assert result.returncode == 0, "test_hooks の読み込みに失敗した:\n" + stderr
    return json.loads(stdout.strip().splitlines()[-1])


class TempProjectMixin:
    def setUp(self):
        super().setUp()
        self.root = tempfile.mkdtemp(prefix="mode-layers-")
        self.addCleanup(shutil.rmtree, self.root, True)


# ---------------------------------------------------------------------------
# 前提: 共通層／team 層の切り分けが仕様どおりであること
# ---------------------------------------------------------------------------
class TestLayerPremises(unittest.TestCase):
    def test_check_sync_is_team_layer(self):
        """check_sync.sh は team 層として仕様の 3 箇所（reference・SKILL・GUIDE_01）に載っている."""
        self.assertIn(".claude/hooks/check_sync.sh", team_layer_files())
        self.assertRegex(read(SYNC_SKILL), r"\|\s*`\.claude/hooks/check_sync\.sh`\s*\|\s*team\s*\|")
        self.assertIn(".claude/hooks/check_sync.sh", read(GUIDE_01))

    def test_hooks_tests_are_synced_to_all_projects(self):
        """`.claude/hooks/tests/` は同期対象外でも team 層でもない（= 共通層として solo にも配る）."""
        skip_files = re.search(r"SKIP_FILES=\((.*?)\)", read(SYNC_REFERENCE), flags=re.DOTALL)
        self.assertIsNotNone(skip_files, "reference.md に SKIP_FILES の定義が見つからない")
        entries = re.findall(r'"([^"]+)"', skip_files.group(1))
        for entry in entries:
            self.assertFalse(
                ".claude/hooks/tests/".startswith(entry),
                "`.claude/hooks/tests/` が同期対象外になっている: " + entry,
            )
        for entry in team_layer_files():
            self.assertFalse(
                ".claude/hooks/tests/test_hooks.py".startswith(entry.rstrip("/")),
                "test_hooks.py が team 層扱いになっている: " + entry,
            )

    def test_hooks_ci_is_not_synced(self):
        """フックの 3 OS CI（hooks-ci.yml）はテンプレート専用で，同期対象外として仕様の 2 箇所に載っている."""
        skip_files = re.search(r"SKIP_FILES=\((.*?)\)", read(SYNC_REFERENCE), flags=re.DOTALL)
        self.assertIsNotNone(skip_files, "reference.md に SKIP_FILES の定義が見つからない")
        self.assertIn(".github/workflows/hooks-ci.yml", re.findall(r'"([^"]+)"', skip_files.group(1)))
        self.assertRegex(read(SYNC_SKILL), r"\|\s*`\.github/workflows/hooks-ci\.yml`\s*\|")


# ---------------------------------------------------------------------------
# 本体: solo（team 層無し）では skip，team（有り）では実行される
# ---------------------------------------------------------------------------
class TestCheckSyncGuard(TempProjectMixin, unittest.TestCase):
    def test_solo_project_skips_instead_of_failing(self):
        """team 層が無い構成でも失敗せず，check_sync のテストがすべて skip される（Issue #43 の期待値）."""
        project = make_project(self.root, include_team_layer=False)
        result = run_check_sync_tests(project)
        output = result.stdout.decode("utf-8", errors="replace") + result.stderr.decode("utf-8", errors="replace")
        self.assertEqual(result.returncode, 0, "team 層が無い環境でテストが失敗した:\n" + output)
        self.assertNotIn("FAILED", output, output)
        ran = re.search(r"Ran (\d+) tests?", output)
        self.assertIsNotNone(ran, output)
        self.assertGreaterEqual(int(ran.group(1)), 3, "check_sync のテストが収集されていない:\n" + output)
        skipped = re.search(r"OK \(skipped=(\d+)\)", output)
        self.assertIsNotNone(skipped, "skip されずに実行されている:\n" + output)
        self.assertEqual(
            int(skipped.group(1)),
            int(ran.group(1)),
            "team 層が無い環境で実行されてしまったテストがある:\n" + output,
        )

    def test_solo_skip_reason_mentions_team_layer(self):
        """skip の理由が「team 層ファイルが無いこと」だと分かる（原因不明の skip にしない）."""
        project = make_project(self.root, include_team_layer=False)
        info = inspect_check_sync_class(project)
        self.assertTrue(info["found"], "TestCheckSync が見つからない")
        self.assertFalse(info["check_sync_path_exists"], "疑似 solo 構成に check_sync.sh が残っている")
        self.assertTrue(info["skipped"], "team 層が無いのに skip されない: " + json.dumps(info))
        self.assertTrue(
            re.search(r"check_sync|team", info["reason"]),
            "skip 理由から team 層依存だと分からない: " + repr(info["reason"]),
        )

    def test_team_project_runs_the_tests(self):
        """team 層がある構成では skip されない（ガードが過剰に効いていない）."""
        project = make_project(self.root, include_team_layer=True)
        info = inspect_check_sync_class(project)
        self.assertTrue(info["found"], "TestCheckSync が見つからない")
        self.assertTrue(info["check_sync_path_exists"], "疑似 team 構成に check_sync.sh が無い")
        self.assertFalse(info["skipped"], "team 層があるのに skip されている: " + json.dumps(info))

    def test_template_itself_runs_the_tests(self):
        """本テンプレート（team 層を含む正本）でも skip されない．"""
        info = inspect_check_sync_class(REPO_ROOT)
        self.assertTrue(info["check_sync_path_exists"])
        self.assertFalse(info["skipped"], "テンプレート自身で TestCheckSync が skip されている")


# ---------------------------------------------------------------------------
# 一般化: 共通層テストが team 層ファイルに無防備に依存していないこと
# ---------------------------------------------------------------------------
class TestCommonTestsDoNotRequireTeamLayer(unittest.TestCase):
    def setUp(self):
        self.tree = ast.parse(read(TEST_HOOKS), filename=TEST_HOOKS)

    def team_layer_constants(self) -> dict[str, str]:
        """モジュール直下の定数のうち，team 層ファイルを指すもの（定数名 -> 相対パス）."""
        basenames = {os.path.basename(f): f for f in team_layer_hook_files()}
        found = {}
        for node in self.tree.body:
            if not isinstance(node, ast.Assign):
                continue
            literals = [n.value for n in ast.walk(node.value) if isinstance(n, ast.Constant) and isinstance(n.value, str)]
            for name, rel_path in basenames.items():
                if name in literals:
                    for target in node.targets:
                        if isinstance(target, ast.Name):
                            found[target.id] = rel_path
        return found

    def test_team_layer_paths_are_referenced_by_constants(self):
        """前提の確認: team 層ファイルはモジュール定数として定義されている（未定義なら依存が無い）."""
        constants = self.team_layer_constants()
        self.assertIn(
            "CHECK_SYNC",
            constants,
            "check_sync.sh を指す定数が test_hooks.py に見つからない（仕様の前提が変わった可能性）",
        )

    def test_classes_using_team_layer_files_are_guarded(self):
        """team 層ファイルを使うテストクラスには存在ガード（skipUnless/skipIf）が付いている."""
        constants = self.team_layer_constants()
        self.assertTrue(constants, "team 層ファイルを指す定数が無い")
        checked = 0
        for node in self.tree.body:
            if not isinstance(node, ast.ClassDef):
                continue
            used = {n.id for n in ast.walk(node) if isinstance(n, ast.Name)} & set(constants)
            if not used:
                continue
            checked += 1
            with self.subTest(cls=node.name, uses=sorted(used)):
                decorators = []
                for dec in node.decorator_list:
                    target = dec.func if isinstance(dec, ast.Call) else dec
                    decorators.append(target.attr if isinstance(target, ast.Attribute) else getattr(target, "id", ""))
                self.assertTrue(
                    {"skipUnless", "skipIf"} & set(decorators),
                    node.name + " が team 層ファイル " + ", ".join(sorted(used)) + " を存在ガード無しで使っている",
                )
                guard_source = " ".join(ast.dump(dec) for dec in node.decorator_list)
                self.assertIn(
                    "exists",
                    guard_source,
                    node.name + " のガードがファイルの存在判定になっていない",
                )
        self.assertGreater(checked, 0, "team 層ファイルを使うテストクラスが見つからない")

    def test_no_module_level_access_to_team_layer_files(self):
        """モジュール読み込み時点で team 層ファイルを開かない（インポートだけで壊れない）."""
        constants = self.team_layer_constants()
        for node in self.tree.body:
            if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
                continue
            for call in (n for n in ast.walk(node) if isinstance(n, ast.Call)):
                func = call.func
                name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", "")
                if name not in ("open", "run", "check_output", "check_call"):
                    continue
                used = {n.id for n in ast.walk(call) if isinstance(n, ast.Name)} & set(constants)
                self.assertFalse(
                    used,
                    "モジュール直下で team 層ファイルを使っている: " + ", ".join(sorted(used)),
                )


if __name__ == "__main__":
    unittest.main()
