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

借鉴 zhikuncode ProjectContextService：
- 三级缓存：内存 → 文件持久化（~/.abyss/.project_cache/）→ 全量收集
- 按 git HEAD SHA 判断缓存是否过期
- 多工作目录隔离
- 异步 ensureContext 预加载（不阻塞启动）
- MAX_FILE_TREE=500 限制
- MAX_RECENT_COMMITS=20 限制

abyss 当前痛点：
- context_engine.py 每次会话开始都重新收集项目上下文
- 收集过程含 git status / file tree / recent commits，开销大
- 多会话切换同一项目时无共享缓存
"""
from __future__ import annotations

import hashlib
import json
import os
import subprocess
import threading
import time
from dataclasses import dataclass, field, asdict
from enum import Enum
from pathlib import Path
from typing import Dict, List, Optional


class ProjectType(Enum):
    """项目类型枚举。"""
    PYTHON = "python"
    NODE = "node"
    RUST = "rust"
    GO = "go"
    UNKNOWN = "unknown"


@dataclass
class ProjectContext:
    """项目上下文快照。

    Attributes:
        git_root: git 仓库根目录（None 表示非 git 仓库）
        branch: 当前 git 分支名
        head_sha: 当前 HEAD 的 SHA-1（None 表示非 git 仓库）
        file_tree: 项目文件列表（相对路径，限制 500 项）
        recent_commits: 最近的 commit 信息列表（限制 20 项）
        project_type: 项目类型枚举
        collected_at: 收集时间戳（time.time()）
    """
    git_root: Optional[str]
    branch: Optional[str]
    head_sha: Optional[str]
    file_tree: List[str] = field(default_factory=list)
    recent_commits: List[str] = field(default_factory=list)
    project_type: ProjectType = ProjectType.UNKNOWN
    collected_at: float = 0.0


class ProjectContextService:
    """项目上下文三级缓存服务。

    借鉴 zhikuncode ProjectContextService：
    - 三级缓存：内存 → 文件持久化（~/.abyss/.project_cache/）→ 全量收集
    - 按 git HEAD SHA 判断缓存是否过期
    - 多工作目录隔离
    - 异步 ensureContext 预加载

    Usage:
        >>> service = ProjectContextService()
        >>> ctx = service.get_context("/path/to/project")
        >>> print(ProjectContextService.format_project_context(ctx))
    """

    MAX_FILE_TREE = 500
    MAX_RECENT_COMMITS = 20
    NO_GIT_EXPIRE_SECONDS = 3600  # 1 小时
    IGNORED_DIRS = {
        ".git", ".hg", ".svn",
        "__pycache__", ".pytest_cache", ".mypy_cache",
        "node_modules", ".venv", "venv", "env",
        ".idea", ".vscode",
        "dist", "build", "target",
        ".abyss",  # abyss 自身目录
    }

    def __init__(self, cache_dir: Optional[str] = None) -> None:
        """初始化服务。

        Args:
            cache_dir: 缓存目录路径；None 时使用默认 ~/.abyss/.project_cache/
        """
        if cache_dir is None:
            cache_dir = os.path.expanduser("~/.abyss/.project_cache")
        self._cache_dir = cache_dir
        self._memory_cache: Dict[str, ProjectContext] = {}
        self._lock = threading.RLock()
        # 确保缓存目录存在
        try:
            Path(self._cache_dir).mkdir(parents=True, exist_ok=True)
        except OSError:
            pass

    def get_context(self, working_dir: str) -> ProjectContext:
        """获取项目上下文（三级缓存：内存 → 文件 → 全量收集）。

        Args:
            working_dir: 工作目录路径

        Returns:
            ProjectContext 实例
        """
        # 规范化路径
        try:
            normalized = str(Path(working_dir).resolve())
        except OSError:
            normalized = working_dir

        with self._lock:
            # 1. 内存缓存
            if normalized in self._memory_cache:
                ctx = self._memory_cache[normalized]
                if not self._is_expired(ctx, normalized):
                    return ctx

        # 2. 文件缓存
        file_ctx = self._load_from_file(normalized)
        if file_ctx is not None and not self._is_expired(file_ctx, normalized):
            with self._lock:
                self._memory_cache[normalized] = file_ctx
            return file_ctx

        # 3. 全量收集
        ctx = self._collect_full(normalized)
        with self._lock:
            self._memory_cache[normalized] = ctx
        self._save_to_file(normalized, ctx)
        return ctx

    def invalidate(self, working_dir: str) -> None:
        """使指定工作目录的缓存失效（内存 + 文件）。"""
        try:
            normalized = str(Path(working_dir).resolve())
        except OSError:
            normalized = working_dir
        with self._lock:
            self._memory_cache.pop(normalized, None)
        # 删除文件缓存
        cache_path = self._cache_path_for(normalized)
        try:
            if cache_path.exists():
                cache_path.unlink()
        except OSError:
            pass

    def ensure_context_async(self, working_dir: str) -> threading.Thread:
        """异步预加载项目上下文（不阻塞调用方）。

        Returns:
            已启动的后台 Thread 实例
        """
        thread = threading.Thread(
            target=self.get_context,
            args=(working_dir,),
            daemon=True,
            name="ProjectContextEnsure",
        )
        thread.start()
        return thread

    # ============ 内部实现 ============

    def _is_expired(self, ctx: ProjectContext, working_dir: str) -> bool:
        """检查缓存是否过期。

        策略：
        - git 仓库：HEAD SHA 变更视为过期
        - 非 git 仓库：超过 1 小时视为过期
        """
        current_sha = self._get_head_sha(working_dir)
        if ctx.head_sha is not None and current_sha is not None:
            # git 仓库：比较 SHA
            return ctx.head_sha != current_sha
        if ctx.head_sha is None and current_sha is None:
            # 非 git 仓库：按时间过期
            return (time.time() - ctx.collected_at) > self.NO_GIT_EXPIRE_SECONDS
        # 状态变化（git -> 非 git 或反之）
        return True

    def _get_head_sha(self, working_dir: str) -> Optional[str]:
        """获取当前目录的 git HEAD SHA。

        Returns:
            SHA 字符串；非 git 仓库或失败时返回 None
        """
        try:
            result = subprocess.run(
                ["git", "rev-parse", "HEAD"],
                cwd=working_dir,
                capture_output=True,
                encoding="utf-8", errors="replace",
                timeout=5,
                creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
            )
            if result.returncode == 0:
                return result.stdout.strip()
        except (subprocess.SubprocessError, OSError):
            pass
        return None

    def _get_git_info(self, working_dir: str) -> tuple[Optional[str], Optional[str], Optional[str]]:
        """获取 git_root / branch / head_sha 三元组。"""
        try:
            root_result = subprocess.run(
                ["git", "rev-parse", "--show-toplevel"],
                cwd=working_dir,
                capture_output=True,
                encoding="utf-8", errors="replace",
                timeout=5,
                creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
            )
            if root_result.returncode != 0:
                return None, None, None
            git_root = root_result.stdout.strip()

            branch_result = subprocess.run(
                ["git", "rev-parse", "--abbrev-ref", "HEAD"],
                cwd=working_dir,
                capture_output=True,
                encoding="utf-8", errors="replace",
                timeout=5,
                creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
            )
            branch = (
                branch_result.stdout.strip()
                if branch_result.returncode == 0
                else None
            )

            head_sha = self._get_head_sha(working_dir)
            return git_root, branch, head_sha
        except (subprocess.SubprocessError, OSError):
            return None, None, None

    def _collect_full(self, working_dir: str) -> ProjectContext:
        """全量收集项目上下文。"""
        # 1. git 信息
        git_root, branch, head_sha = self._get_git_info(working_dir)

        # 2. file_tree（限制 500）
        file_tree = self._collect_file_tree(working_dir)

        # 3. recent_commits（限制 20，仅 git 仓库）
        recent_commits = []
        if head_sha is not None:
            recent_commits = self._collect_recent_commits(working_dir)

        # 4. project_type
        project_type = self._detect_project_type(working_dir, file_tree)

        return ProjectContext(
            git_root=git_root,
            branch=branch,
            head_sha=head_sha,
            file_tree=file_tree,
            recent_commits=recent_commits,
            project_type=project_type,
            collected_at=time.time(),
        )

    def _collect_file_tree(self, working_dir: str) -> List[str]:
        """收集文件树（相对路径，限制 500 项，排除忽略目录）。"""
        result = []
        try:
            for root, dirs, files in os.walk(working_dir):
                # 原地修改 dirs 来 prune（os.walk 推荐方式）
                dirs[:] = [
                    d for d in dirs
                    if d not in self.IGNORED_DIRS
                    and not d.startswith(".")
                ]
                for filename in files:
                    if filename.startswith("."):
                        continue
                    full_path = os.path.join(root, filename)
                    rel_path = os.path.relpath(full_path, working_dir)
                    # 转换为正斜杠（跨平台一致）
                    rel_path = rel_path.replace(os.sep, "/")
                    result.append(rel_path)
                    if len(result) >= self.MAX_FILE_TREE:
                        return result
        except OSError:
            pass
        return result

    def _collect_recent_commits(self, working_dir: str) -> List[str]:
        """收集最近 commit 列表（限制 20 条）。"""
        try:
            result = subprocess.run(
                [
                    "git", "log",
                    "--format=%h %s",
                    "-n", str(self.MAX_RECENT_COMMITS),
                ],
                cwd=working_dir,
                capture_output=True,
                encoding="utf-8", errors="replace",
                timeout=5,
                creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
            )
            if result.returncode == 0:
                return [
                    line.strip()
                    for line in result.stdout.splitlines()
                    if line.strip()
                ]
        except (subprocess.SubprocessError, OSError):
            pass
        return []

    def _detect_project_type(
        self, working_dir: str, file_tree: List[str]
    ) -> ProjectType:
        """检测项目类型（按标志性文件）。"""
        file_set = {os.path.basename(f) for f in file_tree}
        if "pyproject.toml" in file_set or "setup.py" in file_set:
            return ProjectType.PYTHON
        if "package.json" in file_set:
            return ProjectType.NODE
        if "Cargo.toml" in file_set:
            return ProjectType.RUST
        if "go.mod" in file_set:
            return ProjectType.GO
        # 按文件扩展名统计
        py_count = sum(1 for f in file_tree if f.endswith(".py"))
        js_count = sum(1 for f in file_tree if f.endswith((".js", ".ts")))
        if py_count > js_count and py_count > 0:
            return ProjectType.PYTHON
        if js_count > 0:
            return ProjectType.NODE
        return ProjectType.UNKNOWN

    def _cache_path_for(self, working_dir: str) -> Path:
        """获取工作目录对应的缓存文件路径。"""
        # 用路径的 hash 作为文件名（避免特殊字符）
        path_hash = hashlib.sha256(working_dir.encode("utf-8")).hexdigest()[:16]
        return Path(self._cache_dir) / f"{path_hash}.json"

    def _save_to_file(self, working_dir: str, ctx: ProjectContext) -> None:
        """保存到文件缓存。"""
        cache_path = self._cache_path_for(working_dir)
        try:
            Path(self._cache_dir).mkdir(parents=True, exist_ok=True)
            data = asdict(ctx)
            # ProjectType 枚举转 value
            data["project_type"] = ctx.project_type.value
            # 原子写入：先写 .tmp 再 rename
            tmp_path = cache_path.with_suffix(".tmp")
            with open(tmp_path, "w", encoding="utf-8") as f:
                json.dump(data, f, ensure_ascii=False, indent=2)
            os.replace(tmp_path, cache_path)
        except OSError:
            pass

    def _load_from_file(self, working_dir: str) -> Optional[ProjectContext]:
        """从文件缓存加载。"""
        cache_path = self._cache_path_for(working_dir)
        try:
            with open(cache_path, "r", encoding="utf-8-sig") as f:
                data = json.load(f)
            # 兼容 project_type 字段
            pt_value = data.get("project_type", "unknown")
            try:
                project_type = ProjectType(pt_value)
            except ValueError:
                project_type = ProjectType.UNKNOWN
            return ProjectContext(
                git_root=data.get("git_root"),
                branch=data.get("branch"),
                head_sha=data.get("head_sha"),
                file_tree=data.get("file_tree", []),
                recent_commits=data.get("recent_commits", []),
                project_type=project_type,
                collected_at=data.get("collected_at", 0.0),
            )
        except (OSError, json.JSONDecodeError, ValueError, KeyError):
            return None

    @classmethod
    def format_project_context(cls, ctx: ProjectContext) -> str:
        """格式化 ProjectContext 为 system prompt 注入用的文本块。"""
        lines = ["<project_context>"]
        if ctx.git_root:
            lines.append(f"git_root: {ctx.git_root}")
        if ctx.branch:
            lines.append(f"branch: {ctx.branch}")
        if ctx.head_sha:
            lines.append(f"head_sha: {ctx.head_sha[:8]}...")
        lines.append(f"project_type: {ctx.project_type.value}")
        if ctx.file_tree:
            lines.append("file_tree (top 50):")
            for f in ctx.file_tree[:50]:
                lines.append(f"  - {f}")
        if ctx.recent_commits:
            lines.append("recent_commits:")
            for c in ctx.recent_commits:
                lines.append(f"  - {c}")
        lines.append("</project_context>")
        return "\n".join(lines)


# 默认全局实例
_default_service: Optional[ProjectContextService] = None
_default_lock = threading.Lock()


def get_default_service() -> ProjectContextService:
    """获取全局默认 ProjectContextService 实例。"""
    global _default_service
    if _default_service is None:
        with _default_lock:
            if _default_service is None:
                _default_service = ProjectContextService()
    return _default_service


__all__ = [
    "ProjectContext",
    "ProjectContextService",
    "ProjectType",
    "get_default_service",
]
