# 中文注释：
# 文件：echobot/agent/tools/skill_script.py
# 说明：提供“技能脚本执行”工具，允许在受控目录中运行 skills 下的 scripts。

"""Skill script execution tool."""

from __future__ import annotations

import asyncio
import os
import re
import shutil
import sys
from pathlib import Path
from typing import Any

from echobot.agent.skills import BUILTIN_SKILLS_DIR
from echobot.agent.tools.base import Tool

# 技能名安全约束：
# - 仅允许字母、数字、点、下划线、短横线
# - 禁止路径分隔符与空白，防止目录穿越
_SKILL_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")


class RunSkillScriptTool(Tool):
    """
    运行技能脚本工具。

    目标：
    - 允许 Agent 自动执行技能目录中的脚本，提高技能“可执行性”。
    - 仅允许执行受控路径：`<skill>/scripts/...`，避免任意路径执行风险。
    """

    def __init__(
        self,
        workspace: Path,
        builtin_skills_dir: Path | None = None,
        timeout: int = 120,
        max_output_chars: int = 10000,
    ):
        """
        初始化工具。

        Args:
            workspace: 工作区路径（用于解析 workspace/skills）。
            builtin_skills_dir: 内置技能目录，默认使用 echobot 内置目录。
            timeout: 默认执行超时（秒）。
            max_output_chars: 最大返回输出长度（超长会截断）。
        """
        self.workspace = Path(workspace).expanduser().resolve()
        self.workspace_skills_dir = self.workspace / "skills"
        self.builtin_skills_dir = (
            Path(builtin_skills_dir).expanduser().resolve()
            if builtin_skills_dir
            else BUILTIN_SKILLS_DIR.resolve()
        )
        self.timeout = max(1, timeout)
        self.max_output_chars = max(1000, max_output_chars)

    @property
    def name(self) -> str:
        return "run_skill_script"

    @property
    def description(self) -> str:
        return (
            "Run a script under a skill's scripts directory. "
            "Only files in <skill>/scripts are allowed."
        )

    @property
    def parameters(self) -> dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "skill_name": {
                    "type": "string",
                    "description": "Skill folder name, e.g. xlsx or weather",
                },
                "script": {
                    "type": "string",
                    "description": (
                        "Script path relative to the skill scripts directory, "
                        "e.g. recalc.py or office/validate.py"
                    ),
                },
                "args": {
                    "type": "array",
                    "description": "Optional command arguments list",
                    "items": {"type": "string"},
                },
                "timeout": {
                    "type": "integer",
                    "description": "Optional timeout in seconds (1-600)",
                    "minimum": 1,
                    "maximum": 600,
                },
            },
            "required": ["skill_name", "script"],
        }

    def _validate_skill_name(self, skill_name: str) -> str:
        """校验技能名，防止路径穿越。"""
        normalized = str(skill_name or "").strip()
        if not normalized:
            raise ValueError("skill_name cannot be empty.")
        if not _SKILL_NAME_PATTERN.match(normalized):
            raise ValueError(f"Invalid skill_name: {skill_name}")
        return normalized

    def _resolve_skill_dir(self, skill_name: str) -> Path:
        """
        解析技能目录。

        优先级：
        1. workspace skills（允许项目内覆写）
        2. builtin skills（内置技能）
        """
        safe_name = self._validate_skill_name(skill_name)
        workspace_dir = self.workspace_skills_dir / safe_name
        builtin_dir = self.builtin_skills_dir / safe_name

        if workspace_dir.is_dir():
            return workspace_dir.resolve()
        if builtin_dir.is_dir():
            return builtin_dir.resolve()
        raise ValueError(f"Skill not found: {skill_name}")

    def _resolve_script_path(self, skill_dir: Path, script: str) -> Path:
        """
        将脚本相对路径解析为绝对路径，并做边界校验。
        """
        raw = Path(str(script or "").strip())
        if not raw.parts:
            raise ValueError("script cannot be empty.")
        if raw.is_absolute():
            raise ValueError("script must be a relative path under scripts/.")

        # 兼容传入 "scripts/foo.py" 与 "foo.py" 两种形式。
        if raw.parts[0] == "scripts":
            raw = Path(*raw.parts[1:]) if len(raw.parts) > 1 else Path("")

        if not raw.parts:
            raise ValueError("script cannot point to scripts root directory.")

        scripts_root = (skill_dir / "scripts").resolve()
        if not scripts_root.exists() or not scripts_root.is_dir():
            raise ValueError(f"Skill has no scripts directory: {skill_dir}")

        resolved = (scripts_root / raw).resolve()
        if not resolved.is_relative_to(scripts_root):
            raise ValueError(f"Script path is outside scripts boundary: {script}")
        if not resolved.exists() or not resolved.is_file():
            raise ValueError(f"Script not found: {script}")
        return resolved

    def _build_command(self, script_path: Path) -> list[str]:
        """
        根据文件类型选择解释器。

        支持：
        - .py -> 当前 Python 解释器
        - .sh/.bash -> bash 或 sh
        - .js/.mjs/.cjs -> node
        - 可执行文件 -> 直接执行
        """
        suffix = script_path.suffix.lower()
        if suffix == ".py":
            return [sys.executable, str(script_path)]
        if suffix in {".sh", ".bash"}:
            shell_bin = shutil.which("bash") or shutil.which("sh")
            if not shell_bin:
                raise ValueError("bash/sh is not available in PATH.")
            return [shell_bin, str(script_path)]
        if suffix in {".js", ".mjs", ".cjs"}:
            node_bin = shutil.which("node")
            if not node_bin:
                raise ValueError("node is not available in PATH.")
            return [node_bin, str(script_path)]
        if os.access(script_path, os.X_OK):
            return [str(script_path)]
        raise ValueError(
            f"Unsupported script type: {script_path.name}. "
            "Use .py/.sh/.js or make script executable."
        )

    @staticmethod
    def _normalize_args(args: list[Any] | None) -> list[str]:
        """将参数列表规范化为字符串列表。"""
        if args is None:
            return []
        if not isinstance(args, list):
            raise ValueError("args must be a list of strings.")
        return [str(item) for item in args]

    def _build_result(self, stdout: bytes, stderr: bytes, returncode: int) -> str:
        """格式化执行输出。"""
        output_parts: list[str] = []

        stdout_text = stdout.decode("utf-8", errors="replace")
        stderr_text = stderr.decode("utf-8", errors="replace")
        if stdout_text:
            output_parts.append(stdout_text)
        if stderr_text.strip():
            output_parts.append(f"STDERR:\n{stderr_text}")
        if returncode != 0:
            output_parts.append(f"\nExit code: {returncode}")

        result = "\n".join(output_parts) if output_parts else "(no output)"
        if len(result) > self.max_output_chars:
            overflow = len(result) - self.max_output_chars
            result = result[: self.max_output_chars] + f"\n... (truncated, {overflow} more chars)"
        return result

    async def execute(
        self,
        skill_name: str,
        script: str,
        args: list[Any] | None = None,
        timeout: int | None = None,
        **kwargs: Any,
    ) -> str:
        """
        执行技能脚本。

        运行目录固定为 `skill_dir`，避免脚本相对路径行为不确定。
        """
        try:
            skill_dir = self._resolve_skill_dir(skill_name)
            script_path = self._resolve_script_path(skill_dir=skill_dir, script=script)
            command = self._build_command(script_path)
            normalized_args = self._normalize_args(args)

            effective_timeout = self.timeout if timeout is None else max(1, min(int(timeout), 600))
            process = await asyncio.create_subprocess_exec(
                *command,
                *normalized_args,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
                cwd=str(skill_dir),
            )

            try:
                stdout, stderr = await asyncio.wait_for(
                    process.communicate(),
                    timeout=effective_timeout,
                )
            except asyncio.TimeoutError:
                process.kill()
                await process.wait()
                return f"Error: Script timed out after {effective_timeout} seconds."

            return self._build_result(stdout=stdout, stderr=stderr, returncode=process.returncode)
        except ValueError as e:
            return f"Error: {e}"
        except Exception as e:
            return f"Error running skill script: {e}"
