"""Chat-tool registry — ``@tool`` decorator, pydantic-validated dispatch.

v2 replacement for v1's hand-maintained ``CHAT_TOOLS`` dict +
``chat_dispatch_tool`` (main.py ≈3423-3615). Tools register themselves via
the decorator; their JSON-schema declarations (ToolSpec.parameters) are
GENERATED from the function signature through a pydantic model instead of
being hand-written, so declaration and validation can never drift.

Tool function contract (see tools/garden_tools.py):

    @tool("name", "description")
    def name(db: Session, garden_id: int, *, arg: int, opt: str = "") -> dict

- ``db`` and ``garden_id`` are injected by ``dispatch`` — every tool is
  garden-scoped; the model never sees those parameters.
- Keyword-only params become the tool's argument schema; params WITHOUT a
  default are ``required``. ``Annotated[..., Field(description=...)]``
  carries per-arg descriptions into the declaration.
- Tools return JSON-serializable dicts; domain misses return
  ``{"error": ...}`` (v1 convention — the model reads it and recovers).

``dispatch`` mirrors v1 chat_dispatch_tool's never-raise posture: unknown
tool, invalid args (pydantic), AppError from a reused service, and DB errors
all come back as ``{"error": ...}`` so a bad call breaks one tool result,
not the whole turn.
"""
from __future__ import annotations

import inspect
from dataclasses import dataclass
from typing import Callable, get_type_hints

from pydantic import BaseModel, ValidationError, create_model
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session

from ...errors import AppError
from .providers.base import ToolSpec

_INJECTED_PARAMS = ("db", "garden_id")


@dataclass(frozen=True)
class RegisteredTool:
    name: str
    description: str
    fn: Callable[..., dict]
    args_model: type[BaseModel]
    spec: ToolSpec


_REGISTRY: dict[str, RegisteredTool] = {}


def _clean_schema(node):
    """pydantic JSON schema -> the object/properties/required subset Gemini
    function declarations accept: drop ``title``/``default`` noise and
    collapse ``anyOf [X, null]`` (Optional fields) to plain X."""
    if isinstance(node, dict):
        node = {k: v for k, v in node.items() if k not in ("title", "default")}
        if "anyOf" in node:
            non_null = [s for s in node["anyOf"] if s.get("type") != "null"]
            if len(non_null) == 1:
                merged = {k: v for k, v in node.items() if k != "anyOf"}
                merged.update(non_null[0])
                return _clean_schema(merged)
        return {k: _clean_schema(v) for k, v in node.items()}
    if isinstance(node, list):
        return [_clean_schema(v) for v in node]
    return node


def _build_args_model(name: str, fn: Callable) -> type[BaseModel]:
    hints = get_type_hints(fn, include_extras=True)
    fields: dict = {}
    for pname, param in inspect.signature(fn).parameters.items():
        if pname in _INJECTED_PARAMS:
            continue
        ann = hints.get(pname, str)
        default = (
            param.default if param.default is not inspect.Parameter.empty else ...
        )
        fields[pname] = (ann, default)
    return create_model(f"{name}_args", **fields)


def tool(name: str, description: str):
    """Register ``fn`` as a callable chat tool. Import the defining module
    (tools/garden_tools.py) to populate the registry."""

    def deco(fn: Callable[..., dict]):
        if name in _REGISTRY:
            raise ValueError(f"chat tool {name!r} registered twice")
        args_model = _build_args_model(name, fn)
        schema = _clean_schema(args_model.model_json_schema())
        schema.setdefault("type", "object")
        schema.setdefault("properties", {})
        _REGISTRY[name] = RegisteredTool(
            name=name,
            description=description,
            fn=fn,
            args_model=args_model,
            spec=ToolSpec(name=name, description=description, parameters=schema),
        )
        return fn

    return deco


def tool_specs() -> list[ToolSpec]:
    """Declarations for the provider call, in registration order."""
    return [rt.spec for rt in _REGISTRY.values()]


def tool_names() -> list[str]:
    return list(_REGISTRY)


def dispatch(db: Session, garden_id: int, name: str, args: dict | None) -> dict:
    """Execute one tool call. Never raises (v1 chat_dispatch_tool posture)."""
    rt = _REGISTRY.get(name)
    if rt is None:
        return {"error": f"unknown tool {name!r}"}
    try:
        parsed = rt.args_model(**(args or {}))
    except ValidationError as e:
        problems = "; ".join(
            f"{'.'.join(str(x) for x in err['loc'])}: {err['msg']}"
            for err in e.errors()
        )
        return {"error": f"bad arguments for {name}: {problems}"}
    except TypeError as e:
        return {"error": f"bad arguments for {name}: {e}"}
    try:
        return rt.fn(db, garden_id, **parsed.model_dump())
    except AppError as e:
        return {"error": e.message}
    except SQLAlchemyError as e:
        return {"error": f"database error in {name}: {e.__class__.__name__}"}
