"""Tool-calling actor runtime for CleverAgents execution actors.

Implements the core tool-call loop that maps ``ToolRegistry`` specs to
LLM provider tool schemas and executes tool calls through
``ToolCallRouter`` / ``ToolRunner`` with iteration safeguards.

## Loop Semantics

```
1. Convert ToolRegistry specs -> provider tool schemas
2. Send prompt + tool schemas to LLM
3. If LLM returns tool calls:
   a. Route each call through ToolCallRouter -> ToolRunner
   b. Capture metadata (name, inputs, outputs, duration, success)
   c. Thread sandbox root + resource bindings into tool inputs
   d. Feed tool results back to LLM
   e. Repeat from step 2
4. If LLM responds without tool calls or max_iterations reached:
   a. Return final response
```

## Safety

- ``max_iterations`` (default 25) prevents infinite loops
- Each iteration is tracked; exceeding the limit produces a
  ``MaxIterationsExceededError``

Based on ``implementation_plan.md`` task M1.actor-runtime.
"""

from __future__ import annotations

import logging
import time
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable

from pydantic import BaseModel, ConfigDict, Field

from cleveragents.infrastructure.events.models import DomainEvent
from cleveragents.infrastructure.events.types import EventType
from cleveragents.tool.actor_context import ToolActorContext, ToolCallRecord
from cleveragents.tool.registry import ToolRegistry
from cleveragents.tool.router import (
    ProviderFormat,
    ToolCallRouter,
    normalize_tool_schema_for_provider,
)
from cleveragents.tool.runner import ToolRunner
from cleveragents.tool.runtime import ToolError

if TYPE_CHECKING:
    from cleveragents.infrastructure.events.protocol import EventBus

logger = logging.getLogger(__name__)

_DEFAULT_MAX_ITERATIONS = 25


# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------


class MaxIterationsExceededError(Exception):
    """Raised when the tool-call loop exceeds the configured iteration limit."""


# ---------------------------------------------------------------------------
# LLM response model
# ---------------------------------------------------------------------------


class LLMToolCall(BaseModel):
    """A single tool call requested by the LLM."""

    name: str = Field(..., min_length=1, description="Tool name to invoke")
    arguments: dict[str, Any] = Field(
        default_factory=dict, description="Arguments for the tool"
    )

    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
    )


class LLMResponse(BaseModel):
    """Structured response from an LLM invocation.

    If ``tool_calls`` is non-empty the LLM is requesting tool executions.
    Otherwise ``content`` contains the final text response.
    """

    content: str = Field("", description="Text content of the LLM response")
    tool_calls: list[LLMToolCall] = Field(
        default_factory=list, description="Tool calls requested by the LLM"
    )

    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
    )


# ---------------------------------------------------------------------------
# LLM caller protocol
# ---------------------------------------------------------------------------


@runtime_checkable
class LLMCaller(Protocol):
    """Protocol for invoking an LLM with tool schemas.

    Implementations wrap a concrete LLM provider (LangChain, direct API,
    or a test double) and translate between the provider's native format
    and the ``LLMResponse`` model.
    """

    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.

        Parameters
        ----------
        prompt:
            The user/system prompt text.
        tool_schemas:
            Provider-formatted tool schema dicts.
        tool_results:
            Results from prior tool calls to feed back to the LLM.
        actor_config:
            Actor configuration (provider, model, options).

        Returns
        -------
        LLMResponse:
            The LLM's response, potentially containing tool calls.
        """
        ...


# ---------------------------------------------------------------------------
# Run result
# ---------------------------------------------------------------------------


class ToolCallRunResult(BaseModel):
    """Result of a complete tool-call loop execution.

    Contains the final LLM response content, the complete tool call
    history, iteration count, and whether the loop was terminated by
    the iteration safety limit.
    """

    content: str = Field("", description="Final LLM response text")
    tool_call_history: list[ToolCallRecord] = Field(
        default_factory=list, description="All tool calls made during the run"
    )
    iterations: int = Field(0, ge=0, description="Number of loop iterations")
    terminated_by_limit: bool = Field(
        False, description="Whether max_iterations was reached"
    )

    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
    )


# ---------------------------------------------------------------------------
# ToolCallingRuntime
# ---------------------------------------------------------------------------


class ToolCallingRuntime:
    """Tool-calling runtime for execution actors.

    Orchestrates the tool-call loop: converts registry specs to provider
    schemas, sends prompts to the LLM, routes tool calls through the
    runner, and repeats until the LLM produces a final response or the
    iteration limit is reached.

    Parameters
    ----------
    registry:
        The ``ToolRegistry`` containing available tool specs.
    runner:
        The ``ToolRunner`` for executing tools.
    llm_caller:
        An ``LLMCaller`` implementation for invoking the LLM.
    router:
        Optional ``ToolCallRouter`` for provider format translation.
        When not supplied, tool calls are executed directly via the runner.
    max_iterations:
        Maximum number of tool-call loop iterations (default 25).
    provider_format:
        Target provider format for schema export (default ``LANGCHAIN``).
    """

    def __init__(
        self,
        *,
        registry: ToolRegistry,
        runner: ToolRunner,
        llm_caller: LLMCaller,
        router: ToolCallRouter | None = None,
        max_iterations: int = _DEFAULT_MAX_ITERATIONS,
        provider_format: ProviderFormat = ProviderFormat.LANGCHAIN,
        plan_env: str | None = None,
        plan_priority: str | None = None,
        project_env: str | None = None,
        project_priority: str | None = None,
        event_bus: EventBus | None = None,
    ) -> None:
        if not isinstance(registry, ToolRegistry):
            raise TypeError("registry must be a ToolRegistry")
        if not isinstance(runner, ToolRunner):
            raise TypeError("runner must be a ToolRunner")
        if max_iterations < 1:
            raise ValueError("max_iterations must be at least 1")

        self._registry = registry
        self._runner = runner
        self._llm_caller = llm_caller
        self._router = router
        self._max_iterations = max_iterations
        self._provider_format = provider_format
        self._plan_env = plan_env
        self._plan_priority = plan_priority
        self._project_env = project_env
        self._project_priority = project_priority
        self._event_bus = event_bus

    def _try_emit(
        self, event_type: EventType, plan_id: str, details: dict[str, Any]
    ) -> None:
        """Best-effort event emission — never raises."""
        if self._event_bus is None:
            return
        try:
            self._event_bus.emit(
                DomainEvent(event_type=event_type, plan_id=plan_id, details=details)
            )
        except Exception:
            logger.warning("event_bus_emit_failed: %s", event_type.value)

    # -- Properties -----------------------------------------------------------

    @property
    def max_iterations(self) -> int:
        """Return the configured max iterations."""
        return self._max_iterations

    @property
    def provider_format(self) -> ProviderFormat:
        """Return the target provider format."""
        return self._provider_format

    # -- Schema export --------------------------------------------------------

    def export_tool_schemas(
        self,
        namespace: str | None = None,
    ) -> list[dict[str, Any]]:
        """Export tool schemas in the configured provider format.

        If a ``ToolCallRouter`` is available, delegates to its
        ``export_schemas`` method.  Otherwise, converts each spec
        individually using ``normalize_tool_schema_for_provider``.

        Parameters
        ----------
        namespace:
            Optional namespace filter.

        Returns
        -------
        list[dict]:
            Provider-normalized tool schemas.
        """
        if self._router is not None:
            return self._router.export_schemas(
                self._provider_format, namespace=namespace
            )

        specs = self._registry.list_tools(namespace=namespace)
        return [
            normalize_tool_schema_for_provider(spec, self._provider_format)
            for spec in specs
        ]

    # -- Tool execution -------------------------------------------------------

    def _execute_tool_call(
        self,
        tool_call: LLMToolCall,
        actor_context: ToolActorContext,
        iteration: int,
    ) -> dict[str, Any]:
        """Execute a single tool call and record metadata.

        Threads sandbox root and resource bindings into the tool inputs,
        executes via the router (if available) or runner, and records
        a ``ToolCallRecord`` in the actor context.

        Parameters
        ----------
        tool_call:
            The LLM-requested tool call.
        actor_context:
            The actor context carrying sandbox/resource state.
        iteration:
            Current loop iteration number.

        Returns
        -------
        dict:
            Tool result as a dict for feeding back to the LLM.
        """
        # Thread sandbox root + resource bindings into inputs
        enriched_inputs = dict(tool_call.arguments)
        if actor_context.sandbox_root:
            enriched_inputs.setdefault("sandbox_root", actor_context.sandbox_root)
        if actor_context.resource_bindings:
            enriched_inputs.setdefault(
                "resource_bindings", actor_context.resource_bindings
            )

        start = time.monotonic()
        success = False
        output: dict[str, Any] = {}
        error: str | None = None

        if self._router is not None:
            # Route through ToolCallRouter for provider format handling
            payload: dict[str, Any] = {
                "name": tool_call.name,
                "args": enriched_inputs,
                "type": "tool_call",
            }
            routed_result = self._router.route(payload)
            elapsed_ms = (time.monotonic() - start) * 1000.0
            success = routed_result.result.success
            output = routed_result.result.output
            error = routed_result.result.error
        else:
            # Execute directly via runner, catching ToolError for not-found
            try:
                result = self._runner.execute(
                    tool_call.name,
                    enriched_inputs,
                    plan_env=self._plan_env,
                    plan_priority=self._plan_priority,
                    project_env=self._project_env,
                    project_priority=self._project_priority,
                )
                elapsed_ms = (time.monotonic() - start) * 1000.0
                success = result.success
                output = result.output
                error = result.error
            except ToolError as exc:
                elapsed_ms = (time.monotonic() - start) * 1000.0
                success = False
                output = {}
                error = str(exc)
            except Exception as exc:
                elapsed_ms = (time.monotonic() - start) * 1000.0
                success = False
                output = {}
                error = f"{type(exc).__name__}: {exc}"

        # Emit ACTOR_ERRORED if tool call failed
        if not success:
            self._try_emit(
                EventType.ACTOR_ERRORED,
                actor_context.plan_id,
                {
                    "tool_name": tool_call.name,
                    "error": error or "",
                    "iteration": iteration,
                },
            )

        # Record metadata
        record = ToolCallRecord(
            tool_name=tool_call.name,
            inputs=dict(tool_call.arguments),
            output=output,
            duration_ms=round(elapsed_ms, 2),
            success=success,
            error=error,
            iteration=iteration,
        )
        actor_context.record_tool_call(record)

        # Build result for LLM feedback
        result_dict: dict[str, Any] = {
            "tool_name": tool_call.name,
            "success": success,
            "output": output,
        }
        if error:
            result_dict["error"] = error

        return result_dict

    # -- Main loop ------------------------------------------------------------

    def run_tool_loop(
        self,
        prompt: str,
        actor_config: dict[str, Any] | None = None,
        context: ToolActorContext | None = None,
    ) -> ToolCallRunResult:
        """Execute the tool-calling loop.

        Sends the prompt with tool schemas to the LLM, executes any
        requested tool calls, feeds results back, and repeats until the
        LLM responds without tool calls or ``max_iterations`` is reached.

        Parameters
        ----------
        prompt:
            The initial prompt text for the LLM.
        actor_config:
            Optional actor configuration dict (provider, model, etc.).
        context:
            Optional ``ToolActorContext`` carrying sandbox/resource state.
            If not provided, a minimal context is created.

        Returns
        -------
        ToolCallRunResult:
            The final result including content, history, and loop metadata.

        Raises
        ------
        ValueError:
            If prompt is empty.
        """
        if not prompt:
            raise ValueError("prompt must not be empty")

        if context is None:
            context = ToolActorContext(plan_id="default", phase="execute")

        # Emit ACTOR_INVOKED event
        self._try_emit(
            EventType.ACTOR_INVOKED,
            context.plan_id,
            {"plan_id": context.plan_id, "phase": context.phase},
        )

        # Export tool schemas
        tool_schemas = self.export_tool_schemas()

        tool_results: list[dict[str, Any]] | None = None
        final_content = ""

        for iteration in range(1, self._max_iterations + 1):
            logger.debug(
                "Tool-call loop iteration %d/%d",
                iteration,
                self._max_iterations,
            )

            # Invoke LLM
            llm_response = self._llm_caller.invoke(
                prompt=prompt,
                tool_schemas=tool_schemas,
                tool_results=tool_results,
                actor_config=actor_config,
            )

            # No tool calls -> done
            if not llm_response.tool_calls:
                final_content = llm_response.content
                # Emit ACTOR_COMPLETED event
                self._try_emit(
                    EventType.ACTOR_COMPLETED,
                    context.plan_id,
                    {
                        "plan_id": context.plan_id,
                        "iterations": iteration,
                        "tool_calls_count": len(context.tool_call_history),
                    },
                )
                return ToolCallRunResult(
                    content=final_content,
                    tool_call_history=context.tool_call_history,
                    iterations=iteration,
                    terminated_by_limit=False,
                )

            # Execute each tool call
            tool_results = []
            for tc in llm_response.tool_calls:
                result_dict = self._execute_tool_call(tc, context, iteration)
                tool_results.append(result_dict)

        # Max iterations reached
        logger.warning(
            "Tool-call loop reached max iterations (%d)", self._max_iterations
        )
        # Emit ACTOR_ESCALATED when max iterations reached
        self._try_emit(
            EventType.ACTOR_ESCALATED,
            context.plan_id,
            {
                "plan_id": context.plan_id,
                "reason": "max_iterations_reached",
                "max_iterations": self._max_iterations,
            },
        )
        return ToolCallRunResult(
            content=final_content,
            tool_call_history=context.tool_call_history,
            iterations=self._max_iterations,
            terminated_by_limit=True,
        )
