"""ToolCallingLLMCaller — LLMCaller implementation for actor run tool calling.

Wraps a LangChain chat model with tool schema binding and multi-turn tool
result threading for use with ``ToolCallingRuntime`` in the reactive actor
run path.

This caller is stateful within a single ``run_tool_loop()`` invocation: it
accumulates the LangChain message thread across tool-call loop iterations.
Create a fresh instance for each ``process()`` call in ``ToolCallingAgent``.
"""

from __future__ import annotations

import logging
from typing import Any

from cleveragents.application.services.prompt_sanitizer import PromptSanitizer
from cleveragents.tool.actor_runtime import LLMResponse, LLMToolCall

try:
    from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
    from langchain_core.messages.tool import ToolMessage
except Exception:  # pragma: no cover - optional runtime dependency guard
    AIMessage = None  # type: ignore[assignment, misc]
    HumanMessage = None  # type: ignore[assignment, misc]
    SystemMessage = None  # type: ignore[assignment, misc]
    ToolMessage = None  # type: ignore[assignment, misc]

try:
    from jinja2.sandbox import SandboxedEnvironment
except Exception:  # pragma: no cover - optional runtime dependency guard
    SandboxedEnvironment = None  # type: ignore[assignment, misc]

from cleveragents.providers.registry import get_provider_registry

logger = logging.getLogger(__name__)

_SANITIZER = PromptSanitizer()


def _encode_tool_name(name: str) -> str:
    """Encode forbidden characters for Anthropic tool name pattern.

    The Anthropic API requires tool names to match ``^[a-zA-Z0-9_-]{1,128}$``.
    CleverAgents tool names use ``:`` (server prefix) and ``/`` (namespace
    separator), neither of which is in the allowed character set.

    Because valid CleverAgents tool names (per the internal name regex) may
    only contain lowercase letters, digits, ``-``, ``_``, ``:``, and ``/``,
    any **uppercase** letter is guaranteed to be unambiguous as an escape
    marker.  This function replaces:

    * ``:`` → ``_C_`` (C for Colon)
    * ``/`` → ``_S_`` (S for Slash)

    These sentinels cannot collide with legitimate tool names because
    uppercase letters are forbidden in the internal name regex.
    """
    return name.replace(":", "_C_").replace("/", "_S_")


def _decode_tool_name(name: str) -> str:
    """Reverse :func:`_encode_tool_name`.

    Restores ``_S_`` → ``/`` and ``_C_`` → ``:``.
    """
    return name.replace("_S_", "/").replace("_C_", ":")


class ToolCallingLLMCaller:
    """LLMCaller implementation for actor run tool-calling loop.

    Implements the :class:`~cleveragents.tool.actor_runtime.LLMCaller`
    protocol.  On the first ``invoke()`` call it binds tool schemas to the
    LLM via ``bind_tools()`` and sends the initial system + user messages.
    On subsequent calls (when ``tool_results`` is provided) it appends the
    previous AI response and ``ToolMessage`` objects for each result, then
    invokes again to continue the tool-call conversation.

    Parameters
    ----------
    actor_config:
        Actor configuration dict (provider, model, temperature, etc.).
        When not provided an empty dict is used, which falls through to
        ``get_provider_registry().create_llm()`` defaults.
    """

    def __init__(self, actor_config: dict[str, Any] | None = None) -> None:
        self._actor_config: dict[str, Any] = dict(actor_config or {})
        self._llm: Any = None
        self._accumulated: list[Any] = []
        self._first_call: bool = True
        self._last_response: Any = None
        self._template_env = (
            SandboxedEnvironment() if SandboxedEnvironment is not None else None
        )

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

    def _render_prompt(self, template: str, context: dict[str, Any]) -> str:
        """Render a Jinja2 template string against *context*.

        Falls back to returning the raw template if Jinja2 is unavailable or
        rendering fails.
        """
        if not template:
            return ""
        if self._template_env is None:
            return template
        try:
            return self._template_env.from_string(template).render(
                context=context, **context
            )
        except Exception:
            return template

    def _resolve_llm(self, tool_schemas: list[dict[str, Any]]) -> Any:
        """Resolve and cache the LangChain LLM with tools bound.

        The LLM is resolved once on the first ``invoke()`` call.  Subsequent
        calls reuse the cached instance so that tool binding (which may create
        a new LLM wrapper object) is only performed once per loop run.
        """
        if self._llm is not None:
            return self._llm

        provider = self._actor_config.get("provider")
        model = self._actor_config.get("model")
        temperature = self._actor_config.get("temperature")
        max_tokens = self._actor_config.get("max_tokens")
        max_retries = self._actor_config.get("max_retries")

        llm_kwargs: dict[str, Any] = {}

        # M7 (#11243): merge options block so custom LLM backend kwargs
        # (e.g. openai_api_base, openai_api_key) are forwarded to the
        # LLM constructor — mirrors the fix applied to SimpleLLMAgent in
        # stream_router.py (PR #11225 / commit b3851693).
        # Options are applied first so top-level keys take precedence over
        # any duplicates in the options block.
        options = dict(self._actor_config.get("options") or {})
        from cleveragents.actor.config import build_llm_kwargs_from_options

        llm_kwargs.update(build_llm_kwargs_from_options(options, logger=logger))

        if temperature is not None:
            llm_kwargs["temperature"] = temperature
        if max_tokens is not None:
            llm_kwargs["max_tokens"] = max_tokens
        if max_retries is not None:
            llm_kwargs["max_retries"] = max_retries

        registry = get_provider_registry()
        # Annotated as Any because create_llm() returns BaseLanguageModel but the
        # real runtime object is BaseChatModel which has bind_tools().
        base_llm: Any = registry.create_llm(
            provider_type=provider, model_id=model, **llm_kwargs
        )

        if tool_schemas:
            try:
                # Encode tool names for Anthropic: ':' -> '_C_', '/' -> '_S_'
                encoded_schemas = [
                    {**s, "name": _encode_tool_name(s["name"])} for s in tool_schemas
                ]
                self._llm = base_llm.bind_tools(encoded_schemas)
            except Exception as exc:
                logger.warning("bind_tools failed (%s); falling back to plain LLM", exc)
                self._llm = base_llm
        else:
            self._llm = base_llm

        return self._llm

    # -- LLMCaller protocol ---------------------------------------------------

    def invoke(
        self,
        prompt: str,
        tool_schemas: list[dict[str, Any]],
        tool_results: list[dict[str, Any]] | None = None,
        actor_config: dict[str, Any] | None = None,
    ) -> LLMResponse:
        """Send a prompt (with optional tool results) to the LLM.

        On the **first** call (``tool_results is None``):
            Builds the initial ``[SystemMessage, HumanMessage]`` pair from the
            actor's ``system_prompt`` config and the user prompt, then invokes
            the tool-bound LLM.

        On **subsequent** calls (``tool_results`` is a list):
            Appends the previous ``AIMessage`` and one ``ToolMessage`` per
            result entry, then invokes again.

        Parameters
        ----------
        prompt:
            The original user prompt text (used only on the first call).
        tool_schemas:
            Provider-formatted tool schema dicts passed to ``bind_tools()``.
        tool_results:
            Tool execution results from the previous iteration.  Each entry
            is a dict with keys ``tool_name``, ``call_id``, ``success``,
            ``output``, and optionally ``error``.
        actor_config:
            Optional per-call actor config override.  Falls back to the
            instance-level ``actor_config`` passed to ``__init__``.

        Returns
        -------
        LLMResponse
            Structured response including content and any tool calls.
        """
        if HumanMessage is None or SystemMessage is None:
            raise RuntimeError("LangChain messages not available for LLM tool calling")

        effective_config = actor_config or self._actor_config
        llm = self._resolve_llm(tool_schemas)

        if self._first_call:
            system_prompt: str = effective_config.get("system_prompt", "") or ""

            # Apply prompt boundary sanitization (mechanism 2)
            if system_prompt:
                system_prompt = _SANITIZER.augment_system_prompt(system_prompt)
                self._accumulated.append(SystemMessage(content=system_prompt))

            wrapped_prompt = _SANITIZER.wrap_user_content(prompt)
            self._accumulated.append(HumanMessage(content=wrapped_prompt))
            self._first_call = False

        elif tool_results is not None:
            # Append the previous AI response so the model has context
            if self._last_response is not None:
                self._accumulated.append(self._last_response)

            # Append one ToolMessage per result
            for tr in tool_results:
                tool_result_content = (
                    str(tr.get("output", {}))
                    if tr.get("success")
                    else tr.get("error", "error")
                )
                if ToolMessage is not None:
                    self._accumulated.append(
                        ToolMessage(
                            content=tool_result_content,
                            tool_call_id=(
                                tr.get("call_id") or tr.get("tool_name", "unknown")
                            ),
                        )
                    )

        response = llm.invoke(self._accumulated)
        self._last_response = response

        # Extract content
        raw_content = getattr(response, "content", None)
        if raw_content is None:
            raw_content = str(response)
        if isinstance(raw_content, list):
            parts: list[str] = []
            for chunk in raw_content:
                if isinstance(chunk, dict):
                    parts.append(str(chunk.get("text") or chunk.get("content", "")))
                else:
                    parts.append(str(chunk))
            content = " ".join(parts)
        else:
            content = str(raw_content)

        # Extract tool calls from the LangChain response
        tool_calls: list[LLMToolCall] = []
        raw_tool_calls = getattr(response, "tool_calls", None) or []
        for tc in raw_tool_calls:
            if isinstance(tc, dict):
                raw_name = tc.get("name") or tc.get("id", "")
                # Decode tool names from LLM response back to namespaced format
                name = _decode_tool_name(raw_name)
                args = tc.get("args") or tc.get("arguments") or {}
                call_id = tc.get("id", "")
                if name:
                    tool_calls.append(
                        LLMToolCall(name=name, arguments=args, call_id=call_id)
                    )

        return LLMResponse(content=content, tool_calls=tool_calls)
