"""Tool interface — base classes for all tools.

Provides the abstract Tool base class, result types, error handling,
and a MockTool for testing. Tools are the execution primitives used
by the Executor to carry out plan steps.
"""

import asyncio
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any


@dataclass
class ToolResult:
    """Result from a tool execution.

    Carries output data, error information, timing, and metadata.
    The retryable flag indicates whether the caller should attempt
    to re-execute on failure (e.g. transient network errors).
    """

    success: bool = True
    output: Any = None
    error: str | None = None
    metadata: dict[str, Any] = field(default_factory=dict)
    duration_ms: int = 0
    retryable: bool = False

    @property
    def failed(self) -> bool:
        return not self.success

    def to_dict(self) -> dict[str, Any]:
        return {
            "success": self.success,
            "output": str(self.output) if self.output is not None else None,
            "error": self.error,
            "duration_ms": self.duration_ms,
            "retryable": self.retryable,
        }

    @classmethod
    def ok(cls, output: Any = None, **metadata: Any) -> "ToolResult":
        """Convenience constructor for a successful result."""
        return cls(success=True, output=output, metadata=metadata)

    @classmethod
    def fail(cls, error: str, retryable: bool = False, **metadata: Any) -> "ToolResult":
        """Convenience constructor for a failed result."""
        return cls(success=False, error=error, retryable=retryable, metadata=metadata)


class ToolError(Exception):
    """Error raised by a tool during execution.

    Attributes:
        tool_name: Which tool raised the error.
        recoverable: Whether the error is transient and retryable.
        error_code: Optional machine-readable error code.
    """

    def __init__(
        self,
        message: str,
        tool_name: str = "",
        recoverable: bool = True,
        error_code: str = "",
    ) -> None:
        super().__init__(message)
        self.tool_name = tool_name
        self.recoverable = recoverable
        self.error_code = error_code

    def to_result(self) -> ToolResult:
        """Convert this error into a ToolResult for uniform handling."""
        return ToolResult.fail(
            error=str(self),
            retryable=self.recoverable,
            tool_name=self.tool_name,
            error_code=self.error_code,
        )


class ToolValidationError(ToolError):
    """Raised when tool parameters fail validation."""

    def __init__(self, issues: list[str], tool_name: str = "") -> None:
        self.issues = issues
        super().__init__(
            f"Validation failed: {'; '.join(issues)}",
            tool_name=tool_name,
            recoverable=False,
        )


# ---------------------------------------------------------------------------
# Tool parameter schema
# ---------------------------------------------------------------------------

@dataclass
class ToolParam:
    """Describes a single parameter a tool accepts."""

    name: str
    param_type: str = "string"  # string, int, float, bool, list, dict
    required: bool = False
    default: Any = None
    description: str = ""
    choices: list[Any] | None = None

    def validate_value(self, value: Any) -> str | None:
        """Returns an error message if the value is invalid, else None."""
        type_map: dict[str, type | tuple[type, ...]] = {
            "string": str,
            "int": int,
            "float": (int, float),
            "bool": bool,
            "list": list,
            "dict": dict,
        }
        expected = type_map.get(self.param_type)
        if expected and not isinstance(value, expected):
            return f"Parameter '{self.name}' expected {self.param_type}, got {type(value).__name__}"
        if self.choices is not None and value not in self.choices:
            return f"Parameter '{self.name}' must be one of {self.choices}, got {value!r}"
        return None


# ---------------------------------------------------------------------------
# Abstract Tool
# ---------------------------------------------------------------------------

class Tool(ABC):
    """Abstract base class for all tools.

    Subclasses must implement ``name`` and ``execute``. They may optionally
    override ``description``, ``category``, ``parameters``, and ``validate``
    for richer integration with the executor and plan builder.
    """

    @property
    @abstractmethod
    def name(self) -> str:
        """Unique name for this tool."""
        ...

    @property
    def description(self) -> str:
        """Human-readable description shown in briefs and logs."""
        return ""

    @property
    def category(self) -> str:
        """Tool category (browser, http, ai, email, files, database, code)."""
        return "general"

    @property
    def parameters(self) -> list[ToolParam]:
        """Declared parameter schema. Used by auto-validation."""
        return []

    @property
    def timeout_ms(self) -> int:
        """Default timeout for this tool in milliseconds."""
        return 30_000

    @property
    def idempotent(self) -> bool:
        """Whether repeated calls with the same params are safe."""
        return True

    @abstractmethod
    async def execute(self, params: dict[str, Any]) -> ToolResult:
        """Execute the tool with given parameters."""
        ...

    async def validate(self, params: dict[str, Any]) -> list[str]:
        """Validate parameters before execution.

        The default implementation checks against ``self.parameters``.
        Override for custom validation logic.
        """
        issues: list[str] = []
        schema = {p.name: p for p in self.parameters}

        # Check required params
        for p in self.parameters:
            if p.required and p.name not in params:
                issues.append(f"Missing required parameter: '{p.name}'")

        # Type-check supplied params
        for key, value in params.items():
            if key in schema:
                error = schema[key].validate_value(value)
                if error:
                    issues.append(error)

        return issues

    async def execute_with_timeout(
        self, params: dict[str, Any], timeout_ms: int | None = None
    ) -> ToolResult:
        """Execute with an enforced timeout.

        Returns a failed ToolResult (retryable=True) on timeout rather
        than raising an exception, so the caller can decide how to handle it.
        """
        timeout = (timeout_ms or self.timeout_ms) / 1000.0
        start = time.time()
        try:
            result = await asyncio.wait_for(self.execute(params), timeout=timeout)
            result.duration_ms = int((time.time() - start) * 1000)
            return result
        except asyncio.TimeoutError:
            return ToolResult.fail(
                error=f"Tool '{self.name}' timed out after {int(timeout * 1000)}ms",
                retryable=True,
                timeout_ms=int(timeout * 1000),
            )
        except ToolError as exc:
            return exc.to_result()
        except Exception as exc:
            return ToolResult.fail(error=str(exc), retryable=False)

    def __repr__(self) -> str:
        return f"<Tool:{self.name}>"


# ---------------------------------------------------------------------------
# Tool Registry
# ---------------------------------------------------------------------------

class ToolRegistry:
    """Central registry mapping tool names to tool instances.

    Used by the Executor to look up tools during plan execution.
    Supports listing available tools, categories, and capability checks.
    """

    def __init__(self) -> None:
        self._tools: dict[str, Tool] = {}

    def register(self, tool: Tool) -> None:
        """Register a tool (overwrites if name already exists)."""
        self._tools[tool.name] = tool

    def unregister(self, name: str) -> bool:
        """Remove a tool by name. Returns True if it existed."""
        return self._tools.pop(name, None) is not None

    def get(self, name: str) -> Tool | None:
        return self._tools.get(name)

    def has(self, name: str) -> bool:
        return name in self._tools

    def list_names(self) -> list[str]:
        return sorted(self._tools.keys())

    def list_by_category(self, category: str) -> list[Tool]:
        return [t for t in self._tools.values() if t.category == category]

    @property
    def count(self) -> int:
        return len(self._tools)

    def to_summary(self) -> list[dict[str, str]]:
        """Return a summary list suitable for prompts or briefs."""
        return [
            {"name": t.name, "category": t.category, "description": t.description}
            for t in self._tools.values()
        ]


# ---------------------------------------------------------------------------
# Mock Tool
# ---------------------------------------------------------------------------

class MockTool(Tool):
    """Mock tool for testing.

    Cycles through a list of pre-defined responses, tracks all calls,
    and supports optional simulated latency.
    """

    def __init__(
        self,
        tool_name: str = "mock",
        responses: list[ToolResult] | None = None,
        category: str = "general",
        latency_ms: int = 0,
        fail_after: int | None = None,
    ) -> None:
        self._name = tool_name
        self._category = category
        self._responses = responses or [ToolResult(success=True, output="mock result")]
        self._call_count = 0
        self._latency_ms = latency_ms
        self._fail_after = fail_after
        self.calls: list[dict[str, Any]] = []

    @property
    def name(self) -> str:
        return self._name

    @property
    def category(self) -> str:
        return self._category

    async def execute(self, params: dict[str, Any]) -> ToolResult:
        self.calls.append(params)
        if self._latency_ms > 0:
            await asyncio.sleep(self._latency_ms / 1000.0)

        if self._fail_after is not None and self._call_count >= self._fail_after:
            self._call_count += 1
            return ToolResult.fail(
                f"MockTool '{self._name}' forced failure after {self._fail_after} calls",
                retryable=True,
            )

        idx = min(self._call_count, len(self._responses) - 1)
        result = self._responses[idx]
        self._call_count += 1
        return result

    def reset(self) -> None:
        """Reset call tracking and counter."""
        self._call_count = 0
        self.calls.clear()

    @property
    def call_count(self) -> int:
        return self._call_count
