# -*- coding: utf-8 -*-
"""CG FileHistoryService 文件历史事务（Phase 8 Tier 2）

借鉴 zhikuncode FileHistoryService.java：
- begin_transaction 开启事务，返回 transaction_id
- record_file_change 记录原内容快照（None 表示新建文件）
- commit_transaction 设置 completed_at
- rewind 批量回退到原内容：
    - 已存在文件：写回原内容，当前内容备份到 .bak
    - 新建文件（snapshot 为 None）：删除
- list_recent_transactions 按 session_id 隔离，最新在前
- 持久化到 ~/.abyss/file_history.json（原子写）

事务生命周期：
1. agent_loop.process_tool_calls 入口 begin_transaction
2. file_edit/file_write 编辑前 record_file_change
3. process_tool_calls 出口 commit_transaction
4. /undo 命令调 rewind 最新 committed 事务
"""
from __future__ import annotations

import json
import logging
import os
import threading
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional

logger = logging.getLogger(__name__)

DEFAULT_STORAGE_DIR = "~/.abyss"
DEFAULT_HISTORY_FILE = "file_history.json"
MAX_TRANCTIONS_KEPT = 200  # 全局事务上限


# ---------------------------------------------------------------------------
# 全局事务上下文（threading.local）
# ---------------------------------------------------------------------------
# process_tool_calls 入口 begin_current_transaction(tid)，
# execute_tool 中针对 file_edit/file_write 调 record_in_current_transaction，
# process_tool_calls 出口 commit_current_transaction(tid)。
# 使用 threading.local 隔离不同线程的子 Agent 调用。

_transaction_ctx = threading.local()


def set_current_transaction(transaction_id: Optional[str]) -> None:
    """设置当前线程的活跃事务 id。None 表示清除。"""
    if transaction_id is None:
        getattr(_transaction_ctx, "tid", None)
        _transaction_ctx.tid = None
    else:
        _transaction_ctx.tid = transaction_id


def get_current_transaction() -> Optional[str]:
    """返回当前线程的活跃事务 id，无则 None。"""
    return getattr(_transaction_ctx, "tid", None)


# 全局 FileHistoryService 单例（由 main.py 启动时注入）
_GLOBAL_SERVICE: Optional["FileHistoryService"] = None


def set_global_service(service: Optional["FileHistoryService"]) -> None:
    """注入全局 FileHistoryService 单例。"""
    global _GLOBAL_SERVICE
    _GLOBAL_SERVICE = service


def get_global_service() -> Optional["FileHistoryService"]:
    """返回全局 FileHistoryService 单例。"""
    return _GLOBAL_SERVICE


def record_in_current_transaction(path: str, old_content: Optional[str]) -> None:
    """若当前线程有活跃事务，则记录文件变更。

    Args:
        path: 文件绝对路径
        old_content: 原内容；None 表示文件原本不存在（新建）

    无活跃事务时静默跳过，便于 file_edit/file_write 在无 history 时直接执行。
    """
    tid = get_current_transaction()
    if tid is None or _GLOBAL_SERVICE is None:
        return
    try:
        _GLOBAL_SERVICE.record_file_change(tid, path, old_content)
    except KeyError:
        # 事务已被 commit 或不存在，跳过
        logger.debug(
            f"FileHistory: record skipped, tx {tid} not active"
        )
    except Exception as exc:
        # 记录失败不阻塞工具执行
        logger.warning(f"FileHistory: record failed for {path}: {exc}")


@dataclass
class TransactionRecord:
    """单条事务记录。"""

    transaction_id: str
    session_id: str
    started_at: float
    completed_at: Optional[float]
    files: List[str] = field(default_factory=list)
    message_id: str = ""
    snapshots: Dict[str, Optional[str]] = field(default_factory=dict)
    # 是否已 rewind（rewind 后再次 rewind 应失败）
    rewound: bool = False

    def to_dict(self) -> dict:
        """序列化为 dict。"""
        return {
            "transaction_id": self.transaction_id,
            "session_id": self.session_id,
            "started_at": self.started_at,
            "completed_at": self.completed_at,
            "files": list(self.files),
            "message_id": self.message_id,
            "snapshots": dict(self.snapshots),
            "rewound": self.rewound,
        }

    @classmethod
    def from_dict(cls, data: dict) -> "TransactionRecord":
        """从 dict 反序列化。"""
        return cls(
            transaction_id=data["transaction_id"],
            session_id=data.get("session_id", ""),
            started_at=float(data.get("started_at", 0)),
            completed_at=data.get("completed_at"),
            files=list(data.get("files", [])),
            message_id=data.get("message_id", ""),
            snapshots=dict(data.get("snapshots", {})),
            rewound=bool(data.get("rewound", False)),
        )


@dataclass
class RewindResult:
    """rewind 操作结果。"""

    success: bool
    transaction_id: str
    affected_files: List[str] = field(default_factory=list)
    error: Optional[str] = None


class FileHistoryService:
    """文件历史事务管理器。

    所有公开方法线程安全。
    事务按 session_id 隔离，commit 后才进入 list_recent_transactions。
    """

    def __init__(self, storage_dir: str = DEFAULT_STORAGE_DIR):
        """初始化。

        Args:
            storage_dir: 持久化目录，~ 会被展开
        """
        self._storage_dir = str(Path(storage_dir).expanduser())
        self._persist_path = os.path.join(self._storage_dir, DEFAULT_HISTORY_FILE)
        # 复用锁
        self._lock = threading.RLock()
        # 已提交事务（按 completed_at 时间顺序）
        self._committed: List[TransactionRecord] = []
        # 进行中事务：tid -> TransactionRecord
        self._pending: Dict[str, TransactionRecord] = {}
        # 启动时加载
        self._load()

    # -- 事务生命周期 --

    def begin_transaction(self, session_id: str, message_id: str) -> str:
        """开启新事务。

        Args:
            session_id: 会话 id
            message_id: 触发本轮工具调用的消息 id

        Returns:
            transaction_id（uuid4 hex）
        """
        tid = uuid.uuid4().hex
        record = TransactionRecord(
            transaction_id=tid,
            session_id=session_id,
            started_at=time.time(),
            completed_at=None,
            message_id=message_id,
        )
        with self._lock:
            self._pending[tid] = record
        logger.debug(f"FileHistory: begin tx {tid} session={session_id}")
        return tid

    def record_file_change(
        self,
        transaction_id: str,
        path: str,
        old_content: Optional[str],
    ) -> None:
        """记录文件变更前的原内容。

        Args:
            transaction_id: 事务 id
            path: 文件绝对路径
            old_content: 原内容；None 表示文件原本不存在（新建）

        Raises:
            KeyError: transaction_id 不存在
        """
        with self._lock:
            record = self._pending.get(transaction_id)
            if record is None:
                raise KeyError(
                    f"transaction not found: {transaction_id} "
                    "(already committed or never begun)"
                )
            if path not in record.snapshots:
                record.files.append(path)
            record.snapshots[path] = old_content

    def commit_transaction(self, transaction_id: str) -> None:
        """提交事务，设置 completed_at 并持久化。

        Raises:
            KeyError: transaction_id 不存在
        """
        with self._lock:
            record = self._pending.pop(transaction_id, None)
            if record is None:
                raise KeyError(
                    f"transaction not found: {transaction_id} "
                    "(already committed or never begun)"
                )
            record.completed_at = time.time()
            self._committed.append(record)
            # 限制总数，超出时淘汰最老的
            if len(self._committed) > MAX_TRANCTIONS_KEPT:
                self._committed = self._committed[-MAX_TRANCTIONS_KEPT:]
            self._persist()
        logger.debug(f"FileHistory: commit tx {transaction_id}")

    # -- 查询 --

    def list_recent_transactions(
        self,
        session_id: str,
        limit: int = 10,
    ) -> List[TransactionRecord]:
        """返回指定 session 最近的已提交事务（最新在前）。"""
        with self._lock:
            scoped = [t for t in self._committed if t.session_id == session_id]
            # 按 completed_at 降序
            scoped.sort(
                key=lambda t: t.completed_at or t.started_at,
                reverse=True,
            )
            return scoped[:limit]

    def get_latest_transaction(self, session_id: str) -> Optional[TransactionRecord]:
        """返回指定 session 最新的可回退事务（未 rewind 的）。"""
        with self._lock:
            for t in sorted(
                self._committed,
                key=lambda x: x.completed_at or x.started_at,
                reverse=True,
            ):
                if t.session_id == session_id and not t.rewound and t.snapshots:
                    return t
        return None

    def get_latest_rewound_transaction(self, session_id: str) -> Optional[TransactionRecord]:
        """返回指定 session 最近一次被撤销的事务（用于 redo）。

        选取条件：rewound=True 且 snapshots 非空，按完成时间倒序取第一个。
        """
        with self._lock:
            for t in sorted(
                self._committed,
                key=lambda x: x.completed_at or x.started_at,
                reverse=True,
            ):
                if t.session_id == session_id and t.rewound and t.snapshots:
                    return t
        return None

    # -- 回退 --

    def rewind(self, transaction_id: str) -> RewindResult:
        """回退指定事务。

        - 已存在文件：写回原内容，当前内容备份到 .bak
        - 新建文件（snapshot 为 None）：删除
        - 已 rewind 的事务再次 rewind 返回失败

        Args:
            transaction_id: 事务 id

        Returns:
            RewindResult 包含 success / affected_files / error
        """
        with self._lock:
            # 在 committed 中查找
            record: Optional[TransactionRecord] = None
            for t in self._committed:
                if t.transaction_id == transaction_id:
                    record = t
                    break
            if record is None:
                return RewindResult(
                    success=False,
                    transaction_id=transaction_id,
                    error="transaction not found",
                )
            if record.rewound:
                return RewindResult(
                    success=False,
                    transaction_id=transaction_id,
                    error="transaction already rewound",
                )

            affected: List[str] = []
            errors: List[str] = []
            for path, old_content in record.snapshots.items():
                try:
                    self._rewind_one_file(path, old_content)
                    affected.append(path)
                except Exception as exc:
                    logger.error(
                        f"FileHistory: rewind {path} failed: {exc}"
                    )
                    errors.append(f"{path}: {exc}")

            record.rewound = True
            self._persist()

            return RewindResult(
                success=len(errors) == 0,
                transaction_id=transaction_id,
                affected_files=affected,
                error="; ".join(errors) if errors else None,
            )

    def _rewind_one_file(self, path: str, old_content: Optional[str]) -> None:
        """回退单个文件。

        - old_content is None：删除当前文件（如果是新建的），删除前备份到 .bak 供 redo 恢复
        - old_content is not None：写回原内容，当前内容备份 .bak
        """
        target = Path(path)
        if old_content is None:
            # 新建文件 → 删除前先备份到 .bak（供 redo 恢复）
            if target.exists():
                bak_path = Path(str(path) + ".bak")
                try:
                    bak_path.write_bytes(target.read_bytes())
                except Exception as exc:
                    logger.warning(
                        f"FileHistory: backup .bak failed for new file {path}: {exc}"
                    )
                target.unlink()
            return
        # 已有原内容 → 备份当前 + 写回原内容
        if target.exists():
            bak_path = Path(str(path) + ".bak")
            try:
                bak_path.write_bytes(target.read_bytes())
            except Exception as exc:
                logger.warning(
                    f"FileHistory: backup .bak failed for {path}: {exc}"
                )
        # 写回原内容（按 utf-8）
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text(old_content, encoding="utf-8")

    def redo(self, transaction_id: str) -> RewindResult:
        """恢复被 rewind 撤销的事务。

        - 从 .bak 文件恢复"撤销前的内容"（即 AI 编辑后的版本）
        - 新建文件（snapshot old_content is None）：从 .bak 恢复文件
        - 已存在文件：从 .bak 覆盖当前内容
        - .bak 不存在：返回失败（无法恢复）
        - 未 rewind 的事务不能 redo（应先用 /undo 撤销）

        Args:
            transaction_id: 事务 id

        Returns:
            RewindResult（复用结构，字段含义：affected_files=已恢复的文件）
        """
        with self._lock:
            record: Optional[TransactionRecord] = None
            for t in self._committed:
                if t.transaction_id == transaction_id:
                    record = t
                    break
            if record is None:
                return RewindResult(
                    success=False,
                    transaction_id=transaction_id,
                    error="transaction not found",
                )
            if not record.rewound:
                return RewindResult(
                    success=False,
                    transaction_id=transaction_id,
                    error="transaction not rewound (use /undo first)",
                )

            affected: List[str] = []
            errors: List[str] = []
            for path in record.snapshots:
                try:
                    self._redo_one_file(path)
                    affected.append(path)
                except Exception as exc:
                    logger.error(f"FileHistory: redo {path} failed: {exc}")
                    errors.append(f"{path}: {exc}")

            if not errors:
                record.rewound = False
            self._persist()

            return RewindResult(
                success=len(errors) == 0,
                transaction_id=transaction_id,
                affected_files=affected,
                error="; ".join(errors) if errors else None,
            )

    @staticmethod
    def _redo_one_file(path: str) -> None:
        """恢复单个文件：从 .bak 写回"撤销前的内容"。

        - .bak 存在：覆盖当前文件（无论当前是原内容还是其他）
        - .bak 不存在：抛 FileNotFoundError
        """
        bak_path = Path(str(path) + ".bak")
        if not bak_path.exists():
            raise FileNotFoundError(f".bak not found: {bak_path}")
        target = Path(path)
        target.parent.mkdir(parents=True, exist_ok=True)
        # 用二进制复制，避免编码假设
        target.write_bytes(bak_path.read_bytes())

    # -- 持久化 --

    def _persist(self) -> None:
        """原子写入持久化文件。必须在 _lock 内调用。"""
        path = self._persist_path
        tmp_path = path + ".tmp"
        try:
            parent = os.path.dirname(path)
            if parent:
                os.makedirs(parent, exist_ok=True)
            data = [t.to_dict() for t in self._committed]
            content = json.dumps(data, indent=2, ensure_ascii=False)
            with open(tmp_path, "w", encoding="utf-8") as f:
                f.write(content)
                f.flush()
                os.fsync(f.fileno())
            os.replace(tmp_path, path)
        except Exception as exc:
            logger.error(f"FileHistory: persist failed: {exc}")
            try:
                if os.path.exists(tmp_path):
                    os.unlink(tmp_path)
            except Exception:
                pass

    def _load(self) -> None:
        """从持久化文件加载。"""
        path = self._persist_path
        if not os.path.exists(path):
            self._committed = []
            return
        try:
            with open(path, "r", encoding="utf-8-sig") as f:
                data = json.load(f)
            if not isinstance(data, list):
                self._committed = []
                return
            records: List[TransactionRecord] = []
            for item in data:
                if not isinstance(item, dict):
                    continue
                try:
                    records.append(TransactionRecord.from_dict(item))
                except (KeyError, ValueError, TypeError) as exc:
                    logger.warning(
                        f"FileHistory: skip malformed tx entry: {exc}"
                    )
            self._committed = records
        except (json.JSONDecodeError, OSError) as exc:
            logger.warning(
                f"FileHistory: load failed, starting fresh: {exc}"
            )
            self._committed = []

    # -- 维护 --

    def clear_session(self, session_id: str) -> int:
        """清除指定 session 的所有事务记录。

        Returns:
            清除的记录数
        """
        with self._lock:
            before = len(self._committed)
            self._committed = [
                t for t in self._committed if t.session_id != session_id
            ]
            removed = before - len(self._committed)
            if removed:
                self._persist()
            return removed
