from __future__ import annotations

import asyncio
import ast
import base64
import math
import os
import threading
import time
from datetime import datetime, date

from colorama import Fore
from dataclasses import dataclass
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Callable, Iterable

from .agent_mcp_tools import exec_mcp_tool
from .helpers import ToolContext, ToolFn, log_msg, vlog
from .rag.knowledgebase import query_knowledge_base, search_web
from .helpers import create_working_folder

# Default tools available in every agent (always allowed)
DEFAULT_TOOL_NAMES: tuple[str, ...] = (
    "calculator_tool",
    "get_current_date_tool",
    "get_current_time_tool",
)

# Intent: restrict imports to reduce the attack surface when executing arbitrary code strings.
_ALLOWED_PYTHON_IMPORTS: set[str] = {
    "asyncio",
    "base64",
    "collections",
    "csv",
    "datetime",
    "io",
    "itertools",
    "json",
    "math",
    "matplotlib",
    "numpy",
    "os",
    "pandas",
    "pathlib",
    "random",
    "re",
    "seaborn",
    "statistics",
    "time",
    "typing",
    "unicodedata",
}


@dataclass(slots=True)
class ToolExecutionResult:
    """Representa o resultado de uma ferramenta com metadados de execução."""

    value: Any | None
    elapsed_seconds: float
    error: str | None = None

    def to_model_payload(self) -> Any:
        """Normaliza o resultado para ser devolvido ao modelo."""
        if self.error:
            return {"error": self.error}
        return self.value


def calculator_tool(expression: str, *, ctx: ToolContext | None = None) -> Any:
    """Evaluate arithmetic expressions using a restricted math namespace.

    Args:
        expression (str): Expressão Python focada em operações matemáticas.
        ctx (ToolContext | None, optional): Contexto opcional usado para logs verbosos. Defaults to None.

    Returns:
        Any: Resultado numérico da expressão avaliada.

    Raises:
        ValueError: Quando a expressão contém construções não permitidas ou apresenta erro de sintaxe.
    """
    print(f"{Fore.LIGHTBLUE_EX}Fazendo cálculo com a ferramenta 'calculator_tool'")

    if ctx and getattr(ctx, "is_verbose", False):
        vlog(ctx, f"session={getattr(ctx, 'session_id', '-')}", func="calculator_tool")

    # Whitelist public attributes from math
    allowed_math = {k: v for k, v in vars(math).items() if not k.startswith("_")}
    # Expose as bare names and under a `math` namespace
    allowed_names: dict[str, Any] = dict(allowed_math)
    allowed_names["math"] = SimpleNamespace(**allowed_math)
    # A few safe builtins commonly used in numeric expressions
    allowed_names.update({
        "abs": abs,
        "min": min,
        "max": max,
        "sum": sum,
        "round": round,
        "pow": pow,
    })

    # Validate AST to allow only arithmetic expressions and math calls
    allowed_builtin_names = {"abs", "min", "max", "sum", "round", "pow"}
    allowed_math_names = set(allowed_math.keys())

    def _is_safe(node: ast.AST) -> bool:
        """Check whether the AST node uses only whitelisted constructs.

        Args:
            node (ast.AST): Nó produzido pelo parse da expressão fornecida.

        Returns:
            bool: `True` quando o nó é considerado seguro, caso contrário `False`.
        """
        # Expression root
        if isinstance(node, ast.Expression):
            return _is_safe(node.body)

        # Literals
        if isinstance(node, (ast.Num, ast.Constant)):
            return isinstance(getattr(node, "value", None), (int, float)) or isinstance(node, ast.Num)

        # Names (variables/constants): must be in allowed (math names or allowed builtins or 'math')
        if isinstance(node, ast.Name):
            return node.id in allowed_math_names or node.id in allowed_builtin_names or node.id == "math"

        # Attribute access: only math.<public_name>
        if isinstance(node, ast.Attribute):
            return isinstance(node.value, ast.Name) and node.value.id == "math" and node.attr in allowed_math_names

        # Unary operations: +x, -x
        if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)):
            return _is_safe(node.operand)

        # Binary operations: + - * / // % **
        if isinstance(node, ast.BinOp) and isinstance(node.op,
                                                      (ast.Add, ast.Sub, ast.Mult, ast.Div, ast.FloorDiv, ast.Mod,
                                                       ast.Pow)):
            return _is_safe(node.left) and _is_safe(node.right)

        # Calls: function(...) where function is allowed
        if isinstance(node, ast.Call):
            func_ok = False
            if isinstance(node.func, ast.Name):
                func_ok = node.func.id in allowed_math_names or node.func.id in allowed_builtin_names
            elif isinstance(node.func, ast.Attribute):
                func_ok = isinstance(node.func.value,
                                     ast.Name) and node.func.value.id == "math" and node.func.attr in allowed_math_names
            if not func_ok:
                return False
            # Validate args and keywords values only (no starargs/kwargs nodes in py3.12 AST here)
            return all(_is_safe(a) for a in node.args) and all(_is_safe(kw.value) for kw in node.keywords)

        # Parentheses/grouping is represented implicitly; tuples can appear only if used in args
        if isinstance(node, ast.Tuple):
            return all(_is_safe(elt) for elt in node.elts)

        # Disallow everything else
        return False

    try:
        tree = ast.parse(expression, mode="eval")
    except SyntaxError as e:
        raise ValueError(f"Expressão inválida: {e}")
    if not _is_safe(tree):
        raise ValueError("Expressão contém construções não permitidas para cálculo seguro.")

    code = compile(tree, "<calc>", "eval")
    result = eval(code, {"__builtins__": {}}, allowed_names)
    return result


def get_current_date_tool(*, ctx: ToolContext | None = None) -> str:
    """Retrieve the current system date.

    Args:
        ctx (ToolContext | None, optional): Contexto do agente usado apenas para logging. Defaults to None.

    Returns:
        str: Data atual formatada como `YYYY-MM-DD`.
    """
    print(f"{Fore.LIGHTBLUE_EX}Consultando a data atual no calendário com a ferramenta 'get_current_date_tool'")

    if ctx and getattr(ctx, "is_verbose", False):
        vlog(ctx, f"session={getattr(ctx, 'session_id', '-')}", func="get_current_date_tool")

    return date.today().strftime("%Y-%m-%d")


def get_current_time_tool(*, ctx: ToolContext | None = None) -> str:
    """Retrieve the current system time.

    Args:
        ctx (ToolContext | None, optional): Contexto do agente usado apenas para logging. Defaults to None.

    Returns:
        str: Hora atual formatada como `HH:MM:SS`.
    """
    print(f"{Fore.LIGHTBLUE_EX}Consultando a hora atual no relógio com a ferramenta 'get_current_time_tool'")

    if ctx and getattr(ctx, "is_verbose", False):
        vlog(ctx, f"session={getattr(ctx, 'session_id', '-')}", func="get_current_time_tool")

    return datetime.now().time().strftime("%H:%M:%S")


async def _run_code_async(code: str, output_dir: Path, *, message_id: str | None = None) -> Any:
    """Execute user-provided Python code inside an async runner.

    Args:
        code (str): Script Python sincronizado/assíncrono a ser executado.
        output_dir (Path | None, optional): Diretório onde artefatos (logs, imagens) serão gravados. Defaults to None.
        message_id (str | None, optional): Identificador da mensagem usado para nomear arquivos de saída. Defaults to None.

    Returns:
        Any: Valor produzido pelo script, incluindo estruturas com imagens em base64 quando aplicável.

    Raises:
        Exception: Propaga exceções geradas durante a execução do código do usuário.
    """
    local_vars: dict[str, Any] = {}
    captured_paths: list[str] = []
    # Exec context is intentionally permissive to support arbitrary instructions.
    import random as _rand

    def _patched_show(*args, **kwargs):  # type: ignore[no-untyped-def]
        """Persist matplotlib figures to disk instead of displaying them.

        Args:
            *args: Argumentos posicionais encaminhados para `matplotlib.pyplot.show`.
            **kwargs: Argumentos nomeados encaminhados para `matplotlib.pyplot.show`.

        Returns:
            None: A função intercepta a chamada padrão e salva as figuras como arquivos PNG.
        """
        try:
            from matplotlib import pyplot as plt  # type: ignore
            nums = plt.get_fignums()
            saved: list[str] = []
            for number in nums:
                figure = plt.figure(number)
                # File name must follow: "<message_id>-<X>.png" where X is [1..100]
                msg_id = str(message_id) if message_id else "default"
                for _ in range(5):
                    x = _rand.randint(1, 100)
                    fname = f"{msg_id}-{x}.png"
                    path = output_dir / fname
                    if not path.exists():
                        break
                else:
                    # Fallback (very unlikely) after retries
                    x = _rand.randint(101, 1000)
                    fname = f"{msg_id}-{x}.png"

                path = output_dir / fname
                figure.savefig(path)
                saved.append(str(path))
            captured_paths.extend(saved)
        except Exception as _e:  # noqa: F841
            pass
        return None

    # Prefer a headless backend for matplotlib operations
    try:
        os.environ.setdefault("MPLBACKEND", "Agg")
    except Exception:
        pass

    # Proactively patch pyplot.show before user code runs
    try:
        import matplotlib  # type: ignore
        # Ensure non-interactive backend if not already set
        try:
            import matplotlib.pyplot as _plt  # type: ignore
            _plt.show = _patched_show  # type: ignore[attr-defined]
        except Exception:
            pass
    except Exception:
        pass

    # Expose optional modules (pandas may be missing in some environments)
    try:
        import pandas as _pd  # type: ignore
    except ImportError:
        _pd = None
    import unicodedata as _unicodedata
    import re as _re
    try:
        import seaborn as _sns  # type: ignore
    except ImportError:
        _sns = None

    # Capture stdout/stderr produced by the executed code
    import io
    from contextlib import redirect_stdout, redirect_stderr

    exec_globals: dict[str, Any] = {"asyncio": asyncio, "os": os, "unicodedata": _unicodedata, "base64": base64, "re": _re}
    if _pd is not None:
        exec_globals["pandas"] = _pd
        exec_globals["pd"] = _pd
    if _sns is not None:
        exec_globals["seaborn"] = _sns
        exec_globals["sns"] = _sns
    stdout_buf, stderr_buf = io.StringIO(), io.StringIO()
    with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
        exec(code, exec_globals, local_vars)

    # If no images were captured via patched show, try to save any open figures
    if not captured_paths:
        try:
            from matplotlib import pyplot as _plt2  # type: ignore
            nums2 = _plt2.get_fignums()
            if nums2:
                for num in nums2:
                    fig = _plt2.figure(num)
                    mid2 = str(message_id) if message_id else "default"
                    base_dir2 = output_dir
                    for _ in range(5):
                        x2 = _rand.randint(1, 100)
                        fname2 = f"{mid2}-{x2}.png"
                        path2 = base_dir2 / fname2
                        if not path2.exists():
                            break
                    else:
                        x2 = _rand.randint(1, 100)
                        fname2 = f"{mid2}-{x2}.png"
                        path2 = base_dir2 / fname2

                    path2.parent.mkdir(parents=True, exist_ok=True)
                    fig.savefig(path2)
                    captured_paths.append(str(path2))
        except Exception:
            pass

    # Build result structure
    result_val = local_vars.get("result")

    # Persist script output to file (always). Filename must be prefixed by `message_id`.
    try:
        base_dir = output_dir
        base_dir.mkdir(parents=True, exist_ok=True)
        mid = str(message_id) if message_id else "default"
        out_file = base_dir / f"{mid}_python_output.json"
        payload: dict[str, Any] = {
            "stdout": stdout_buf.getvalue(),
            "stderr": stderr_buf.getvalue(),
            "result": result_val,
            "images": captured_paths[:],
        }
        try:
            import json as _json
            out_file.write_text(_json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
        except Exception:
            # Fallback to a plain-text serialization
            text = (
                "# Script output (stdout)\n" + payload["stdout"] +
                "\n# Errors (stderr)\n" + payload["stderr"] +
                "\n# Result\n" + str(payload["result"]) +
                "\n# Images\n" + "\n".join(payload["images"])
            )
            out_file.write_text(text, encoding="utf-8")
    except Exception:
        pass

    if captured_paths:
        images: list[dict[str, str]] = []
        for p in captured_paths:
            try:
                data = Path(p).read_bytes()
                images.append({"path": p, "base64": base64.b64encode(data).decode("ascii")})
            except Exception:
                images.append({"path": p, "base64": ""})
        if result_val is None:
            return {"images": images}
        return {"result": result_val, "images": images}
    return result_val


def _run_coro_in_thread(coro):  # type: ignore[no-untyped-def]
    """Execute an async coroutine inside a dedicated worker thread.

    Args:
        coro: Coroutine que será executada utilizando `asyncio.run`.

    Returns:
        Any: Resultado produzido pela coroutine após a conclusão.

    Raises:
        BaseException: Repropaga exceções levantadas pela coroutine.
    """
    result_holder: dict[str, Any] = {}
    error_holder: dict[str, BaseException] = {}

    def _target():
        """Wrapper responsável por executar a coroutine e capturar resultados.

        Returns:
            None: Apenas atualiza `result_holder` ou `error_holder`.
        """
        try:
            result_holder["value"] = asyncio.run(coro)
        except BaseException as e:  # capture to re-raise in caller thread
            error_holder["error"] = e

    t = threading.Thread(target=_target, daemon=True)
    t.start()
    t.join()
    if "error" in error_holder:
        raise error_holder["error"]
    return result_holder.get("value")


def _validate_python_imports(code: str, allowed_modules: set[str]) -> None:
    """Validate import statements within a Python code string.

    Args:
        code (str): Script Python a ser analisado.
        allowed_modules (set[str]): Conjunto de módulos permitidos para import.

    Raises:
        ImportError: Quando o script tenta importar um módulo não permitido.
        SyntaxError: Quando o script não é um Python válido.
    """
    # Intent: block modules that can expand runtime capabilities beyond the intended sandbox.
    tree = ast.parse(code)
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            for alias in node.names:
                module_name = alias.name.split(".")[0]
                if module_name not in allowed_modules:
                    raise ImportError(f"Import de módulo não permitido: {module_name}")
        elif isinstance(node, ast.ImportFrom):
            # Import relativo é bloqueado para evitar acesso arbitrário ao filesystem local.
            if node.level and node.level > 0:
                raise ImportError("Import relativo não permitido.")
            module_name = (node.module or "").split(".")[0]
            if module_name and module_name not in allowed_modules:
                raise ImportError(f"Import de módulo não permitido: {module_name}")


def execute_python_code_tool(code: str, *, ctx: ToolContext | None = None) -> Any:
    """Execute Python code in a sandbox honoring the agent session context.

    Args:
        code (str): Script Python a ser executado, preferencialmente atribuindo saídas à variável `result`.
        ctx (ToolContext | None, optional): Contexto da ferramenta contendo diretórios de trabalho e ids. Defaults to None.

    Returns:
        Any: Resultado retornado pela execução (_stdout_, _stderr_ e possíveis imagens).

    Raises:
        ImportError: Quando o script tenta importar módulos não permitidos.
        BaseException: Propaga quaisquer erros levantados durante a execução do código do usuário.
    """
    if ctx and getattr(ctx, "is_verbose", False):
        vlog(ctx, f"session={getattr(ctx, 'session_id', '-')}", func="ao")

    working_folder = create_working_folder(Path(ctx.working_folder), getattr(ctx, "session_id", "default_session_id"))
    out_dir = Path(working_folder)

    script_file = Path(out_dir, "script.py")
    script_file.write_text(code, encoding="utf-8")

    if not code or not code.strip():
        return "Erro: o script Python não foi informado."

    # Intent: keep outputs/artefacts stored inside a per-session working folder.
    session_id = getattr(ctx, "session_id", "default_session_id") if ctx else "default_session_id"
    message_id = getattr(ctx, "message_id", None) if ctx else None
    base_folder = Path(getattr(ctx, "working_folder", None) or "_oskar_working_folder")
    output_dir = Path(create_working_folder(base_folder, session_id))

    _validate_python_imports(code, _ALLOWED_PYTHON_IMPORTS)

    try:
        res = _run_coro_in_thread(_run_code_async(code, output_dir, message_id=message_id))
        return res
    except ImportError:
        raise
    except BaseException as exc:
        # Intent: surface execution errors while keeping verbose logs for diagnostics.
        if ctx and getattr(ctx, "is_verbose", False):
            vlog(ctx, f"session={getattr(ctx, 'session_id', '-')}; error={exc}", func="execute_python_code_tool")
        raise



def retriever_tool(source_name: str, query: str, *, top_k: int = 5,
                   ctx: ToolContext | None = None) -> list[str]:
    """Retrieve relevant documents via the configured vector store.

    Args:
        source_name (str): Nome da fonte de dados.
        query (str): Texto usado como chave de busca semântica.
        top_k (int, optional): Quantidade de documentos que devem ser retornados. Defaults to 4.
        ctx (ToolContext | None, optional): Contexto utilizado para logs verbosos. Defaults to None.

    Returns:
        list[str]: Lista de conteúdos/documentos similares ou vazia quando não há resultados.
    """
    print(f"{Fore.LIGHTBLUE_EX}Recuperando documentos de um vector store com a ferramenta 'retriever_tool'")

    if ctx and getattr(ctx, "is_verbose", False):
        vlog(ctx, f"session={getattr(ctx, 'session_id', '-')}", func="retriever_tool")

    retrievers = getattr(ctx, "retrievers")
    if retrievers is None:
        return []

    for retriever_info in retrievers:
        if retriever_info["name"] != source_name:
            continue

        try:
            results = query_knowledge_base(query=query,
                                           knowledge_base_name=retriever_info['name'],
                                           knowledge_base_folder=retriever_info["details"]['kb_path'],
                                           num_of_itens=top_k)
            return results
        except Exception as e:
            # Log error with details and return a structured error item to surface the issue
            vlog(ctx, f"session={getattr(ctx, 'session_id', '-')}", func=f"retriever_tool ==> ERROR: {str(e)}")

    return []


def search_web_tool(query: str, *, ctx: ToolContext | None = None) -> str | None:
    """Proxy to perform web searches when allowed by the environment.

    Args:
        query (str): Termo que será consultado externamente.
        ctx (ToolContext | None, optional): Contexto do agente usado para logging. Defaults to None.

    Returns:
        list[dict[str, Any]]: Lista de resultados estruturados; vazia quando a funcionalidade não está disponível.
    """
    print(f"{Fore.LIGHTBLUE_EX}Pesquisando informações na internet com a ferramenta 'search_web_tool'")

    if ctx and getattr(ctx, "is_verbose", False):
        vlog(ctx, f"session={getattr(ctx, 'session_id', '-')}", func="search_web_tool")

    return search_web(query)


def read_file_tool(pathname: str, *, as_base64: bool = False,
                   ctx: ToolContext | None = None) -> str:
    """Read file contents relative to the agent workspace.

    Args:
        pathname (str): pathname do arquivo, incluindo a pasta mais o nome do arquivo que será lido.
        as_base64 (bool, optional): Quando `True`, retorna o conteúdo codificado em base64. Defaults to False.
        ctx (ToolContext | None, optional): Contexto usado para identificar sessão e diretórios. Defaults to None.

    Returns:
        str: Conteúdo textual (ou base64) do arquivo solicitado.

    Raises:
        FileNotFoundError: Se o arquivo não puder ser localizado.
        OSError: Para erros de leitura de arquivo.
    """
    print(f"{Fore.LIGHTBLUE_EX}Lendo arquivo com a ferramenta 'read_file_tool'")

    if ctx and getattr(ctx, "is_verbose", False):
        vlog(ctx, f"[tool] read_file_tool() called; file={pathname}")

    if not pathname:
        return f"Erro: o nome do arquivo não foi informado"

    try:
        if '\\' in pathname or '/' in pathname:
            # foi informado um pathname completo
            file_path = Path(pathname)
        else:
            # quando não é informado um pathname completo então considera o working folder
            file_path = Path(create_working_folder(Path(ctx.working_folder), getattr(ctx, "session_id", "default_session_id"))) / pathname

        if as_base64:
            file_data = file_path.read_bytes().decode("ascii")
        else:
            file_data = file_path.read_text(encoding="utf-8")
        return file_data
    except Exception as e:
        return f"Erro ao ler arquivo: {e}"


def write_file_tool(pathname: str, content: str, *,
                    ctx: ToolContext | None = None) -> str:
    """Persist textual content to disk within the agent session scope.

    Args:
        pathname (str): pathname do arquivo, incluindo a pasta mais o nome do arquivo que será gravado.
        content (str): Conteúdo textual a ser gravado.
        ctx (ToolContext | None, optional): Contexto de execução contendo `session_id` e `message_id`. Defaults to None.

    Returns:
        str: Mensagem de status sobre a gravação do arquivo.

    Raises:
        OSError: Caso ocorra erro ao criar diretórios ou gravar o conteúdo.
    """
    print(f"{Fore.LIGHTBLUE_EX}Gravando arquivo com a ferramenta 'write_file_tool'")

    if ctx and getattr(ctx, "is_verbose", False):
        vlog(ctx, f"[tool] write_file_tool() called; file={pathname}")

    if not pathname:
        return f"Erro: o nome do arquivo não foi informado"

    message_id = getattr(ctx, "message_id", None) if ctx else None

    try:
        if '\\' in pathname or '/' in pathname:
            # foi informado um pathname completo
            file_path = Path(pathname)
        else:
            # quando não é informado um pathname completo então considera o working folder
            safe_filename = f"{message_id}_{pathname}" if message_id else pathname
            file_path = Path(create_working_folder(Path(ctx.working_folder), getattr(ctx, "session_id", "default_session_id"))) / safe_filename

        file_path.write_text(content, encoding="utf-8")
        return "Arquivo salvo com sucesso."
    except Exception as e:
        return f"Erro ao salvar o arquivo: {e}"


def ask_to_agent_tool(agent_name: str, question: str, *, ctx: ToolContext | None = None) -> str:
    """Forward a prompt to a subordinate agent registered in the context.

    Args:
        agent_name (str): Identificador único do agente que receberá a pergunta.
        question (str): Texto da consulta a ser encaminhada.
        ctx (ToolContext | None, optional): Contexto contendo a lista de agentes subordinados. Defaults to None.

    Returns:
        str: Resposta formatada do agente consultado ou mensagem informando que não foi encontrado.
    """
    if ctx and getattr(ctx, "is_verbose", False):
        vlog(ctx, f"[tool] ask_to_agent_tool() called; session={getattr(ctx, 'session_id', '-')}")

    subordinate_agents = getattr(ctx, "subordinate_agents", [])
    agent = next((a for a in subordinate_agents if a.name == agent_name), None)

    if agent is None:
        vlog(ctx, f"[tool] ask_to_agent_tool() called; session={getattr(ctx, 'session_id', '-')}; agent {agent_name} not found")
        return f"O agente {agent_name} não foi encontrado"

    print(f"{Fore.LIGHTBLUE_EX}Consultando o agente '{agent.name}'")

    result: dict[str, Any] = agent.answer(
        question=question,
        action="consult",
        message_format="raw",
        is_consult_prompt=True,
    )

    # submete a pergunta/pedido para o agente
    answer = (result or {}).get("content") or ""
    return f"Segue a resposta do agente:\n {answer}"


def get_builtin_tools() -> dict[str, ToolFn]:
    """Return the registry of builtin tools available to oskaragent.

    Returns:
        dict[str, ToolFn]: Mapeamento entre nomes de ferramentas e funções executáveis.
    """
    tools = {
        "calculator_tool": calculator_tool,
        "get_current_date_tool": get_current_date_tool,
        "get_current_time_tool": get_current_time_tool,
        "execute_python_code_tool": execute_python_code_tool,
        "retriever_tool": (
            lambda source_name, query, top_k=4, *, ctx=None: retriever_tool(source_name, query, top_k=top_k, ctx=ctx)),
        "search_web_tool": search_web_tool,
        "read_file_tool": (
            lambda pathname, as_base64=False, *, ctx=None: read_file_tool(pathname, as_base64=as_base64, ctx=ctx)),
        "write_file_tool": (lambda pathname, content, *, ctx=None: write_file_tool(pathname, content, ctx=ctx)),
        "ask_to_agent_tool": (lambda agent_name, question, *, ctx=None: ask_to_agent_tool(agent_name, question, ctx=ctx)),
    }

    return tools


def build_tool_schemas(tool_names: Iterable[str]) -> list[dict[str, Any]]:
    """Generate minimal JSON schema definitions for API-exposed tools.

    Args:
        tool_names (Iterable[str]): Conjunto de nomes de ferramentas permitidos na sessão.

    Returns:
        list[dict[str, Any]]: Lista de esquemas no formato esperado pela OpenAI Responses API.
    """
    schemas: list[dict[str, Any]] = []
    for name in tool_names:
        if name == "calculator_tool":
            schemas.append({
                "type": "function",
                "name": name,
                "description": "Avalia uma expressão matemática Python segura. Essa função precisa receber como argumento uma expressão matemática Python segura.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "expression": {"type": "string", "description": "Expressão matemática Python segura"},
                    },
                    "required": ["expression"],
                    "additionalProperties": False,
                },
            })

        elif name == "get_current_date_tool":
            schemas.append({
                "type": "function",
                "name": name,
                "description": "Consulta a data atual, data de hoje informada pelo sistema.",
                "parameters": {
                    "type": "object",
                    "properties": {},
                    "additionalProperties": False,
                },
            })

        elif name == "get_current_time_tool":
            schemas.append({
                "type": "function",
                "name": name,
                "description": "Consulta a hora atual, hora de agora informada pelo sistema.",
                "parameters": {
                    "type": "object",
                    "properties": {},
                    "additionalProperties": False,
                },
            })

        elif name == "retriever_tool":
            schemas.append({
                "type": "function",
                "name": name,
                "description": "Busca dados relevantes no índice local (RAG). Essa função precisa receber dois argumentos: nome da fonte de dados e 'query'.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "source_name": {"type": "string", "description": "Nome da fonte de dados"},
                        "query": {"type": "string", "description": "Texto para pesquisa"},
                        "top_k": {"type": "integer", "minimum": 1, "maximum": 50},
                    },
                    "required": ["source_name", "query"],
                    "additionalProperties": False,
                },
            })

        elif name == "search_web_tool":
            schemas.append({
                "type": "function",
                "name": name,
                "description": "Faz busca na web. Essa função precisa receber como argumento o texto para pesquisa.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "query": {"type": "string", "description": "Texto para pesquisa"},
                    },
                    "required": ["query"],
                    "additionalProperties": False,
                },
            })

        elif name == "execute_python_code_tool":
            schemas.append({
                "type": "function",
                "name": name,
                "description": "Executa código Python assíncrono controlado (retorna variável 'result'). Essa função precisa receber como argumento o script a ser executado.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "code": {"type": "string", "description": "Código Python a ser executado"},
                    },
                    "required": ["code"],
                    "additionalProperties": False,
                },
            })

        elif name == "read_file_tool":
            schemas.append({
                "type": "function",
                "name": name,
                "description": "Use essa ferramenta para ler dados de um arquivo. Essa função precisa receber como argumento o nome do arquivo.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "pathname": {"type": "string", "description": "Pathname do arquivo, incluindo o diretório e o nome do arquivo"},
                        "as_base64": {"type": "boolean", "default": False},
                    },
                    "required": ["pathname"],
                    "additionalProperties": False,
                },

            })

        elif name == "write_file_tool":
            schemas.append({
                "type": "function",
                "name": name,
                "description": "Use essa ferramenta para salvar/gravar dados num arquivo. Essa função precisa receber dois argumentos do tipo string: nome do arquivo e conteúdo.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "pathname": {"type": "string", "description": "Pathname do arquivo, incluindo o diretório e o nome do arquivo"},
                        "content": {"type": "string", "description": "Conteúdo do arquivo"},
                    },
                    "required": ["pathname", "content"],
                    "additionalProperties": False,
                },
            })

        elif name == "ask_to_agent_tool":
            schemas.append({
                "type": "function",
                "name": name,
                "description": "Use essa ferramenta para fazer perguntas/pedidos para outros agentes. Essa função precisa receber dois argumentos do tipo string: nome do agente e pergunta/pedido.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "agent_name": {"type": "string", "description": "Nome do agente que será consultado"},
                        "question": {"type": "string", "description": "Pergunta ou pedido para o agente"},
                    },
                    "required": ["agent_id", "question"],
                    "additionalProperties": False,
                },
            })

    return schemas


def build_custom_tool_schemas(custom_tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Generate minimal JSON schema definitions for API-exposed tools.

    Exemplo de ferramentas personalizadas:
    [
        {
            'tool': 'GetOpportunitySummary',
            'custom_tool': ask_to_agent_tool,
            'description': 'Prepara um sumário de uma oportunidade. Para usar essa ferramenta submeta um prompt solicitando o sumário, incluindo código da oportunidade.',
            'agent_name': 'opportunity_specialist',
        }
    ]

    Args:
        custom_tools (list[dict[str, Any]]): parametrização das ferramentas personalizadas.

    Returns:
        list[dict[str, Any]]: Lista de esquemas no formato esperado pela OpenAI Responses API.
    """

    schemas: list[dict[str, Any]] = []
    for custom_tool in custom_tools:
        # considera os mesmos parâmetros da ferramenta interna associada
        tool_parameters = build_tool_schemas([custom_tool['tool']])

        schemas.append({
            "type": "function",
            "name": custom_tool['custom_tool'],
            "description": custom_tool['description'],
            "parameters": tool_parameters[0]['args_obj'] if tool_parameters else {},
        })

    return schemas


def exec_tool(tool_name: str, tool_fn, args_obj: dict[str, Any], ctx: ToolContext) -> Any:
    """Execute a tool by name and arguments."""
    # se for uma ferramenta interna então executa
    match tool_name:
        case "get_current_date_tool":
            return tool_fn(ctx=ctx)

        case "get_current_time_tool":
            return tool_fn(ctx=ctx)

        case "calculator_tool":
            expr = args_obj.get("expression") or args_obj.get("expr") or args_obj.get(
                    "code") or ""
            return tool_fn(expression=str(expr), ctx=ctx)

        case "search_web_tool":
            q = args_obj.get("query") or args_obj.get("q") or args_obj.get("text") or ""
            return tool_fn(query=str(q), ctx=ctx)

        case "retriever_tool":
            source_name = args_obj.get("source_name") or args_obj.get("source") or args_obj.get("name") or ""
            q = args_obj.get("query") or args_obj.get("q") or args_obj.get("text") or ""
            top_k = args_obj.get("top_k") or 4
            return tool_fn(source_name=str(source_name), query=str(q), top_k=int(top_k), ctx=ctx)

        case "execute_python_code_tool":
            code = args_obj.get("code") or args_obj.get("source") or args_obj.get(
                    "python") or ""
            return tool_fn(code=str(code), ctx=ctx)

        case "read_file_tool":
            pathname = args_obj.get("pathname") or args_obj.get("file") or ""
            as_b64 = bool(args_obj.get("as_base64", False))
            return tool_fn(pathname=str(pathname), ctx=ctx) if not as_b64 else tool_fn(pathname=str(pathname), as_base64=True, ctx=ctx)

        case "write_file_tool":
            pathname = args_obj.get("pathname") or args_obj.get("file") or ""
            content = args_obj.get("content") or args_obj.get("text") or ""
            return tool_fn(pathname=str(pathname), content=str(content), ctx=ctx)

        case "ask_to_agent_tool":
            agent_name = args_obj.get("agent_name") or ""
            question = args_obj.get("question") or args_obj.get("prompt") or ""
            return tool_fn(agent_name=str(agent_name), question=str(question), ctx=ctx)

    # se for uma custom tool então executa
    for custom_tool in getattr(ctx, "custom_tools", []):
        if custom_tool["custom_tool"] == tool_name:
            # executa a ferramenta associada à custom tool
            return exec_tool(custom_tool["tool"], tool_fn, args_obj, ctx)

    # se for uma MCP tool então executa
    for tool_schema in ctx.mcp_tools_schema or []:
        if tool_schema["name"] == tool_name:
            return exec_mcp_tool(tool_name, args_obj, ctx)

    # se for uma ferramenta externa definida no código então executa
    for tool_schema in ctx.external_tools_schema or []:
        if tool_schema["name"] == tool_name:
            return tool_fn(tool_name=tool_name, args_obj=args_obj, ctx=ctx)

    # não identificou a ferramenta
    return f"Não consegui identificar a ferramenta '{tool_name}'!"


def execute_tool_with_policy(tool_name: str,
                             tool_fn: ToolFn,
                             args_obj: dict[str, Any],
                             ctx: ToolContext,
                             runner: Callable[[], Any] | None = None) -> ToolExecutionResult:
    """Roda uma ferramenta com logs consistentes."""
    start = time.perf_counter()
    msg_id = getattr(ctx, "message_id", "-")
    if getattr(ctx, "is_verbose", False):
        log_msg(f"id={msg_id} tool_start name={tool_name}", func="tool_runner", action="tools", color="MAGENTA")

    callable_to_run = runner or (lambda: exec_tool(tool_name, tool_fn, args_obj, ctx))
    try:
        value = callable_to_run()
        elapsed = time.perf_counter() - start
        if getattr(ctx, "is_verbose", False):
            log_msg(
                f"id={msg_id} tool_end name={tool_name} elapsed={elapsed:.2f}s",
                func="tool_runner",
                action="tools",
                color="MAGENTA",
            )
        return ToolExecutionResult(value=value, elapsed_seconds=elapsed)
    except Exception as exc:  # noqa: BLE001
        elapsed = time.perf_counter() - start
        err = f"Erro ao executar ferramenta: {exc}"
        log_msg(f"id={msg_id} tool_error name={tool_name}: {err}", func="tool_runner", action="tools", color="RED")
        return ToolExecutionResult(value=None, elapsed_seconds=elapsed, error=err)
