"""Agent factory — create PydanticAI Agent from PersonaSpec.

Extracted from agent.py to reduce file size.
Spec source: vectl step ``shrink-agent-py`` in ``gate-review-debt`` phase.
"""

# @invar:allow shell_result: Agent factory returns Result types
# @invar:allow forbidden_import: Shell module needs os for env vars

from __future__ import annotations

import os
from typing import TYPE_CHECKING, Any

from pydantic import ValidationError
from pydantic_ai import Agent
from pydantic_ai.exceptions import ModelHTTPError, UserError as PAgentUserError
from returns.result import Failure, Result, Success

from anima.core.execution import (
    ExecutionIntent,
    ExecutionIntentDefaults,
    ExecutionIntentOverrides,
    resolve_execution_contract,
)
from anima.core.schemas import (
    AnimaError,
    InternalError,
    InvalidInputError,
    PersonaSpec,
    SideEffectPolicy,
    UnsupportedModelError,
)
from anima.core.tool_policy import most_restrictive
from anima.shell.agent_helpers import detect_mcp_ctl_tools
from anima.shell.ctl_tools import create_ctl_tools
from anima.shell.model_config import load_global_config
from anima.shell.mcp_toolset_policy import wrap_mcp_toolsets_with_policy
from anima.shell.tool_policy import create_policy_wrapped_tools

from pydantic_ai.toolsets import AbstractToolset, ToolsetTool

if TYPE_CHECKING:
    from anima.shell.agent import AgentRunner


# @shell_complexity: Agent creation requires persona validation and tool setup
# @invar:allow function_size: create_agent - model resolution + MCP detection + tool wiring
async def create_agent(
    persona: PersonaSpec,
    mcp_servers: list[AbstractToolset[Any]] | None = None,
    agent_runner: "AgentRunner | None" = None,
    side_effect_policy: SideEffectPolicy | None = None,
    model_override: str | None = None,
    model_instance: Any | None = None,
) -> Result[Agent[Any], AnimaError]:
    """Create a PydanticAI Agent from PersonaSpec.

    Configures model, system prompt, optional MCP toolsets, and built-in ctl tools.
    Implements combined-mode detection: if MCP toolset provides ctl.* tools,
    those take precedence over built-in ctl tools.

    Args:
        persona: Agent capability specification with model and prompt.
        mcp_servers: Optional list of MCP toolsets to attach to the agent.
        agent_runner: Optional AgentRunner reference for ctl tools.
        side_effect_policy: Job policy for restricting tool calls. Merged with
            persona policy via most_restrictive ordering when persona policy exists.
        model_override: Optional model override from CLI ``--model``.
        model_instance: Optional pre-constructed model instance (e.g., TestModel).
            If provided, bypasses model resolution and uses this instance directly.

    Returns:
        Success with configured Agent, or Failure with AnimaError.

    Examples:
        >>> import asyncio
        >>> from anima.core.schemas import PersonaSpec
        >>> persona = PersonaSpec(
        ...     id="test",
        ...     description="test persona",
        ...     prompt="You are a test agent.",
        ...     model="gpt-5.2"
        ... )
        >>> result = asyncio.run(create_agent(persona))
        >>> isinstance(result, Success)
        True
    """
    # Spec source: vectl step ``model-simplify.simplify-create-agent``.
    # If model_instance is provided, use it directly (testing seam).
    # Otherwise, resolve model via precedence chain.
    resolved_effective_side_effect_policy: SideEffectPolicy | None = None
    if model_instance is not None:
        effective_model = model_instance
    else:
        # Always load config first so ``[env]`` keys are applied before precedence.
        config_result = load_global_config()
        if isinstance(config_result, Failure):
            return Failure(config_result.failure())
        global_config = config_result.unwrap()

        env_model = os.environ.get("ANIMA_DEFAULT_MODEL")
        config_model: str | None = None
        if model_override is None and env_model is None:
            config_model = global_config.defaults.model

        execution_intent = ExecutionIntent(
            persona_spec=persona,
            overrides=ExecutionIntentOverrides(
                requested_model=model_override or env_model,
                requested_side_effect_policy=side_effect_policy,
            ),
            defaults=ExecutionIntentDefaults(
                default_model=config_model,
            ),
        )
        resolved_contract = resolve_execution_contract(
            execution_intent,
            persona_source="file",
        )
        effective_model = resolved_contract.runtime.effective_model
        resolved_effective_side_effect_policy = (
            resolved_contract.tool_access.effective_side_effect_policy
        )

        if not effective_model:
            return Failure(
                InvalidInputError(
                    "Model required. Provide --model, set ANIMA_DEFAULT_MODEL, "
                    "set defaults.model in ~/.anima/config.toml, "
                    "or include model in PersonaSpec."
                )
            )

    try:
        # Spec source: vectl step ``model-simplify.simplify-create-agent``.
        # Pass resolved model string directly to Agent; do not construct providers here.
        agent_kwargs: dict[str, Any] = {
            "model": effective_model,
            "defer_model_check": True,  # Don't connect to API until run
        }
        agent_kwargs["system_prompt"] = persona.prompt

        persona_policy = persona.side_effect_policy
        effective_side_effect_policy = most_restrictive(
            persona_policy,
            resolved_effective_side_effect_policy or side_effect_policy,
        )

        # Sanitize MCP tool names for LLM provider compatibility.
        # Some providers (OpenAI) reject tool names with dots. Replace '.' with '_'.
        sanitized_mcp_servers = (
            _sanitize_mcp_tool_names(mcp_servers) if mcp_servers else None
        )

        # Wrap MCP toolsets with policy enforcement
        # Spec source: spec-compliance-v2.policy-external-mcp
        # Policy must be enforced for external MCP tools via toolset wrapping
        wrapped_mcp_servers = (
            wrap_mcp_toolsets_with_policy(
                sanitized_mcp_servers,
                effective_side_effect_policy,
                agent_runner.instance_id if agent_runner else "unknown",
            )
            if sanitized_mcp_servers
            else None
        )

        # Add MCP toolsets if provided (after policy wrapping)
        if wrapped_mcp_servers:
            agent_kwargs["toolsets"] = wrapped_mcp_servers

        # Detect MCP-provided ctl tools (combined-mode detection)
        # Note: Detect from original toolsets since wrapper is transparent
        mcp_ctl_tools: list[str] = []
        if mcp_servers:
            mcp_ctl_tools = await detect_mcp_ctl_tools(mcp_servers)

        # Add built-in ctl tools if agent_runner is provided
        # Skip built-in tools that MCP already provides
        if agent_runner is not None:
            ctl_tools = create_ctl_tools(agent_runner.instance_id, agent_runner)
            # Filter out tools that MCP already provides
            # Tool objects have a name attribute in PydanticAI
            if mcp_ctl_tools and ctl_tools:
                ctl_tools = [
                    t
                    for t in ctl_tools
                    if getattr(t, "name", None) not in mcp_ctl_tools
                ]
            # Apply policy wrapper to CTL tools if needed
            # Note: CTL tools are always allowed (is_read_only_tool returns True)
            # But we still wrap them for approval_required to handle state
            if ctl_tools and agent_runner is not None:
                ctl_tools = create_policy_wrapped_tools(
                    ctl_tools, effective_side_effect_policy, agent_runner
                )
            if ctl_tools:
                agent_kwargs["tools"] = ctl_tools

        # Note: MCP toolsets are wrapped with policy enforcement above.
        # CTL tools (built-in) are also wrapped with policy in create_policy_wrapped_tools.

        agent: Agent[Any] = Agent(**agent_kwargs)
        return Success(agent)

    except (ModelHTTPError, PAgentUserError) as e:
        # Model configuration errors
        return Failure(
            UnsupportedModelError(
                f"Failed to configure model '{effective_model}': {e}",
                details={"model": effective_model, "error": str(e)},
            )
        )
    except (RuntimeError, TypeError, ValueError, ValidationError) as e:
        # Unexpected errors
        return Failure(
            InternalError(
                f"Unexpected error creating agent: {e}",
                details={"error": str(e), "error_type": type(e).__name__},
            )
        )


import re

# OpenAI tool name pattern: ^[a-zA-Z0-9_-]{1,64}$
_TOOL_NAME_UNSAFE_CHARS = re.compile(r"[^a-zA-Z0-9_-]")


# @invar:allow shell_result: Toolset wrapper passes through results, no Result wrapping needed
# @shell_orchestration: Tool name sanitization adapter for LLM provider compatibility
class _ToolNameSanitizedToolset(AbstractToolset[Any]):
    """Wrapper that sanitizes tool names for LLM provider compatibility.

    Replaces characters not in [a-zA-Z0-9_-] (e.g., dots) with underscores.
    Maintains a mapping to reverse the sanitization on call_tool.
    """

    def __init__(self, wrapped: AbstractToolset[Any]) -> None:
        self._wrapped = wrapped
        self._sanitized_to_original: dict[str, str] = {}

    @property
    def id(self) -> str:
        return self._wrapped.id

    def _sanitize(self, name: str) -> str:
        return _TOOL_NAME_UNSAFE_CHARS.sub("_", name)

    async def get_tools(self, ctx: Any) -> dict[str, ToolsetTool[Any]]:
        original_tools = await self._wrapped.get_tools(ctx)
        sanitized: dict[str, ToolsetTool[Any]] = {}
        self._sanitized_to_original.clear()
        for name, tool in original_tools.items():
            safe_name = self._sanitize(name)
            if safe_name != name:
                self._sanitized_to_original[safe_name] = name
            sanitized[safe_name] = tool
        return sanitized

    async def call_tool(
        self, name: str, tool_args: dict[str, Any], ctx: Any, tool: ToolsetTool[Any]
    ) -> Any:
        original_name = self._sanitized_to_original.get(name, name)
        return await self._wrapped.call_tool(original_name, tool_args, ctx, tool)


# @invar:allow shell_result: Factory function returns toolset objects, not Result
def _sanitize_mcp_tool_names(
    toolsets: list[AbstractToolset[Any]],
) -> list[AbstractToolset[Any]]:
    """Wrap MCP toolsets to sanitize tool names for LLM provider compatibility.

    Replaces dots and other unsafe characters in tool names with underscores.
    This is a fallback for MCP servers that use dots in tool names (e.g., larva_resolve)
    which are rejected by OpenAI's tool name validation.
    """
    return [_ToolNameSanitizedToolset(ts) for ts in toolsets]


__all__ = ["create_agent"]
