"""Unit tests for connectonion/tool_executor.py"""
"""
LLM-Note: Tests for tool executor

What it tests:
- Tool Executor functionality

Components under test:
- Module: tool_executor
"""


import pytest
from unittest.mock import Mock, patch
from connectonion.core.tool_executor import execute_single_tool, _add_assistant_message
from connectonion.core.tool_registry import ToolRegistry
from connectonion.core.tool_factory import create_tool_from_function
from connectonion.logger import Logger
from connectonion.core.llm import ToolCall


class FakeAgent:
    def __init__(self):
        self.name = "test-agent"
        self.current_session = {"messages": [], "trace": [], "iteration": 1}
        self.connection = None
        self.io = None
        self._trace_id = 0

    def _next_trace_id(self):
        self._trace_id += 1
        return self._trace_id

    def _record_trace(self, entry: dict):
        """Record trace entry - mirrors Agent._record_trace."""
        if 'id' not in entry:
            entry['id'] = self._next_trace_id()
        self.current_session['trace'].append(entry)

    def _invoke_events(self, event_type: str):
        """Stub for event invocation - no-op in test."""
        pass


class TestToolExecutor:
    """Test minimal path of tool execution utility."""

    def test_execute_tool_success(self):
        """Test successful tool execution path via execute_single_tool."""
        def sample_tool(x: int) -> int:
            return x * 2

        tools = ToolRegistry()
        tools.add(create_tool_from_function(sample_tool))

        agent = FakeAgent()
        logger = Logger("test-agent", log=False)
        trace = execute_single_tool(
            tool_name="sample_tool",
            tool_args={"x": 5},
            tool_id="call_1",
            tools=tools,
            agent=agent,
            logger=logger,
        )
        assert trace["status"] == "success"
        # execute_single_tool stores the raw result as string in trace['result']
        assert "10" in str(trace["result"])

    def test_execute_tool_not_found(self):
        """Unknown tool returns not_found status."""
        tools = ToolRegistry()
        agent = FakeAgent()
        logger = Logger("test-agent", log=False)
        trace = execute_single_tool(
            tool_name="missing",
            tool_args={},
            tool_id="call_2",
            tools=tools,
            agent=agent,
            logger=logger,
        )
        assert trace["status"] == "not_found"


class TestAddAssistantMessage:
    """Tests for _add_assistant_message null handling (Gemini compatibility)."""

    def test_excludes_extra_content_when_none(self):
        """extra_content field should NOT be included when it's None.

        Gemini API rejects null values where it expects structs.
        See: https://discuss.ai.google.dev/t/openai-compatibility-api-does-not-support-message-refusal-field-set-to-null/100010
        """
        messages = []
        tool_calls = [
            ToolCall(
                name="test_tool",
                arguments={"x": 1},
                id="call_1",
                extra_content=None  # No extra content
            )
        ]

        _add_assistant_message(messages, tool_calls)

        assert len(messages) == 1
        assistant_msg = messages[0]
        assert assistant_msg["role"] == "assistant"
        assert "tool_calls" in assistant_msg

        # Verify extra_content is NOT in the tool call when None
        tc = assistant_msg["tool_calls"][0]
        assert "extra_content" not in tc
        assert tc["id"] == "call_1"
        assert tc["function"]["name"] == "test_tool"

    def test_includes_extra_content_when_present(self):
        """extra_content field should be included when it has a value.

        This is required for Gemini 3 thought_signature to be echoed back.
        See: https://ai.google.dev/gemini-api/docs/thinking#openai-sdk
        """
        messages = []
        thought_sig = {"signature": "abc123", "type": "thought"}
        tool_calls = [
            ToolCall(
                name="test_tool",
                arguments={"x": 1},
                id="call_1",
                extra_content=thought_sig
            )
        ]

        _add_assistant_message(messages, tool_calls)

        tc = messages[0]["tool_calls"][0]
        assert "extra_content" in tc
        assert tc["extra_content"] == thought_sig

    def test_assistant_message_no_content_field(self):
        """Assistant message with tool_calls should not have content field.

        Gemini rejects null content values. Omitting the field is safer.
        """
        messages = []
        tool_calls = [
            ToolCall(name="test", arguments={}, id="call_1", extra_content=None)
        ]

        _add_assistant_message(messages, tool_calls)

        assistant_msg = messages[0]
        # content key should not be present (or if present, not None)
        assert "content" not in assistant_msg or assistant_msg.get("content") is not None


class TestLoggerIntegration:
    """Tests for logger integration in tool executor."""

    def test_log_tool_call_invoked_before_execution(self):
        """Logger.log_tool_call is called before tool execution."""
        def sample_tool(x: int) -> int:
            return x * 2

        tools = ToolRegistry()
        tools.add(create_tool_from_function(sample_tool))

        agent = FakeAgent()
        logger = Mock()

        execute_single_tool(
            tool_name="sample_tool",
            tool_args={"x": 5},
            tool_id="call_1",
            tools=tools,
            agent=agent,
            logger=logger,
        )

        # Verify log_tool_call was called with correct args
        logger.log_tool_call.assert_called_once_with("sample_tool", {"x": 5})

    def test_log_tool_result_invoked_after_success(self):
        """Logger.log_tool_result is called after successful execution."""
        def sample_tool(x: int) -> int:
            return x * 2

        tools = ToolRegistry()
        tools.add(create_tool_from_function(sample_tool))

        agent = FakeAgent()
        logger = Mock()

        execute_single_tool(
            tool_name="sample_tool",
            tool_args={"x": 5},
            tool_id="call_1",
            tools=tools,
            agent=agent,
            logger=logger,
        )

        # Verify log_tool_result was called with result and timing
        logger.log_tool_result.assert_called_once()
        args, kwargs = logger.log_tool_result.call_args
        assert args[0] == "10"  # Result as string
        assert isinstance(args[1], float)  # Timing in ms
        assert args[1] >= 0  # Non-negative timing

    def test_log_tool_call_invoked_for_not_found(self):
        """Logger.log_tool_call is called even when tool not found."""
        tools = ToolRegistry()
        agent = FakeAgent()
        logger = Mock()

        execute_single_tool(
            tool_name="missing_tool",
            tool_args={"x": 1},
            tool_id="call_1",
            tools=tools,
            agent=agent,
            logger=logger,
        )

        # log_tool_call still called
        logger.log_tool_call.assert_called_once_with("missing_tool", {"x": 1})
        # log_tool_result NOT called for not_found
        logger.log_tool_result.assert_not_called()
        # Error message printed
        logger.print.assert_called_once()
        assert "not found" in logger.print.call_args[0][0]

    def test_logger_print_called_on_error(self):
        """Logger.print is called with error message when tool raises."""
        def failing_tool() -> str:
            raise ValueError("Something went wrong")

        tools = ToolRegistry()
        tools.add(create_tool_from_function(failing_tool))

        agent = FakeAgent()
        logger = Mock()

        trace = execute_single_tool(
            tool_name="failing_tool",
            tool_args={},
            tool_id="call_1",
            tools=tools,
            agent=agent,
            logger=logger,
        )

        # Verify error handling
        assert trace["status"] == "error"
        logger.log_tool_call.assert_called_once()
        logger.print.assert_called_once()
        error_output = logger.print.call_args[0][0]
        assert "Error" in error_output or "✗" in error_output
        assert "Something went wrong" in error_output

    def test_error_includes_schema_info(self):
        """Error result includes tool schema so LLM can fix the call."""
        def write_file(path: str, content: str) -> str:
            """Write content to file."""
            return f"wrote {len(content)} bytes"

        tools = ToolRegistry()
        tools.add(create_tool_from_function(write_file))

        agent = FakeAgent()
        logger = Mock()

        # Call with missing required argument
        trace = execute_single_tool(
            tool_name="write_file",
            tool_args={"path": "/tmp/test.py"},  # Missing 'content'!
            tool_id="call_1",
            tools=tools,
            agent=agent,
            logger=logger,
        )

        # Verify error includes schema info
        assert trace["status"] == "error"
        result = trace["result"]
        assert "required=" in result
        assert "path" in result
        assert "content" in result
        assert "you_provided=" in result
