# -*- coding: utf-8 -*-
"""ProjectContextService 项目上下文三级缓存测试（Phase 8 Tier 1-3 CC）

借鉴 zhikuncode ProjectContextService：
- 三级缓存：内存 → 文件持久化（~/.abyss/.project_cache/）→ 全量收集
- 按 git HEAD SHA 判断缓存是否过期
- 多工作目录隔离
"""
import os
import tempfile
import time
from pathlib import Path
from unittest.mock import patch, MagicMock

import pytest

from abyss.context.project_context import (
    ProjectContext,
    ProjectContextService,
    ProjectType,
)


class TestProjectContext:
    """ProjectContext dataclass 测试"""

    def test_create_basic(self):
        ctx = ProjectContext(
            git_root="/path/to/project",
            branch="main",
            head_sha="abc123",
            file_tree=["src/main.py", "README.md"],
            recent_commits=[],
            project_type=ProjectType.PYTHON,
            collected_at=time.time(),
        )
        assert ctx.git_root == "/path/to/project"
        assert ctx.branch == "main"
        assert ctx.project_type == ProjectType.PYTHON

    def test_no_git_repo(self):
        ctx = ProjectContext(
            git_root=None,
            branch=None,
            head_sha=None,
            file_tree=[],
            recent_commits=[],
            project_type=ProjectType.UNKNOWN,
            collected_at=time.time(),
        )
        assert ctx.git_root is None
        assert ctx.project_type == ProjectType.UNKNOWN


class TestProjectType:
    """ProjectType 枚举测试"""

    def test_all_types_exist(self):
        assert ProjectType.PYTHON
        assert ProjectType.NODE
        assert ProjectType.RUST
        assert ProjectType.GO
        assert ProjectType.UNKNOWN


class TestProjectContextServiceBasic:
    """基本功能测试"""

    def test_create_service(self):
        service = ProjectContextService()
        assert service is not None

    def test_get_context_returns_project_context(self, tmp_path):
        """get_context 返回 ProjectContext 实例"""
        service = ProjectContextService(cache_dir=str(tmp_path / "cache"))
        ctx = service.get_context(str(tmp_path))
        assert isinstance(ctx, ProjectContext)

    def test_first_call_triggers_collection(self, tmp_path):
        """首次调用触发全量收集"""
        service = ProjectContextService(cache_dir=str(tmp_path / "cache"))
        with patch.object(
            service, "_collect_full", return_value=ProjectContext(
                git_root=None, branch=None, head_sha=None,
                file_tree=[], recent_commits=[],
                project_type=ProjectType.UNKNOWN, collected_at=time.time(),
            )
        ) as mock_collect:
            service.get_context(str(tmp_path))
            assert mock_collect.called

    def test_second_call_uses_cache(self, tmp_path):
        """相同项目第二次调用命中内存缓存"""
        service = ProjectContextService(cache_dir=str(tmp_path / "cache"))
        ctx1 = ProjectContext(
            git_root=str(tmp_path), branch="main", head_sha="abc",
            file_tree=["a.py"], recent_commits=[],
            project_type=ProjectType.PYTHON, collected_at=time.time(),
        )
        # 注入内存缓存
        service._memory_cache[str(tmp_path)] = ctx1
        # mock _get_head_sha 返回相同 SHA，确保缓存命中
        with patch.object(service, "_get_head_sha", return_value="abc"), \
             patch.object(service, "_collect_full") as mock_collect:
            ctx2 = service.get_context(str(tmp_path))
            assert mock_collect.called is False
            assert ctx2.head_sha == "abc"


class TestProjectContextServiceGitExpiry:
    """按 git HEAD SHA 判过期"""

    def test_same_head_sha_no_expire(self, tmp_path):
        """HEAD SHA 相同不失效"""
        service = ProjectContextService(cache_dir=str(tmp_path / "cache"))
        ctx = ProjectContext(
            git_root=str(tmp_path), branch="main", head_sha="abc",
            file_tree=[], recent_commits=[],
            project_type=ProjectType.UNKNOWN, collected_at=time.time(),
        )
        # 模拟 git HEAD 仍是 abc
        with patch.object(service, "_get_head_sha", return_value="abc"):
            assert service._is_expired(ctx, str(tmp_path)) is False

    def test_different_head_sha_expired(self, tmp_path):
        """HEAD SHA 变更视为过期"""
        service = ProjectContextService(cache_dir=str(tmp_path / "cache"))
        ctx = ProjectContext(
            git_root=str(tmp_path), branch="main", head_sha="old",
            file_tree=[], recent_commits=[],
            project_type=ProjectType.UNKNOWN, collected_at=time.time(),
        )
        with patch.object(service, "_get_head_sha", return_value="new"):
            assert service._is_expired(ctx, str(tmp_path)) is True

    def test_no_git_repo_no_expiry(self, tmp_path):
        """非 git 仓库：按时间过期（短时间内不失效）"""
        service = ProjectContextService(cache_dir=str(tmp_path / "cache"))
        ctx = ProjectContext(
            git_root=None, branch=None, head_sha=None,
            file_tree=[], recent_commits=[],
            project_type=ProjectType.UNKNOWN, collected_at=time.time(),
        )
        # 没有 head_sha，_get_head_sha 返回 None
        with patch.object(service, "_get_head_sha", return_value=None):
            # 刚收集，不应过期
            assert service._is_expired(ctx, str(tmp_path)) is False

    def test_no_git_repo_old_age_expired(self, tmp_path):
        """非 git 仓库：超过 1 小时视为过期"""
        service = ProjectContextService(cache_dir=str(tmp_path / "cache"))
        ctx = ProjectContext(
            git_root=None, branch=None, head_sha=None,
            file_tree=[], recent_commits=[],
            project_type=ProjectType.UNKNOWN,
            collected_at=time.time() - 3700,  # 1 小时 1 分前
        )
        with patch.object(service, "_get_head_sha", return_value=None):
            assert service._is_expired(ctx, str(tmp_path)) is True


class TestProjectContextServiceFileCache:
    """文件持久化缓存"""

    def test_save_and_load_from_file(self, tmp_path):
        """保存到文件并加载"""
        service = ProjectContextService(cache_dir=str(tmp_path / "cache"))
        ctx = ProjectContext(
            git_root=str(tmp_path), branch="main", head_sha="abc",
            file_tree=["a.py", "b.py"], recent_commits=["fix: bug"],
            project_type=ProjectType.PYTHON, collected_at=time.time(),
        )
        # 保存
        service._save_to_file(str(tmp_path), ctx)
        # 加载
        loaded = service._load_from_file(str(tmp_path))
        assert loaded is not None
        assert loaded.head_sha == "abc"
        assert loaded.branch == "main"
        assert loaded.project_type == ProjectType.PYTHON
        assert "a.py" in loaded.file_tree

    def test_load_from_file_returns_none_if_not_exists(self, tmp_path):
        """文件不存在返回 None"""
        service = ProjectContextService(cache_dir=str(tmp_path / "cache"))
        loaded = service._load_from_file(str(tmp_path))
        assert loaded is None

    def test_load_from_file_corrupted_returns_none(self, tmp_path):
        """文件损坏返回 None"""
        cache_dir = tmp_path / "cache"
        cache_dir.mkdir(parents=True)
        # 写入损坏的 JSON
        from abyss.context.project_context import ProjectContextService
        path = cache_dir / f"{hash(str(tmp_path))}.json"
        path.write_text("not valid json {{{", encoding="utf-8")
        service = ProjectContextService(cache_dir=str(cache_dir))
        loaded = service._load_from_file(str(tmp_path))
        assert loaded is None


class TestProjectContextServiceInvalidate:
    """缓存失效"""

    def test_invalidate_clears_memory(self, tmp_path):
        service = ProjectContextService(cache_dir=str(tmp_path / "cache"))
        ctx = ProjectContext(
            git_root=str(tmp_path), branch="main", head_sha="abc",
            file_tree=[], recent_commits=[],
            project_type=ProjectType.UNKNOWN, collected_at=time.time(),
        )
        service._memory_cache[str(tmp_path)] = ctx
        service.invalidate(str(tmp_path))
        assert str(tmp_path) not in service._memory_cache

    def test_invalidate_clears_file(self, tmp_path):
        service = ProjectContextService(cache_dir=str(tmp_path / "cache"))
        ctx = ProjectContext(
            git_root=str(tmp_path), branch="main", head_sha="abc",
            file_tree=[], recent_commits=[],
            project_type=ProjectType.UNKNOWN, collected_at=time.time(),
        )
        service._save_to_file(str(tmp_path), ctx)
        service.invalidate(str(tmp_path))
        # 文件应被删除
        loaded = service._load_from_file(str(tmp_path))
        assert loaded is None


class TestProjectContextServiceCollect:
    """全量收集逻辑"""

    def test_collect_python_project(self, tmp_path):
        """收集 Python 项目"""
        (tmp_path / "main.py").write_text("print('hello')", encoding="utf-8")
        (tmp_path / "pyproject.toml").write_text("[project]\nname='test'", encoding="utf-8")
        service = ProjectContextService(cache_dir=str(tmp_path / "cache"))
        ctx = service._collect_full(str(tmp_path))
        assert ctx.project_type == ProjectType.PYTHON
        assert "main.py" in ctx.file_tree

    def test_collect_node_project(self, tmp_path):
        (tmp_path / "index.js").write_text("console.log('hi')", encoding="utf-8")
        (tmp_path / "package.json").write_text('{"name":"test"}', encoding="utf-8")
        service = ProjectContextService(cache_dir=str(tmp_path / "cache"))
        ctx = service._collect_full(str(tmp_path))
        assert ctx.project_type == ProjectType.NODE

    def test_collect_unknown_project(self, tmp_path):
        (tmp_path / "README.md").write_text("hello", encoding="utf-8")
        service = ProjectContextService(cache_dir=str(tmp_path / "cache"))
        ctx = service._collect_full(str(tmp_path))
        assert ctx.project_type == ProjectType.UNKNOWN

    def test_collect_respects_max_file_tree(self, tmp_path):
        """file_tree 上限 500"""
        for i in range(600):
            (tmp_path / f"f{i}.py").write_text("", encoding="utf-8")
        service = ProjectContextService(cache_dir=str(tmp_path / "cache"))
        ctx = service._collect_full(str(tmp_path))
        assert len(ctx.file_tree) <= 500

    def test_collect_ignores_hidden_dirs(self, tmp_path):
        """忽略 .git / .venv / node_modules 等"""
        (tmp_path / "main.py").write_text("", encoding="utf-8")
        (tmp_path / ".git").mkdir()
        (tmp_path / ".git" / "config").write_text("", encoding="utf-8")
        (tmp_path / "node_modules").mkdir()
        (tmp_path / "node_modules" / "pkg.js").write_text("", encoding="utf-8")
        service = ProjectContextService(cache_dir=str(tmp_path / "cache"))
        ctx = service._collect_full(str(tmp_path))
        assert "main.py" in ctx.file_tree
        assert not any(".git" in f for f in ctx.file_tree)
        assert not any("node_modules" in f for f in ctx.file_tree)


class TestProjectContextServiceMultiDir:
    """多工作目录隔离"""

    def test_different_dirs_separate_caches(self, tmp_path):
        dir1 = tmp_path / "proj1"
        dir2 = tmp_path / "proj2"
        dir1.mkdir()
        dir2.mkdir()
        (dir1 / "main.py").write_text("", encoding="utf-8")
        (dir2 / "index.js").write_text("", encoding="utf-8")

        service = ProjectContextService(cache_dir=str(tmp_path / "cache"))
        ctx1 = service.get_context(str(dir1))
        ctx2 = service.get_context(str(dir2))
        assert ctx1.project_type == ProjectType.PYTHON
        assert ctx2.project_type == ProjectType.NODE


class TestProjectContextServiceFormatProjectContext:
    """format_project_context 输出格式"""

    def test_format_includes_basic_info(self, tmp_path):
        ctx = ProjectContext(
            git_root=str(tmp_path), branch="main", head_sha="abc",
            file_tree=["a.py", "b.py"], recent_commits=["fix: bug"],
            project_type=ProjectType.PYTHON, collected_at=time.time(),
        )
        formatted = ProjectContextService.format_project_context(ctx)
        assert "main" in formatted  # branch
        assert "abc" in formatted  # head_sha
        assert "a.py" in formatted  # file_tree
        assert "python" in formatted.lower()  # project_type

    def test_format_no_git_info(self, tmp_path):
        ctx = ProjectContext(
            git_root=None, branch=None, head_sha=None,
            file_tree=["a.py"], recent_commits=[],
            project_type=ProjectType.UNKNOWN, collected_at=time.time(),
        )
        formatted = ProjectContextService.format_project_context(ctx)
        # 没有 git 信息时也应输出
        assert isinstance(formatted, str)
        assert len(formatted) > 0


class TestProjectContextServiceEdgeCases:
    """边界情况"""

    def test_nonexistent_dir(self, tmp_path):
        service = ProjectContextService(cache_dir=str(tmp_path / "cache"))
        ctx = service.get_context(str(tmp_path / "nonexistent"))
        assert isinstance(ctx, ProjectContext)
        # 不存在的目录应优雅降级
        assert ctx.project_type == ProjectType.UNKNOWN
        assert ctx.file_tree == []

    def test_unicode_path(self, tmp_path):
        unicode_dir = tmp_path / "项目"
        unicode_dir.mkdir()
        (unicode_dir / "main.py").write_text("# 中文注释", encoding="utf-8")
        service = ProjectContextService(cache_dir=str(tmp_path / "cache"))
        ctx = service.get_context(str(unicode_dir))
        assert ctx.project_type == ProjectType.PYTHON

    def test_cache_dir_not_exist_creates(self, tmp_path):
        """缓存目录不存在自动创建"""
        cache_dir = tmp_path / "new_cache"
        service = ProjectContextService(cache_dir=str(cache_dir))
        # 不抛异常
        service.get_context(str(tmp_path))
        assert cache_dir.exists()
