"""ToolCallingAgent — actor run agent with LLM tool-calling support.

Implements a reactive agent that drives ``ToolCallingRuntime`` for the
``actor run`` path.  When a skill is attached via ``--skill``, this agent
is used instead of ``SimpleLLMAgent`` so that the LLM can actually invoke
tools during its response loop.
"""

from __future__ import annotations

import logging
from typing import Any

from cleveragents.tool.actor_runtime import ToolCallingRuntime, ToolCallRunResult
from cleveragents.tool.registry import ToolRegistry
from cleveragents.tool.router import ProviderFormat
from cleveragents.tool.runner import ToolRunner

logger = logging.getLogger(__name__)


class ToolCallingAgent:
    """LLM-backed agent that supports real tool calling via ToolCallingRuntime.

    Unlike ``SimpleLLMAgent`` (which calls ``llm.invoke()`` with no tool
    schemas) this agent:

    1. Builds a per-run ``ToolRegistry`` from the resolved skill tool entries
       by looking them up in a shared *builtin_registry*.
    2. Creates a ``ToolRunner`` and a ``ToolCallingLLMCaller`` for that run.
    3. Delegates to ``ToolCallingRuntime.run_tool_loop()`` which handles the
       bind-tools / invoke / execute / feed-back loop.
    4. Exposes ``last_result`` so the CLI can surface the ``tool_calls`` count.

    Parameters
    ----------
    name:
        Actor name (used for logging).
    actor_config:
        Raw actor configuration dict from the YAML file (provider, model,
        system_prompt, temperature, etc.).
    resolved_tool_entries:
        List of resolved tool entry dicts from ``_resolve_skills()``/
        ``_make_agent_instance()``.  Each entry has at least a ``"name"``
        key with the namespaced tool name.
    builtin_registry:
        Shared ``ToolRegistry`` pre-populated with built-in tools
        (file, git, subplan).  Each entry's name is looked up here to
        obtain the real ``ToolSpec``.
    """

    def __init__(
        self,
        name: str,
        actor_config: dict[str, Any] | None,
        resolved_tool_entries: list[dict[str, Any]],
        builtin_registry: ToolRegistry,
    ) -> None:
        self._name = name
        self._actor_config: dict[str, Any] = dict(actor_config or {})
        self._resolved_tool_entries = resolved_tool_entries
        self._builtin_registry = builtin_registry
        self.last_result: ToolCallRunResult | None = None

    # -- Internal helpers -----------------------------------------------------

    def _build_tool_registry(self) -> ToolRegistry:
        """Build a fresh per-run ``ToolRegistry`` from resolved entries.

        Each entry's ``"name"`` is looked up in the shared
        ``_builtin_registry``.  Entries whose name is not found (e.g. inline
        or MCP tools not yet in the builtin registry) are warned and skipped
        so the run degrades gracefully rather than crashing.

        Returns
        -------
        ToolRegistry
            A new registry containing only the successfully resolved tools.
        """
        local_registry = ToolRegistry()
        seen: set[str] = set()

        for entry in self._resolved_tool_entries:
            tool_name: str = entry.get("name", "") if isinstance(entry, dict) else ""
            if not tool_name:
                continue
            if tool_name in seen:
                # Deduplicate: the same builtin may appear more than once when
                # multiple skills reference it.
                continue
            spec = self._builtin_registry.get(tool_name)
            if spec is None:
                logger.warning(
                    "ToolCallingAgent '%s': tool '%s' not found in builtin "
                    "registry — skipping (inline/MCP tools not yet supported "
                    "in actor run tool-calling path)",
                    self._name,
                    tool_name,
                )
                continue
            try:
                local_registry.register(spec)
                seen.add(tool_name)
            except Exception as exc:  # pylint: disable=broad-except
                logger.debug(
                    "ToolCallingAgent '%s': skipping '%s' (%s)",
                    self._name,
                    tool_name,
                    exc,
                )

        return local_registry

    # -- Agent interface ------------------------------------------------------

    @staticmethod
    def _resolve_provider_format(actor_config: dict[str, Any] | None) -> ProviderFormat:
        """Map an actor config's ``provider`` field to the correct ``ProviderFormat``.

        The format determines the schema key emitted by
        ``normalize_tool_schema_for_provider``, which must survive
        LangChain's ``convert_to_openai_function()`` call inside
        ``bind_tools()``:

        - ``ProviderFormat.LANGCHAIN`` emits ``args_schema`` → **silently
          dropped** by ``convert_to_openai_function()`` → ``bind_tools``
          raises ``'parameters'``.
        - ``ProviderFormat.ANTHROPIC`` emits ``input_schema`` → preserved
          and mapped to ``parameters``.
        - ``ProviderFormat.OPENAI`` emits ``parameters`` → preserved
          directly.

        Therefore ``ProviderFormat.LANGCHAIN`` must never be used in the
        ``ToolCallingRuntime`` → ``bind_tools()`` pipeline.  The default
        for unrecognised providers is ``ProviderFormat.OPENAI`` since
        most providers accept OpenAI-compatible tool schemas.
        """
        if not actor_config:
            return ProviderFormat.OPENAI
        provider: str = (actor_config.get("provider") or "").lower()
        if provider == "anthropic":
            return ProviderFormat.ANTHROPIC
        # openai, google, groq, azure, openrouter, gemini, cohere,
        # together, mock, and any unknown provider default to OpenAI
        # format since all of these consume OpenAI-compatible tool
        # schemas through LangChain's bind_tools().
        return ProviderFormat.OPENAI

    def process(
        self,
        content: Any,
        metadata: dict[str, Any] | None = None,
        context: dict[str, Any] | None = None,
    ) -> Any:
        """Execute the tool-calling loop and return the final text response.

        Parameters
        ----------
        content:
            The user prompt (string or coercible to string).
        metadata:
            Optional stream metadata (currently unused).
        context:
            Optional Jinja2 rendering context (currently unused).

        Returns
        -------
        str
            The final text response from the LLM after all tool calls.
        """
        # Lazy import to avoid circular imports at module load time
        from cleveragents.reactive.tool_caller import ToolCallingLLMCaller

        prompt = content if isinstance(content, str) else str(content or "")
        local_registry = self._build_tool_registry()
        runner = ToolRunner(local_registry)
        caller = ToolCallingLLMCaller(self._actor_config)
        runtime = ToolCallingRuntime(
            registry=local_registry,
            runner=runner,
            llm_caller=caller,
            provider_format=self._resolve_provider_format(self._actor_config),
        )
        result = runtime.run_tool_loop(prompt, actor_config=self._actor_config)
        self.last_result = result
        return result.content

    def process_message_sync(
        self, content: Any, metadata: dict[str, Any] | None = None
    ) -> Any:
        """Synchronous variant for stream-router compatibility."""
        return self.process(content, metadata)
