from __future__ import annotations

"""Decorator-based custom tool support."""

import dataclasses
import inspect
from dataclasses import dataclass, field
from typing import Any, Callable, get_args, get_origin, get_type_hints

import httpx

from agentrspy.core import Tool, ToolError, ToolOutput

from .registry import IntoToolOutput


@dataclass(slots=True)
class ToolContext:
    """Shared contextual utilities available to tools."""

    http: httpx.AsyncClient = field(default_factory=httpx.AsyncClient)
    metadata: dict[str, Any] = field(default_factory=dict)


class CallableTool(Tool):
    """Tool implementation backed by a Python callable."""

    def __init__(
        self,
        func: Callable[..., Any],
        name: str | None = None,
        description: str | None = None,
    ) -> None:
        self._func = func
        self._name = name or func.__name__
        self._description = description or inspect.getdoc(func) or ""
        self._signature = inspect.signature(func)
        self._type_hints = get_type_hints(func)

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

    def description(self) -> str:
        return self._description

    def schema(self) -> dict[str, Any]:
        properties: dict[str, Any] = {}
        required: list[str] = []
        for parameter in self._signature.parameters.values():
            if parameter.name == "context":
                continue
            annotation = self._type_hints.get(parameter.name, Any)
            if dataclasses.is_dataclass(annotation):
                return _schema_for_dataclass(annotation)
            properties[parameter.name] = _schema_for_annotation(annotation)
            if parameter.default is inspect._empty:
                required.append(parameter.name)
        schema: dict[str, Any] = {"type": "object", "properties": properties}
        if required:
            schema["required"] = required
        return schema

    async def call(self, input_value: Any) -> ToolOutput:
        bound_args = _bind_input(self._signature, self._type_hints, input_value)
        try:
            result = self._func(**bound_args)
            if inspect.isawaitable(result):
                result = await result
            return IntoToolOutput.convert(result)
        except Exception as error:  # pragma: no cover - passthrough behavior
            if isinstance(error, ToolError):
                raise
            raise ToolError.execution(str(error)) from error


def tool(
    func: Callable[..., Any] | None = None,
    *,
    name: str | None = None,
    description: str | None = None,
):
    """Convert a function into a `Tool` instance."""

    def decorator(inner: Callable[..., Any]) -> CallableTool:
        return CallableTool(inner, name=name, description=description)

    if func is not None:
        return decorator(func)
    return decorator


def _bind_input(
    signature: inspect.Signature, type_hints: dict[str, Any], input_value: Any
) -> dict[str, Any]:
    if not signature.parameters:
        return {}

    parameters = [parameter for parameter in signature.parameters.values()]
    if (
        len(parameters) == 1
        and dataclasses.is_dataclass(type_hints.get(parameters[0].name))
        and isinstance(input_value, dict)
    ):
        annotation = type_hints[parameters[0].name]
        return {parameters[0].name: annotation(**input_value)}

    if len(parameters) == 1 and not isinstance(input_value, dict):
        parameter = parameters[0]
        return {
            parameter.name: _coerce_value(
                input_value, type_hints.get(parameter.name, Any)
            )
        }

    if not isinstance(input_value, dict):
        raise ToolError.invalid_input("expected object input")

    bound: dict[str, Any] = {}
    for parameter in parameters:
        if parameter.name == "context":
            bound[parameter.name] = ToolContext()
            continue
        if parameter.name not in input_value:
            if parameter.default is inspect._empty:
                raise ToolError.invalid_input(f"missing '{parameter.name}'")
            bound[parameter.name] = parameter.default
            continue
        bound[parameter.name] = _coerce_value(
            input_value[parameter.name], type_hints.get(parameter.name, Any)
        )
    return bound


def _coerce_value(value: Any, annotation: Any) -> Any:
    if annotation is Any:
        return value
    if dataclasses.is_dataclass(annotation) and isinstance(value, dict):
        return annotation(**value)
    origin = get_origin(annotation)
    args = get_args(annotation)
    if origin is list and args and isinstance(value, list):
        return [_coerce_value(item, args[0]) for item in value]
    if origin is dict and len(args) == 2 and isinstance(value, dict):
        return {
            item_key: _coerce_value(item_value, args[1])
            for item_key, item_value in value.items()
        }
    if origin is not None and type(None) in args and value is None:
        return None
    return value


def _schema_for_dataclass(annotation: type[Any]) -> dict[str, Any]:
    properties: dict[str, Any] = {}
    required: list[str] = []
    hints = get_type_hints(annotation)
    for field_info in dataclasses.fields(annotation):
        properties[field_info.name] = _schema_for_annotation(
            hints.get(field_info.name, Any)
        )
        if (
            field_info.default is dataclasses.MISSING
            and field_info.default_factory is dataclasses.MISSING
        ):
            required.append(field_info.name)
    schema: dict[str, Any] = {"type": "object", "properties": properties}
    if required:
        schema["required"] = required
    return schema


def _schema_for_annotation(annotation: Any) -> dict[str, Any]:
    origin = get_origin(annotation)
    args = get_args(annotation)
    if annotation in {str, Any}:
        return {"type": "string"} if annotation is str else {}
    if annotation is int:
        return {"type": "integer"}
    if annotation is float:
        return {"type": "number"}
    if annotation is bool:
        return {"type": "boolean"}
    if dataclasses.is_dataclass(annotation):
        return _schema_for_dataclass(annotation)
    if origin is list:
        return {
            "type": "array",
            "items": _schema_for_annotation(args[0] if args else Any),
        }
    if origin is dict:
        return {"type": "object"}
    if origin is None and isinstance(annotation, type) and issubclass(annotation, str):
        return {"type": "string"}
    return {}
