# -*- coding: utf-8 -*-
"""PathSecurityService 路径安全服务（Phase 6 Tier 1-5 AP）。

借鉴 zhikuncode-main PathSecurityService.java：
- 5 重校验：符号链接、路径穿越、工作目录边界、受保护路径、Windows 绕过
- RiskLevel 3 级分类（LOW/MEDIUM/HIGH）
- needs_confirmation 标志位（受保护路径读操作）
- check_dangerous_removal 命令级拦截（rm -rf /）

与 ToolSafetyGuard 互补：
- PathSecurityService：file_write/file_edit/file_read 专用，路径校验
- ToolSafetyGuard：shell_exec bash 命令检测 + 环境变量清理

集成点：
1. `tools/file_write.py` / `file_edit.py`：写前 check_write
2. `tools/file_read.py`：读前 check_read
3. `tools/shell_exec.py`：rm 类命令前 check_dangerous_removal
"""
from __future__ import annotations

import os
import re
import sys
import threading
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Optional

__all__ = [
    "PathSecurityService",
    "PathCheckResult",
    "RiskLevel",
    "get_default_service",
]


def _find_protected_name(path: str) -> Optional[str]:
    """延迟导入 find_protected_name，避免循环导入。

    path_security -> tools.base -> tools.__init__ -> tools.file_read -> path_security
    """
    from abyss.tools.base import find_protected_name
    return find_protected_name(path)


class RiskLevel(Enum):
    """风险等级。"""

    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"


@dataclass
class PathCheckResult:
    """路径校验结果。"""

    allowed: bool = False
    reason: str = ""
    normalized_path: str = ""
    risk_level: RiskLevel = RiskLevel.LOW
    needs_confirmation: bool = False

    @classmethod
    def allowed(cls, path: str, risk: RiskLevel = RiskLevel.LOW) -> "PathCheckResult":
        return cls(
            allowed=True,
            reason="ok",
            normalized_path=path,
            risk_level=risk,
            needs_confirmation=False,
        )

    @classmethod
    def denied(cls, reason: str, path: str = "", risk: RiskLevel = RiskLevel.HIGH) -> "PathCheckResult":
        return cls(
            allowed=False,
            reason=reason,
            normalized_path=path,
            risk_level=risk,
            needs_confirmation=False,
        )

    @classmethod
    def confirmation_required(cls, reason: str, path: str) -> "PathCheckResult":
        return cls(
            allowed=False,
            reason=reason,
            normalized_path=path,
            risk_level=RiskLevel.HIGH,
            needs_confirmation=True,
        )


# Windows DOS 设备名
_DOS_DEVICE_NAMES = {"CON", "PRN", "AUX", "NUL"} | {f"COM{i}" for i in range(1, 10)} | {f"LPT{i}" for i in range(1, 10)}

# Unix 设备文件路径前缀
_UNIX_DEVICE_PREFIXES = ("/dev/", "/proc/", "/sys/")

# 危险删除命令正则（rm -rf / 系列）
_DANGEROUS_REMOVAL_PATTERNS = [
    # rm -rf / 根目录（绝对路径）
    re.compile(r"\brm\s+(-\w*\s+)*-?\w*\s*rf?\s+(?:--no-preserve-root\s+)?(/|/\*|~|/\*|/\.\*)\s*$"),
    re.compile(r"\brm\s+-\w*r\w*\s+(?:--no-preserve-root\s+)?(/|~|/\*|/.+)\s*$"),
    re.compile(r"\bsudo\s+rm\s+-\w*r\w*\s+(?:--no-preserve-root\s+)?(/|~|/\*)\s*$"),
    # rm -rf /* 等通配根
    re.compile(r"\brm\s+-\w*r\w*\s+/\*"),
    # rm -rf /etc /home /var 等系统目录
    re.compile(r"\brm\s+-\w*r\w*\s+/(etc|home|var|usr|root|boot|bin|sbin|lib|opt)\b"),
]

# Windows ADS 标记
_NTFS_ADS_PATTERN = re.compile(r":[^\\/]+\b(?!$)")  # file.txt:stream

# 8.3 短文件名
_SHORT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_~]{1,8}~\d+\.[A-Za-z0-9]{1,3}$", re.IGNORECASE)


class PathSecurityService:
    """统一路径安全验证服务。

    5 重校验链：
    1. 输入合法性（None / 空 / null 字节）
    2. Windows 路径绕过（NTFS ADS / 8.3 短名 / DOS 设备名）
    3. 路径穿越（.. 序列）
    4. 工作目录边界（realpath 解析后）
    5. 受保护路径黑名单 + 符号链接逃逸
    """

    def __init__(self, working_dir: Optional[str] = None) -> None:
        self._working_dir: Optional[str] = working_dir
        self._lock = threading.Lock()

    def set_working_dir(self, path: str) -> None:
        """更新工作目录（运行时切换 workspace）。"""
        with self._lock:
            self._working_dir = path

    # ------------------------------------------------------------------ public API

    def check_read(self, path: Optional[str]) -> PathCheckResult:
        """校验读取路径。受保护路径读取需用户确认。"""
        result = self._basic_check(path)
        if result is not None:
            return result
        assert path is not None

        # 受保护路径优先检测（基于原始 path 字符串，无需 resolve）
        # 这样相对路径 ".git/config" 也能被识别
        protected = _find_protected_name(path)
        if protected is not None:
            return PathCheckResult.confirmation_required(
                f"受保护路径禁止读取: {protected}", path
            )

        # 路径穿越检测
        traversal = self._check_traversal(path)
        if traversal is not None:
            return traversal

        # 规范化 + 边界检测
        resolved = self._resolve(path)
        if resolved is None:
            return PathCheckResult.denied("cannot resolve path", path)

        # 工作目录边界
        boundary = self._check_workspace_boundary(resolved)
        if boundary is not None:
            return boundary

        # 符号链接逃逸
        symlink = self._check_symlink_escape(path, resolved)
        if symlink is not None:
            return symlink

        return PathCheckResult.allowed(resolved, RiskLevel.LOW)

    def check_write(self, path: Optional[str]) -> PathCheckResult:
        """校验写入路径。受保护路径写操作直接拒绝。"""
        result = self._basic_check(path)
        if result is not None:
            return result
        assert path is not None

        # 受保护路径优先检测（基于原始 path 字符串，无需 resolve）
        protected = _find_protected_name(path)
        if protected is not None:
            return PathCheckResult.denied(
                f"受保护路径禁止写入: {protected}", path
            )

        # Windows 绕过
        win = self._check_windows_bypass(path)
        if win is not None:
            return win

        # 路径穿越
        traversal = self._check_traversal(path)
        if traversal is not None:
            return traversal

        # 规范化 + 边界
        resolved = self._resolve(path)
        if resolved is None:
            return PathCheckResult.denied("cannot resolve path", path)

        # 工作目录边界
        boundary = self._check_workspace_boundary(resolved)
        if boundary is not None:
            return boundary

        # Unix 设备文件
        if sys.platform != "win32":
            for prefix in _UNIX_DEVICE_PREFIXES:
                if resolved.startswith(prefix):
                    return PathCheckResult.denied(f"device path blocked: {resolved}", resolved)

        # 符号链接逃逸
        symlink = self._check_symlink_escape(path, resolved)
        if symlink is not None:
            return symlink

        return PathCheckResult.allowed(resolved, RiskLevel.LOW)

    def check_dangerous_removal(self, command: str) -> Optional[str]:
        """检测危险删除命令。命中返回原因，否则返回 None。"""
        if not command:
            return None
        for pattern in _DANGEROUS_REMOVAL_PATTERNS:
            if pattern.search(command):
                return f"dangerous removal: {command.strip()[:80]}"
        return None

    # ------------------------------------------------------------------ 内部校验

    def _basic_check(self, path: Optional[str]) -> Optional[PathCheckResult]:
        """输入合法性基础检测。"""
        if path is None:
            return PathCheckResult.denied("path is None")
        if not path:
            return PathCheckResult.denied("empty path")
        if "\x00" in path:
            return PathCheckResult.denied("path contains null byte")
        return None

    def _check_windows_bypass(self, path: str) -> Optional[PathCheckResult]:
        """Windows 路径绕过检测：NTFS ADS / 8.3 短名 / DOS 设备名。"""
        if sys.platform != "win32":
            return None

        # NTFS ADS: file.txt:stream
        # 仅检查文件名部分（不含盘符）
        basename = os.path.basename(path)
        # 排除 Windows 盘符冒号（C:）
        if ":" in basename:
            # 检查是否为 ADS（basename 中含冒号且不是盘符）
            # 例如 "file.txt:hidden"
            parts = basename.split(":", 1)
            if len(parts) == 2 and parts[1] and parts[0]:
                return PathCheckResult.denied(
                    f"NTFS ADS blocked: {basename}", path
                )

        # 8.3 短文件名：FILENA~1.TXT
        if _SHORT_NAME_PATTERN.match(basename):
            return PathCheckResult.denied(
                f"8.3 short filename blocked: {basename}", path
            )

        # DOS 设备名：CON/PRN/NUL/COM1-9/LPT1-9
        name_no_ext = basename.split(".")[0].upper()
        if name_no_ext in _DOS_DEVICE_NAMES:
            return PathCheckResult.denied(
                f"DOS device name blocked: {basename}", path
            )

        return None

    def _check_traversal(self, path: str) -> Optional[PathCheckResult]:
        """路径穿越检测：.. 序列。"""
        # 拆分路径组件
        parts = re.split(r"[\\/]", path)
        if ".." in parts:
            # 进一步规范化判断：若解析后仍在工作目录内，放行
            resolved = self._resolve(path)
            if resolved is None:
                return PathCheckResult.denied(
                    f"path traversal cannot resolve: {path}", path
                )
            if not self._is_in_working_dir(resolved):
                return PathCheckResult.denied(
                    f"path traversal outside workspace: {path}", resolved
                )
        return None

    def _resolve(self, path: str) -> Optional[str]:
        """解析路径为绝对路径（含符号链接解析）。

        相对路径基于 working_dir 解析；working_dir=None 时基于进程 CWD。
        """
        try:
            p = Path(path)
            if not p.is_absolute():
                base = self._working_dir if self._working_dir else os.getcwd()
                p = Path(base) / p
            # resolve(strict=False) 解析符号链接但允许路径不存在
            return str(p.resolve(strict=False))
        except (OSError, ValueError, RuntimeError):
            return None

    def _is_in_working_dir(self, resolved: str) -> bool:
        """检查解析后路径是否在工作目录内。

        working_dir=None 时回退到进程当前工作目录（os.getcwd()）。
        这样默认就有边界保护，而不是放行所有路径（安全关键修复）。
        """
        wd = self._working_dir if self._working_dir is not None else os.getcwd()
        try:
            wd_resolved = Path(wd).resolve(strict=False)
            Path(resolved).relative_to(wd_resolved)
            return True
        except (ValueError, RuntimeError, OSError):
            return False

    def _check_workspace_boundary(self, resolved: str) -> Optional[PathCheckResult]:
        """工作目录边界检测。"""
        if not self._is_in_working_dir(resolved):
            return PathCheckResult.denied(
                f"path outside workspace boundary: {resolved}", resolved
            )
        return None

    def _check_symlink_escape(self, original: str, resolved: str) -> Optional[PathCheckResult]:
        """符号链接逃逸检测：符号链接指向工作目录外。"""
        try:
            p = Path(original)
            if not p.is_symlink():
                return None
            # 读取符号链接目标
            target = os.readlink(original) if p.exists() or os.path.islink(original) else None
            if target is None:
                return None
            # 解析目标为绝对路径
            target_path = Path(target)
            if not target_path.is_absolute():
                # 相对符号链接基于链接所在目录解析
                target_path = Path(original).parent / target_path
            target_resolved = str(target_path.resolve(strict=False))
            # 目标在工作目录外：需确认
            if not self._is_in_working_dir(target_resolved):
                return PathCheckResult.confirmation_required(
                    f"symlink escapes workspace: {original} -> {target_resolved}",
                    resolved,
                )
        except (OSError, ValueError, RuntimeError):
            # 符号链接读取失败不阻断（让其他校验处理）
            return None
        return None


# ------------------------------------------------------------------ 模块级单例

_default_service: Optional[PathSecurityService] = None
_default_lock = threading.Lock()


def get_default_service() -> PathSecurityService:
    """返回模块级默认 PathSecurityService 单例。"""
    global _default_service
    if _default_service is None:
        with _default_lock:
            if _default_service is None:
                _default_service = PathSecurityService()
    return _default_service
