from __future__ import annotations

"""Built-in tools bundled with the SDK."""

import asyncio
import math
import os
import subprocess
from pathlib import Path
from typing import Any

import httpx

from agentrspy.core import AgentError, Tool, ToolError, ToolOutput


class CalculatorTool(Tool):
    """Evaluate mathematical expressions safely."""

    def name(self) -> str:
        return "calculator"

    def description(self) -> str:
        return "Evaluate mathematical expressions safely."

    def schema(self) -> dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "Mathematical expression to evaluate",
                }
            },
            "required": ["expression"],
        }

    async def call(self, input_value: Any) -> ToolOutput:
        if not isinstance(input_value, dict) or not isinstance(
            input_value.get("expression"), str
        ):
            raise ToolError.invalid_input("missing 'expression'")
        try:
            result = _evaluate_expression(input_value["expression"])
        except ExpressionError as error:
            raise ToolError.execution(str(error)) from error
        return ToolOutput.text(str(result))


class ExpressionError(Exception):
    """Expression parser error."""


class _ExpressionParser:
    def __init__(self, input_text: str) -> None:
        self.input = input_text
        self.position = 0

    def parse_expression(self) -> float:
        value = self.parse_term()
        while True:
            self.skip_whitespace()
            char = self.peek_char()
            if char == "+":
                self.consume_char()
                value += self.parse_term()
            elif char == "-":
                self.consume_char()
                value -= self.parse_term()
            else:
                return value

    def parse_term(self) -> float:
        value = self.parse_factor()
        while True:
            self.skip_whitespace()
            char = self.peek_char()
            if char == "*":
                self.consume_char()
                value *= self.parse_factor()
            elif char == "/":
                self.consume_char()
                divisor = self.parse_factor()
                if divisor == 0.0:
                    raise ExpressionError("division by zero")
                value /= divisor
            else:
                return value

    def parse_factor(self) -> float:
        self.skip_whitespace()
        char = self.peek_char()
        if char is None:
            raise ExpressionError("unexpected end of expression")
        if char == "(":
            self.consume_char()
            value = self.parse_expression()
            self.skip_whitespace()
            closing = self.consume_char()
            if closing != ")":
                raise ExpressionError(
                    f"unexpected token at position {max(self.position - 1, 0)}"
                )
            return value
        if char == "+":
            self.consume_char()
            return self.parse_factor()
        if char == "-":
            self.consume_char()
            return -self.parse_factor()
        if char.isdigit() or char == ".":
            return self.parse_number()
        raise ExpressionError(f"unexpected token at position {self.position}")

    def parse_number(self) -> float:
        start = self.position
        seen_dot = False
        while True:
            char = self.peek_char()
            if char is None:
                break
            if char.isdigit():
                self.consume_char()
            elif char == "." and not seen_dot:
                seen_dot = True
                self.consume_char()
            else:
                break
        try:
            return float(self.input[start : self.position])
        except ValueError as error:
            raise ExpressionError(
                f"invalid number starting at position {start}"
            ) from error

    def skip_whitespace(self) -> None:
        while (char := self.peek_char()) is not None and char.isspace():
            self.consume_char()

    def peek_char(self) -> str | None:
        if self.position >= len(self.input):
            return None
        return self.input[self.position]

    def consume_char(self) -> str | None:
        char = self.peek_char()
        if char is None:
            return None
        self.position += 1
        return char

    def is_eof(self) -> bool:
        return self.position >= len(self.input)


def _evaluate_expression(expression: str) -> float:
    parser = _ExpressionParser(expression)
    value = parser.parse_expression()
    parser.skip_whitespace()
    if not parser.is_eof():
        raise ExpressionError(f"unexpected token at position {parser.position}")
    return value


class WebFetchTool(Tool):
    """Fetch a URL and return the text body."""

    def __init__(self) -> None:
        self.client = httpx.AsyncClient(follow_redirects=True)

    def name(self) -> str:
        return "web_fetch"

    def description(self) -> str:
        return "Fetch a URL and return the text body."

    def schema(self) -> dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "url": {"type": "string"},
                "max_chars": {"type": "integer", "default": 4000},
            },
            "required": ["url"],
        }

    async def call(self, input_value: Any) -> ToolOutput:
        if not isinstance(input_value, dict) or not isinstance(
            input_value.get("url"), str
        ):
            raise ToolError.invalid_input("missing 'url'")
        max_chars = int(input_value.get("max_chars", 4000))
        try:
            response = await self.client.get(input_value["url"])
            response.raise_for_status()
        except httpx.HTTPError as error:
            raise ToolError.execution(str(error)) from error
        return ToolOutput.text(response.text[:max_chars])


class WebSearchTool(Tool):
    """Search the web using DuckDuckGo instant answers."""

    def __init__(self) -> None:
        self.client = httpx.AsyncClient(follow_redirects=True)

    def name(self) -> str:
        return "web_search"

    def description(self) -> str:
        return "Search the web for current public information."

    def schema(self) -> dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "query": {"type": "string"},
                "max_results": {"type": "integer", "default": 5},
            },
            "required": ["query"],
        }

    async def call(self, input_value: Any) -> ToolOutput:
        if not isinstance(input_value, dict) or not isinstance(
            input_value.get("query"), str
        ):
            raise ToolError.invalid_input("missing 'query'")
        max_results = int(input_value.get("max_results", 5))
        try:
            response = await self.client.get(
                "https://api.duckduckgo.com/",
                params={"q": input_value["query"], "format": "json", "no_html": "1"},
            )
            response.raise_for_status()
            payload = response.json()
        except (httpx.HTTPError, ValueError) as error:
            raise ToolError.execution(str(error)) from error
        lines: list[str] = []
        abstract_text = payload.get("AbstractText")
        if isinstance(abstract_text, str) and abstract_text:
            lines.append(f"Summary: {abstract_text}")
        related = payload.get("RelatedTopics")
        if isinstance(related, list):
            for item in related[:max_results]:
                text = item.get("Text") if isinstance(item, dict) else None
                if isinstance(text, str):
                    lines.append(f"- {text}")
        if not lines:
            lines.append(
                f"No instant answer results found for '{input_value['query']}'."
            )
        return ToolOutput.text("\n".join(lines))


class FileReadTool(Tool):
    """Read UTF-8 text files from allowed paths."""

    def __init__(self, root: str | Path | None = None) -> None:
        self.allowed_roots = [Path(root)] if root is not None else []

    def name(self) -> str:
        return "file_read"

    def description(self) -> str:
        return "Read UTF-8 text files from allowed paths."

    def schema(self) -> dict[str, Any]:
        return {
            "type": "object",
            "properties": {"path": {"type": "string"}},
            "required": ["path"],
        }

    async def call(self, input_value: Any) -> ToolOutput:
        if not isinstance(input_value, dict) or not isinstance(
            input_value.get("path"), str
        ):
            raise ToolError.invalid_input("missing 'path'")
        path = _resolve_path(self.allowed_roots, Path(input_value["path"]))
        try:
            content = await asyncio.to_thread(path.read_text, encoding="utf-8")
        except OSError as error:
            raise AgentError(str(error)) from error
        return ToolOutput.text(content)


class FileWriteTool(Tool):
    """Write UTF-8 text files to allowed paths."""

    def __init__(self, root: str | Path | None = None) -> None:
        self.allowed_roots = [Path(root)] if root is not None else []

    def name(self) -> str:
        return "file_write"

    def description(self) -> str:
        return "Write UTF-8 text files to allowed paths."

    def schema(self) -> dict[str, Any]:
        return {
            "type": "object",
            "properties": {"path": {"type": "string"}, "content": {"type": "string"}},
            "required": ["path", "content"],
        }

    async def call(self, input_value: Any) -> ToolOutput:
        if not isinstance(input_value, dict) or not isinstance(
            input_value.get("path"), str
        ):
            raise ToolError.invalid_input("missing 'path'")
        if not isinstance(input_value.get("content"), str):
            raise ToolError.invalid_input("missing 'content'")
        path = _resolve_path(self.allowed_roots, Path(input_value["path"]))
        if path.parent:
            await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
        await asyncio.to_thread(
            path.write_text, input_value["content"], encoding="utf-8"
        )
        return ToolOutput.text("ok")


class BashTool(Tool):
    """Execute shell commands and capture stdout and stderr."""

    def name(self) -> str:
        return "bash"

    def description(self) -> str:
        return "Execute shell commands and capture stdout/stderr."

    def schema(self) -> dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "command": {"type": "string"},
                "cwd": {"type": "string"},
                "timeout_secs": {"type": "integer", "default": 30},
            },
            "required": ["command"],
        }

    async def call(self, input_value: Any) -> ToolOutput:
        if not isinstance(input_value, dict) or not isinstance(
            input_value.get("command"), str
        ):
            raise ToolError.invalid_input("missing 'command'")
        timeout_secs = float(input_value.get("timeout_secs", 30))
        process = await asyncio.create_subprocess_shell(
            input_value["command"],
            cwd=input_value.get("cwd"),
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        try:
            stdout, stderr = await asyncio.wait_for(
                process.communicate(), timeout=timeout_secs
            )
        except TimeoutError as error:
            process.kill()
            await process.communicate()
            raise ToolError.timeout() from error
        text = stdout.decode(errors="replace")
        stderr_text = stderr.decode(errors="replace")
        if stderr_text.strip():
            text = f"{text}\n{stderr_text}" if text else stderr_text
        return ToolOutput.text(text)


class PythonTool(Tool):
    """Execute inline Python code."""

    def name(self) -> str:
        return "python"

    def description(self) -> str:
        return "Execute inline Python code."

    def schema(self) -> dict[str, Any]:
        return {
            "type": "object",
            "properties": {"script": {"type": "string"}},
            "required": ["script"],
        }

    async def call(self, input_value: Any) -> ToolOutput:
        if not isinstance(input_value, dict) or not isinstance(
            input_value.get("script"), str
        ):
            raise ToolError.invalid_input("missing 'script'")
        process = await asyncio.create_subprocess_exec(
            "python",
            "-c",
            input_value["script"],
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        stdout, stderr = await process.communicate()
        return ToolOutput.text(
            stdout.decode(errors="replace") + stderr.decode(errors="replace")
        )


def _resolve_path(allowed_roots: list[Path], path: Path) -> Path:
    absolute = path if path.is_absolute() else (Path.cwd() / path)
    try:
        canonical = absolute.resolve(strict=True)
    except FileNotFoundError:
        parent = absolute.parent.resolve(strict=True)
        canonical = parent / absolute.name
    if not allowed_roots:
        return canonical
    allowed = False
    for root in allowed_roots:
        try:
            canonical_root = root.resolve(strict=True)
        except FileNotFoundError:
            continue
        if canonical == canonical_root or canonical_root in canonical.parents:
            allowed = True
            break
    if allowed:
        return canonical
    raise AgentError.tool_error(ToolError.permission_denied(str(canonical)))
