# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Licensed under the MIT License - see LICENSE file for details

"""
Tests for cross-provider tool-calling reliability (P0-P3).

Covers:
- tool_choice parameter threading across all providers
- Empty tool-call retry guard in agent loop
- Ollama robustness fixes (narrow exceptions, structured history)
- Gemini native SDK provider
- Metrics integration (tool counts, empty retries)
"""

import json
from unittest.mock import MagicMock, patch

import pytest

from familiar.core.metrics import LLMCallRecord, MetricsCollector
from familiar.core.providers import (
    LLMResponse,
    ToolCall,
    _anthropic_to_openai_tools,
)

# ── Shared fixtures ──────────────────────────────────────────────

SAMPLE_TOOLS = [
    {
        "name": "read_file",
        "description": "Read a file",
        "input_schema": {
            "type": "object",
            "properties": {"path": {"type": "string"}},
            "required": ["path"],
        },
    },
    {
        "name": "write_file",
        "description": "Write a file",
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string"},
                "content": {"type": "string"},
            },
            "required": ["path", "content"],
        },
    },
]


# =============================================================================
# TestToolChoice — tool_choice parameter on all providers
# =============================================================================


class TestToolChoice:
    """Verify tool_choice is threaded to each provider's API call."""

    def test_anthropic_tool_choice_auto(self):
        """Anthropic: tool_choice='auto' → kwargs['tool_choice'] = {'type': 'auto'}."""
        from familiar.core.providers import AnthropicProvider

        with patch("familiar.core.providers.AnthropicProvider.__init__", return_value=None):
            provider = AnthropicProvider.__new__(AnthropicProvider)
            provider.model = "claude-sonnet-4-6"
            provider.client = MagicMock()

            mock_response = MagicMock()
            mock_response.content = []
            mock_response.stop_reason = "end_turn"
            mock_response.usage = MagicMock(input_tokens=10, output_tokens=5)
            provider.client.messages.create.return_value = mock_response

            provider.chat(
                messages=[],
                tools=SAMPLE_TOOLS,
                system="test",
                tool_choice="auto",
            )

            call_kwargs = provider.client.messages.create.call_args[1]
            assert call_kwargs["tool_choice"] == {"type": "auto"}

    def test_anthropic_tool_choice_any(self):
        """Anthropic: tool_choice='any' → kwargs['tool_choice'] = {'type': 'any'}."""
        from familiar.core.providers import AnthropicProvider

        with patch("familiar.core.providers.AnthropicProvider.__init__", return_value=None):
            provider = AnthropicProvider.__new__(AnthropicProvider)
            provider.model = "claude-sonnet-4-6"
            provider.client = MagicMock()

            mock_response = MagicMock()
            mock_response.content = []
            mock_response.stop_reason = "end_turn"
            mock_response.usage = MagicMock(input_tokens=10, output_tokens=5)
            provider.client.messages.create.return_value = mock_response

            provider.chat(
                messages=[],
                tools=SAMPLE_TOOLS,
                system="test",
                tool_choice="any",
            )

            call_kwargs = provider.client.messages.create.call_args[1]
            assert call_kwargs["tool_choice"] == {"type": "any"}

    def test_openai_tool_choice_any_maps_to_required(self):
        """OpenAI: tool_choice='any' → kwargs['tool_choice'] = 'required'."""
        from familiar.core.providers import OpenAIProvider

        with patch("familiar.core.providers.OpenAIProvider.__init__", return_value=None):
            provider = OpenAIProvider.__new__(OpenAIProvider)
            provider.model = "gpt-4o"
            provider.client = MagicMock()

            mock_response = MagicMock()
            mock_response.choices = [MagicMock()]
            mock_response.choices[0].message.content = "ok"
            mock_response.choices[0].message.tool_calls = None
            mock_response.usage = MagicMock(prompt_tokens=10, completion_tokens=5)
            provider.client.chat.completions.create.return_value = mock_response

            provider.chat(
                messages=[],
                tools=SAMPLE_TOOLS,
                system="test",
                tool_choice="any",
            )

            call_kwargs = provider.client.chat.completions.create.call_args[1]
            assert call_kwargs["tool_choice"] == "required"

    def test_openai_tool_choice_none_omitted(self):
        """OpenAI: tool_choice=None → no tool_choice in kwargs."""
        from familiar.core.providers import OpenAIProvider

        with patch("familiar.core.providers.OpenAIProvider.__init__", return_value=None):
            provider = OpenAIProvider.__new__(OpenAIProvider)
            provider.model = "gpt-4o"
            provider.client = MagicMock()

            mock_response = MagicMock()
            mock_response.choices = [MagicMock()]
            mock_response.choices[0].message.content = "ok"
            mock_response.choices[0].message.tool_calls = None
            mock_response.usage = MagicMock(prompt_tokens=10, completion_tokens=5)
            provider.client.chat.completions.create.return_value = mock_response

            provider.chat(
                messages=[],
                tools=SAMPLE_TOOLS,
                system="test",
                tool_choice=None,
            )

            call_kwargs = provider.client.chat.completions.create.call_args[1]
            assert "tool_choice" not in call_kwargs

    def test_ollama_tool_choice_passed(self):
        """Ollama: tool_choice='any' forwarded as 'required' to OpenAI client."""
        from familiar.core.providers import OllamaProvider

        with patch("familiar.core.providers.OllamaProvider.__init__", return_value=None):
            provider = OllamaProvider.__new__(OllamaProvider)
            provider.model = "qwen3:8b"
            provider.base_url = "http://localhost:11434"
            provider.timeout = 120
            provider._supports_tools_cache = True
            provider.client = MagicMock()

            mock_response = MagicMock()
            mock_response.choices = [MagicMock()]
            mock_response.choices[0].message.content = "ok"
            mock_response.choices[0].message.tool_calls = None
            mock_response.usage = MagicMock(prompt_tokens=10, completion_tokens=5)
            provider.client.chat.completions.create.return_value = mock_response

            provider.chat(
                messages=[],
                tools=SAMPLE_TOOLS,
                system="test",
                tool_choice="any",
            )

            call_kwargs = provider.client.chat.completions.create.call_args[1]
            assert call_kwargs.get("tool_choice") == "required"

    def test_gemini_native_tool_choice(self):
        """Gemini: tool_choice='any' → function_calling_config mode=ANY."""
        from familiar.core.providers import GeminiProvider

        provider = GeminiProvider.__new__(GeminiProvider)
        provider.model = "gemini-2.5-flash"
        provider._api_key = "test-key"
        provider._client = None

        try:
            fc_config = provider._convert_tool_choice("any")
        except (ImportError, ModuleNotFoundError):
            pytest.skip("google-genai not installed")

        assert fc_config is not None
        assert fc_config.mode == "ANY"

        fc_auto = provider._convert_tool_choice("auto")
        assert fc_auto.mode == "AUTO"

        fc_none = provider._convert_tool_choice(None)
        assert fc_none is None


# =============================================================================
# TestEmptyToolRetry — empty tool-call guard in agent loop
# =============================================================================


class TestEmptyToolRetry:
    """Verify the empty tool-call retry guard behavior."""

    def _make_mock_provider(self, responses):
        """Create a mock provider that returns responses in sequence."""
        provider = MagicMock()
        provider.provider_key = "test"
        provider.model_name = "test-model"
        provider.name = "Test Provider"
        provider.supports_tools = True
        provider.supports_streaming = False
        provider.chat = MagicMock(side_effect=responses)
        return provider

    def test_short_text_triggers_retry(self):
        """A short text response (< 100 chars) with no tool calls triggers retry."""
        short_resp = LLMResponse(
            text="I can help with that.", tool_calls=[], stop_reason="end_turn"
        )
        tool_resp = LLMResponse(
            text=None,
            tool_calls=[ToolCall(id="tc1", name="read_file", input={"path": "/test"})],
            stop_reason="tool_use",
        )

        # Simulate: first call returns short text, retry returns tool call
        self._make_mock_provider([short_resp, tool_resp])

        # Verify the guard would trigger
        response = short_resp
        assert not response.tool_calls
        assert len((response.text or "").strip()) < 100

    def test_long_text_no_retry(self):
        """A response with >= 100 chars should NOT trigger retry."""
        long_text = "x" * 150
        response = LLMResponse(text=long_text, tool_calls=[], stop_reason="end_turn")

        # Guard condition check
        assert len((response.text or "").strip()) >= 100

    def test_retry_only_once(self):
        """The guard should only trigger once per conversation turn."""
        # _empty_tool_retry_done starts False, becomes True after first retry
        _empty_tool_retry_done = False

        response = LLMResponse(text="Short.", tool_calls=[], stop_reason="end_turn")
        tool_schemas = SAMPLE_TOOLS

        # First check: should trigger
        should_retry = (
            tool_schemas
            and response is not None
            and not response.tool_calls
            and (response.text or "").strip()
            and len((response.text or "").strip()) < 100
            and not _empty_tool_retry_done
        )
        assert should_retry

        _empty_tool_retry_done = True

        # Second check: should NOT trigger
        should_retry_again = (
            tool_schemas
            and response is not None
            and not response.tool_calls
            and (response.text or "").strip()
            and len((response.text or "").strip()) < 100
            and not _empty_tool_retry_done
        )
        assert not should_retry_again

    def test_no_retry_without_tools(self):
        """No tools provided = no guard triggered."""
        response = LLMResponse(text="Short.", tool_calls=[], stop_reason="end_turn")
        tool_schemas = []

        should_retry = (
            tool_schemas
            and response is not None
            and not response.tool_calls
            and (response.text or "").strip()
            and len((response.text or "").strip()) < 100
        )
        assert not should_retry


# =============================================================================
# TestOllamaFixes — Ollama robustness fixes
# =============================================================================


class TestOllamaFixes:
    """Verify Ollama provider fixes."""

    def test_narrow_exception_propagates_connection_error(self):
        """ConnectionError should NOT be caught by the narrow handler."""
        from familiar.core.providers import OllamaProvider

        with patch("familiar.core.providers.OllamaProvider.__init__", return_value=None):
            provider = OllamaProvider.__new__(OllamaProvider)
            provider.model = "qwen3:8b"
            provider.base_url = "http://localhost:11434"
            provider.timeout = 120
            provider._supports_tools_cache = True
            provider.client = MagicMock()

            # Simulate a ConnectionError during tool calling
            provider.client.chat.completions.create.side_effect = ConnectionError(
                "Connection refused"
            )

            with pytest.raises(ConnectionError):
                provider.chat(
                    messages=[],
                    tools=SAMPLE_TOOLS,
                    system="test",
                )

    def test_parse_error_falls_back_to_plain(self):
        """JSONDecodeError during tool parsing falls back to plain chat."""
        from familiar.core.providers import OllamaProvider

        with patch("familiar.core.providers.OllamaProvider.__init__", return_value=None):
            provider = OllamaProvider.__new__(OllamaProvider)
            provider.model = "qwen3:8b"
            provider.base_url = "http://localhost:11434"
            provider.timeout = 120
            provider._supports_tools_cache = True
            provider.client = MagicMock()

            # First call (tools): raises parse error
            # Second call (plain): succeeds
            plain_response = MagicMock()
            plain_response.choices = [MagicMock()]
            plain_response.choices[0].message.content = "Plain response"
            plain_response.usage = None

            provider.client.chat.completions.create.side_effect = [
                json.JSONDecodeError("test", "doc", 0),
                plain_response,
            ]

            result = provider.chat(
                messages=[],
                tools=SAMPLE_TOOLS,
                system="test",
            )

            assert result.text == "Plain response"
            assert result.tool_calls == []

    def test_structured_tool_history(self):
        """Tool calls should be in OpenAI format when supports_tools=True."""
        from familiar.core.providers import OllamaProvider

        with patch("familiar.core.providers.OllamaProvider.__init__", return_value=None):
            provider = OllamaProvider.__new__(OllamaProvider)
            provider.model = "qwen3:8b"
            provider.base_url = "http://localhost:11434"
            provider.timeout = 120
            provider._supports_tools_cache = True
            provider.client = MagicMock()

            mock_response = MagicMock()
            mock_response.choices = [MagicMock()]
            mock_response.choices[0].message.content = "Done"
            mock_response.choices[0].message.tool_calls = None
            mock_response.usage = None
            provider.client.chat.completions.create.return_value = mock_response

            # Build messages with an LLMResponse containing tool calls
            prev_response = LLMResponse(
                text="Let me read that.",
                tool_calls=[ToolCall(id="tc1", name="read_file", input={"path": "/test"})],
                stop_reason="tool_use",
            )

            messages = [
                {"role": "assistant", "content": prev_response},
                {
                    "role": "user",
                    "content": [
                        {
                            "type": "tool_result",
                            "tool_use_id": "tc1",
                            "tool_name": "read_file",
                            "content": "file contents here",
                        }
                    ],
                },
            ]

            provider.chat(messages=messages, tools=SAMPLE_TOOLS, system="test")

            call_args = provider.client.chat.completions.create.call_args[1]
            msgs = call_args["messages"]

            # Find the assistant message with tool_calls
            assistant_msgs = [m for m in msgs if m["role"] == "assistant" and "tool_calls" in m]
            assert len(assistant_msgs) == 1
            assert assistant_msgs[0]["tool_calls"][0]["function"]["name"] == "read_file"

            # Find the tool result message
            tool_msgs = [m for m in msgs if m["role"] == "tool"]
            assert len(tool_msgs) == 1
            assert tool_msgs[0]["tool_call_id"] == "tc1"

    def test_text_tool_history_fallback(self):
        """Tool calls should use text format when supports_tools=False."""
        from familiar.core.providers import OllamaProvider

        with patch("familiar.core.providers.OllamaProvider.__init__", return_value=None):
            provider = OllamaProvider.__new__(OllamaProvider)
            provider.model = "tinyllama"
            provider.base_url = "http://localhost:11434"
            provider.timeout = 120
            provider._supports_tools_cache = False
            provider.client = MagicMock()

            mock_response = MagicMock()
            mock_response.choices = [MagicMock()]
            mock_response.choices[0].message.content = "Done"
            mock_response.usage = None
            provider.client.chat.completions.create.return_value = mock_response

            prev_response = LLMResponse(
                text="Let me read that.",
                tool_calls=[ToolCall(id="tc1", name="read_file", input={"path": "/test"})],
                stop_reason="tool_use",
            )

            messages = [
                {"role": "assistant", "content": prev_response},
            ]

            # No tools provided (supports_tools=False, so tools won't be passed)
            provider.chat(messages=messages, tools=[], system="test")

            call_args = provider.client.chat.completions.create.call_args[1]
            msgs = call_args["messages"]

            # Should be text format with [Called tool: ...]
            assistant_msgs = [m for m in msgs if m["role"] == "assistant"]
            assert len(assistant_msgs) == 1
            assert "[Called tool: read_file]" in assistant_msgs[0]["content"]
            assert "tool_calls" not in assistant_msgs[0]

    def test_tool_conversion_helper(self):
        """Module-level _anthropic_to_openai_tools produces correct format."""
        result = _anthropic_to_openai_tools(SAMPLE_TOOLS)

        assert len(result) == 2
        assert result[0]["type"] == "function"
        assert result[0]["function"]["name"] == "read_file"
        assert result[0]["function"]["parameters"]["type"] == "object"
        assert result[1]["function"]["name"] == "write_file"


# =============================================================================
# TestGeminiNative — Gemini native SDK provider
# =============================================================================


class TestGeminiNative:
    """Verify Gemini native SDK provider."""

    def test_init_requires_api_key(self):
        """ValueError raised without GEMINI_API_KEY."""
        from familiar.core.providers import GeminiProvider

        with patch.dict("os.environ", {}, clear=True):
            # Remove both possible env vars
            import os

            env = os.environ.copy()
            env.pop("GEMINI_API_KEY", None)
            env.pop("GOOGLE_API_KEY", None)
            with patch.dict("os.environ", env, clear=True):
                with pytest.raises(ValueError, match="GEMINI_API_KEY"):
                    GeminiProvider()

    def test_convert_tools_format(self):
        """Tool conversion produces FunctionDeclaration structure."""
        from familiar.core.providers import GeminiProvider

        provider = GeminiProvider.__new__(GeminiProvider)
        provider.model = "gemini-2.5-flash"
        provider._api_key = "test"
        provider._client = None

        # This will import google.genai; skip if not installed
        try:
            tools = provider._convert_tools(SAMPLE_TOOLS)
        except ImportError:
            pytest.skip("google-genai not installed")

        assert len(tools) == 1  # Wrapped in a single Tool object
        declarations = tools[0].function_declarations
        assert len(declarations) == 2
        assert declarations[0].name == "read_file"
        assert declarations[1].name == "write_file"

    def test_provider_key(self):
        """Provider key should be 'gemini'."""
        from familiar.core.providers import GeminiProvider

        provider = GeminiProvider.__new__(GeminiProvider)
        provider.model = "gemini-2.5-flash"
        assert provider.provider_key == "gemini"

    def test_chat_parses_function_call(self):
        """Response with FunctionCall part parsed to ToolCall."""
        from familiar.core.providers import GeminiProvider

        provider = GeminiProvider.__new__(GeminiProvider)
        provider.model = "gemini-2.5-flash"
        provider._api_key = "test"

        try:
            import google.genai  # noqa: F401
        except ImportError:
            pytest.skip("google-genai not installed")

        # Mock the client
        mock_client = MagicMock()
        provider._client = mock_client

        # Build a mock response with a function call
        mock_fc = MagicMock()
        mock_fc.text = None
        mock_fc.function_call = MagicMock()
        mock_fc.function_call.name = "read_file"
        mock_fc.function_call.args = {"path": "/test.txt"}

        mock_text_part = MagicMock()
        mock_text_part.text = "Let me read that."
        mock_text_part.function_call = None

        mock_candidate = MagicMock()
        mock_candidate.content.parts = [mock_text_part, mock_fc]

        mock_response = MagicMock()
        mock_response.candidates = [mock_candidate]
        mock_response.usage_metadata = MagicMock(prompt_token_count=10, candidates_token_count=20)

        mock_client.models.generate_content.return_value = mock_response

        result = provider.chat(
            messages=[{"role": "user", "content": "Read /test.txt"}],
            tools=SAMPLE_TOOLS,
            system="test",
        )

        assert result.text == "Let me read that."
        assert len(result.tool_calls) == 1
        assert result.tool_calls[0].name == "read_file"
        assert result.tool_calls[0].input == {"path": "/test.txt"}
        assert result.stop_reason == "tool_use"

    def test_chat_text_only(self):
        """Text-only response without tools."""
        from familiar.core.providers import GeminiProvider

        provider = GeminiProvider.__new__(GeminiProvider)
        provider.model = "gemini-2.5-flash"
        provider._api_key = "test"

        try:
            import google.genai  # noqa: F401
        except ImportError:
            pytest.skip("google-genai not installed")

        mock_client = MagicMock()
        provider._client = mock_client

        mock_text_part = MagicMock()
        mock_text_part.text = "Hello! How can I help?"
        mock_text_part.function_call = None

        mock_candidate = MagicMock()
        mock_candidate.content.parts = [mock_text_part]

        mock_response = MagicMock()
        mock_response.candidates = [mock_candidate]
        mock_response.usage_metadata = MagicMock(prompt_token_count=5, candidates_token_count=10)

        mock_client.models.generate_content.return_value = mock_response

        result = provider.chat(
            messages=[{"role": "user", "content": "Hi"}],
            tools=[],
            system="test",
        )

        assert result.text == "Hello! How can I help?"
        assert result.tool_calls == []
        assert result.stop_reason == "end_turn"


# =============================================================================
# TestMetricsIntegration — tool count metrics
# =============================================================================


class TestMetricsIntegration:
    """Verify tool-call metrics recording and computation."""

    def test_record_llm_call_with_tool_counts(self):
        """tools_provided and tool_calls_produced are recorded."""
        mc = MetricsCollector()
        mc.record_llm_call(
            provider="anthropic",
            model="claude-sonnet-4-6",
            duration_ms=500,
            success=True,
            tools_provided=5,
            tool_calls_produced=2,
        )

        metrics = mc.get_llm_metrics("anthropic", "claude-sonnet-4-6")
        assert metrics is not None
        assert metrics.total_tools_provided == 5
        assert metrics.total_tool_calls_produced == 2
        assert metrics.calls_with_tools_provided == 1

    def test_tool_call_rate_computed(self):
        """tool_call_rate = produced / calls_with_tools."""
        mc = MetricsCollector()

        # Call 1: 3 tools provided, 2 calls produced
        mc.record_llm_call(
            provider="anthropic",
            model="test",
            duration_ms=100,
            success=True,
            tools_provided=3,
            tool_calls_produced=2,
        )
        # Call 2: 3 tools provided, 1 call produced
        mc.record_llm_call(
            provider="anthropic",
            model="test",
            duration_ms=100,
            success=True,
            tools_provided=3,
            tool_calls_produced=1,
        )

        metrics = mc.get_llm_metrics("anthropic", "test")
        # rate = (2 + 1) / 2 = 1.5
        assert metrics.tool_call_rate == 1.5

    def test_tool_call_rate_none_when_no_tools(self):
        """tool_call_rate is None when no tools were provided."""
        mc = MetricsCollector()
        mc.record_llm_call(
            provider="anthropic",
            model="test",
            duration_ms=100,
            success=True,
            tools_provided=0,
            tool_calls_produced=0,
        )

        metrics = mc.get_llm_metrics("anthropic", "test")
        assert metrics.tool_call_rate is None

    def test_empty_tool_retry_counted(self):
        """record_empty_tool_retry increments the counter."""
        mc = MetricsCollector()

        # Must have existing metrics entry first
        mc.record_llm_call(
            provider="gemini",
            model="gemini-2.5-flash",
            duration_ms=100,
            success=True,
        )

        mc.record_empty_tool_retry(provider="gemini", model="gemini-2.5-flash")
        mc.record_empty_tool_retry(provider="gemini", model="gemini-2.5-flash")

        metrics = mc.get_llm_metrics("gemini", "gemini-2.5-flash")
        assert metrics.empty_tool_retries == 2

    def test_tool_metrics_in_to_dict(self):
        """to_dict() includes tool-call fields."""
        mc = MetricsCollector()
        mc.record_llm_call(
            provider="openai",
            model="gpt-4o",
            duration_ms=200,
            success=True,
            tools_provided=4,
            tool_calls_produced=1,
        )

        metrics = mc.get_llm_metrics("openai", "gpt-4o")
        d = metrics.to_dict()

        assert "tool_call_rate" in d
        assert "empty_tool_retries" in d
        assert "calls_with_tools" in d
        assert "tool_calls_produced" in d
        assert d["tool_call_rate"] == 1.0
        assert d["calls_with_tools"] == 1
        assert d["tool_calls_produced"] == 1

    def test_llm_call_record_has_tool_fields(self):
        """LLMCallRecord dataclass has tool count fields."""
        record = LLMCallRecord(
            provider="test",
            model="test",
            start_time=0.0,
            end_time=1.0,
            duration_ms=1000,
            success=True,
            tools_provided=3,
            tool_calls_produced=2,
        )
        assert record.tools_provided == 3
        assert record.tool_calls_produced == 2
