"""Tests for LLM provider abstraction."""

from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from fliiq.runtime.llm.providers import (
    AnthropicLLM,
    GeminiLLM,
    LLMConfig,
    LLMProvider,
    LLMResponse,
    OpenAILLM,
    ToolCall,
    _find_tool_name,
    create_llm,
)
from fliiq.runtime.llm.failover import FailoverLLM


def test_factory_returns_anthropic():
    config = LLMConfig(provider=LLMProvider.ANTHROPIC, api_key="test-key")
    llm = create_llm(config)
    assert isinstance(llm, AnthropicLLM)


def test_factory_returns_openai():
    config = LLMConfig(provider=LLMProvider.OPENAI, api_key="test-key")
    llm = create_llm(config)
    assert isinstance(llm, OpenAILLM)


def test_factory_returns_gemini():
    config = LLMConfig(provider=LLMProvider.GEMINI, api_key="test-key")
    llm = create_llm(config)
    assert isinstance(llm, GeminiLLM)


def test_config_requires_api_key():
    with pytest.raises(Exception):
        LLMConfig(provider=LLMProvider.ANTHROPIC, api_key="")


def test_config_default_model_is_none():
    config = LLMConfig(provider=LLMProvider.ANTHROPIC, api_key="test-key")
    assert config.model is None


def test_default_model_applied():
    config = LLMConfig(provider=LLMProvider.ANTHROPIC, api_key="test-key")
    llm = create_llm(config)
    assert llm.model == "claude-sonnet-4-5-20250929"


def test_custom_model_preserved():
    config = LLMConfig(provider=LLMProvider.ANTHROPIC, api_key="test-key", model="claude-3-haiku-20240307")
    llm = create_llm(config)
    assert llm.model == "claude-3-haiku-20240307"


def test_failover_requires_configs():
    with pytest.raises(ValueError, match="at least one"):
        FailoverLLM([])


async def test_failover_tries_second_on_first_failure():
    """First provider fails, second succeeds."""
    mock_response = LLMResponse(content="ok", model="test", provider=LLMProvider.OPENAI)

    provider1 = AsyncMock()
    provider1.generate = AsyncMock(side_effect=RuntimeError("provider1 down"))
    provider1.config = LLMConfig(provider=LLMProvider.ANTHROPIC, api_key="k1")

    provider2 = AsyncMock()
    provider2.generate = AsyncMock(return_value=mock_response)
    provider2.config = LLMConfig(provider=LLMProvider.OPENAI, api_key="k2")

    configs = [
        LLMConfig(provider=LLMProvider.ANTHROPIC, api_key="k1"),
        LLMConfig(provider=LLMProvider.OPENAI, api_key="k2"),
    ]
    failover = FailoverLLM(configs)
    # Replace internal providers with mocks
    failover._providers = [provider1, provider2]

    result = await failover.generate([{"role": "user", "content": "hi"}])
    assert result.content == "ok"
    provider1.generate.assert_called_once()
    provider2.generate.assert_called_once()


async def test_failover_raises_when_all_fail():
    provider1 = AsyncMock()
    provider1.generate = AsyncMock(side_effect=RuntimeError("down"))
    provider1.config = LLMConfig(provider=LLMProvider.ANTHROPIC, api_key="k1")

    configs = [LLMConfig(provider=LLMProvider.ANTHROPIC, api_key="k1")]
    failover = FailoverLLM(configs)
    failover._providers = [provider1]

    with pytest.raises(RuntimeError, match="All LLM providers failed"):
        await failover.generate([{"role": "user", "content": "hi"}])


# --- Tool use tests ---


def test_llm_response_backward_compat():
    """LLMResponse works without new fields (backward compatibility)."""
    resp = LLMResponse(content="hello", model="test", provider=LLMProvider.ANTHROPIC)
    assert resp.content == "hello"
    assert resp.tool_calls == []
    assert resp.stop_reason == "end_turn"
    assert resp.raw_content is None


def test_llm_response_with_tool_calls():
    """LLMResponse accepts tool_calls and stop_reason."""
    tc = ToolCall(id="tc_1", name="get_time", input={"tz": "UTC"})
    resp = LLMResponse(
        content="",
        model="test",
        provider=LLMProvider.ANTHROPIC,
        tool_calls=[tc],
        stop_reason="tool_use",
        raw_content=[{"type": "tool_use", "id": "tc_1", "name": "get_time", "input": {"tz": "UTC"}}],
    )
    assert len(resp.tool_calls) == 1
    assert resp.tool_calls[0].name == "get_time"
    assert resp.stop_reason == "tool_use"


def test_tool_call_model():
    """ToolCall model holds id, name, input."""
    tc = ToolCall(id="tc_abc", name="search", input={"query": "hello"})
    assert tc.id == "tc_abc"
    assert tc.name == "search"
    assert tc.input == {"query": "hello"}


async def test_anthropic_tool_use_parsing():
    """AnthropicLLM parses tool_use content blocks correctly."""
    config = LLMConfig(provider=LLMProvider.ANTHROPIC, api_key="test-key")
    llm = AnthropicLLM(config)

    # Mock the Anthropic response with tool_use blocks
    mock_text_block = MagicMock()
    mock_text_block.type = "text"
    mock_text_block.text = "Let me check the time."

    mock_tool_block = MagicMock()
    mock_tool_block.type = "tool_use"
    mock_tool_block.id = "toolu_123"
    mock_tool_block.name = "get_current_time"
    mock_tool_block.input = {"timezone": "UTC"}

    mock_usage = MagicMock()
    mock_usage.input_tokens = 50
    mock_usage.output_tokens = 30

    mock_response = MagicMock()
    mock_response.content = [mock_text_block, mock_tool_block]
    mock_response.model = "claude-sonnet-4-20250514"
    mock_response.stop_reason = "tool_use"
    mock_response.usage = mock_usage

    llm._client.messages.create = AsyncMock(return_value=mock_response)

    result = await llm.generate(
        [{"role": "user", "content": "what time is it?"}],
        tools=[{"name": "get_current_time", "description": "Get time", "input_schema": {"type": "object"}}],
    )

    assert result.content == "Let me check the time."
    assert len(result.tool_calls) == 1
    assert result.tool_calls[0].id == "toolu_123"
    assert result.tool_calls[0].name == "get_current_time"
    assert result.tool_calls[0].input == {"timezone": "UTC"}
    assert result.stop_reason == "tool_use"
    assert len(result.raw_content) == 2


async def test_anthropic_text_only_backward_compat():
    """AnthropicLLM still works for text-only responses (no tools)."""
    config = LLMConfig(provider=LLMProvider.ANTHROPIC, api_key="test-key")
    llm = AnthropicLLM(config)

    mock_text_block = MagicMock()
    mock_text_block.type = "text"
    mock_text_block.text = "Hello there!"

    mock_usage = MagicMock()
    mock_usage.input_tokens = 10
    mock_usage.output_tokens = 5

    mock_response = MagicMock()
    mock_response.content = [mock_text_block]
    mock_response.model = "claude-sonnet-4-20250514"
    mock_response.stop_reason = "end_turn"
    mock_response.usage = mock_usage

    llm._client.messages.create = AsyncMock(return_value=mock_response)

    result = await llm.generate([{"role": "user", "content": "hi"}])

    assert result.content == "Hello there!"
    assert result.tool_calls == []
    assert result.stop_reason == "end_turn"


async def test_failover_passes_tools():
    """FailoverLLM passes tools param through to providers."""
    mock_response = LLMResponse(
        content="", model="test", provider=LLMProvider.ANTHROPIC,
        tool_calls=[ToolCall(id="tc_1", name="test", input={})],
        stop_reason="tool_use",
    )

    provider1 = AsyncMock()
    provider1.generate = AsyncMock(return_value=mock_response)
    provider1.config = LLMConfig(provider=LLMProvider.ANTHROPIC, api_key="k1")

    configs = [LLMConfig(provider=LLMProvider.ANTHROPIC, api_key="k1")]
    failover = FailoverLLM(configs)
    failover._providers = [provider1]

    tools = [{"name": "test", "description": "test tool", "input_schema": {"type": "object"}}]
    result = await failover.generate([{"role": "user", "content": "hi"}], tools=tools)

    provider1.generate.assert_called_once_with(
        [{"role": "user", "content": "hi"}], None, tools=tools
    )
    assert len(result.tool_calls) == 1


# --- base_url + config tests ---


def test_config_with_base_url():
    config = LLMConfig(provider=LLMProvider.OPENAI, api_key="test", base_url="http://localhost:11434/v1")
    assert config.base_url == "http://localhost:11434/v1"


def test_config_base_url_defaults_none():
    config = LLMConfig(provider=LLMProvider.OPENAI, api_key="test")
    assert config.base_url is None


def test_self_hosted_dummy_key():
    """Self-hosted LLMs can use a dummy api_key."""
    config = LLMConfig(provider=LLMProvider.OPENAI, api_key="not-needed", base_url="http://localhost:11434/v1")
    assert config.api_key == "not-needed"


# --- resolve_llm_config tests ---


def test_resolve_llm_config_picks_up_model_and_base_url(monkeypatch):
    monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
    monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
    monkeypatch.setenv("OPENAI_MODEL", "gpt-4o-mini")
    monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:11434/v1")

    from fliiq.runtime.agent.setup import resolve_llm_config

    config = resolve_llm_config()
    assert config.provider == LLMProvider.OPENAI
    assert config.model == "gpt-4o-mini"
    assert config.base_url == "http://localhost:11434/v1"


def test_resolve_llm_config_no_model_or_url(monkeypatch):
    monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
    monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
    monkeypatch.delenv("OPENAI_MODEL", raising=False)
    monkeypatch.delenv("OPENAI_BASE_URL", raising=False)

    from fliiq.runtime.agent.setup import resolve_llm_config

    config = resolve_llm_config()
    assert config.model is None
    assert config.base_url is None


# --- Provider switching tests ---


def test_provider_override_selects_openai(monkeypatch):
    """provider_override picks OpenAI even when Anthropic key is also set."""
    monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
    monkeypatch.setenv("OPENAI_API_KEY", "sk-oai-test")
    monkeypatch.delenv("FLIIQ_PROVIDER", raising=False)

    from fliiq.runtime.agent.setup import resolve_llm_config

    config = resolve_llm_config(provider_override="openai")
    assert config.provider == LLMProvider.OPENAI
    assert config.api_key == "sk-oai-test"


def test_fliiq_provider_env_var(monkeypatch):
    """FLIIQ_PROVIDER env var selects provider."""
    monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
    monkeypatch.setenv("OPENAI_API_KEY", "sk-oai-test")
    monkeypatch.setenv("FLIIQ_PROVIDER", "openai")

    from fliiq.runtime.agent.setup import resolve_llm_config

    config = resolve_llm_config()
    assert config.provider == LLMProvider.OPENAI


def test_provider_override_beats_env_var(monkeypatch):
    """CLI --provider flag overrides FLIIQ_PROVIDER env var."""
    monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
    monkeypatch.setenv("OPENAI_API_KEY", "sk-oai-test")
    monkeypatch.setenv("FLIIQ_PROVIDER", "openai")

    from fliiq.runtime.agent.setup import resolve_llm_config

    config = resolve_llm_config(provider_override="anthropic")
    assert config.provider == LLMProvider.ANTHROPIC


def test_provider_override_missing_key(monkeypatch):
    """Error when requested provider has no API key."""
    monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
    monkeypatch.delenv("OPENAI_API_KEY", raising=False)
    monkeypatch.delenv("FLIIQ_PROVIDER", raising=False)

    from fliiq.runtime.agent.setup import resolve_llm_config

    with pytest.raises(RuntimeError, match="OPENAI_API_KEY is not set"):
        resolve_llm_config(provider_override="openai")


def test_provider_override_unknown(monkeypatch):
    """Error on unknown provider name."""
    monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
    monkeypatch.delenv("FLIIQ_PROVIDER", raising=False)

    from fliiq.runtime.agent.setup import resolve_llm_config

    with pytest.raises(RuntimeError, match="Unknown provider 'llama'"):
        resolve_llm_config(provider_override="llama")


# --- _find_tool_name tests ---


def test_find_tool_name_found():
    messages = [
        {"role": "assistant", "content": [
            {"type": "tool_use", "id": "tc_1", "name": "get_time", "input": {}},
        ]},
    ]
    assert _find_tool_name(messages, "tc_1") == "get_time"


def test_find_tool_name_not_found():
    messages = [
        {"role": "assistant", "content": [
            {"type": "tool_use", "id": "tc_1", "name": "get_time", "input": {}},
        ]},
    ]
    assert _find_tool_name(messages, "tc_999") == "unknown"


def test_find_tool_name_skips_non_assistant():
    messages = [
        {"role": "user", "content": "hi"},
        {"role": "assistant", "content": [
            {"type": "tool_use", "id": "tc_1", "name": "search", "input": {}},
        ]},
    ]
    assert _find_tool_name(messages, "tc_1") == "search"


# --- OpenAI tool support tests ---


async def test_openai_tool_use_parsing():
    """OpenAILLM converts tools and parses tool_calls from response."""
    config = LLMConfig(provider=LLMProvider.OPENAI, api_key="test-key")
    llm = OpenAILLM(config)

    mock_tc = MagicMock()
    mock_tc.id = "call_abc123"
    mock_tc.type = "function"
    mock_tc.function.name = "get_current_time"
    mock_tc.function.arguments = '{"timezone": "UTC"}'

    mock_message = MagicMock()
    mock_message.content = "Let me check."
    mock_message.tool_calls = [mock_tc]

    mock_choice = MagicMock()
    mock_choice.message = mock_message
    mock_choice.finish_reason = "tool_calls"

    mock_usage = MagicMock()
    mock_usage.prompt_tokens = 50
    mock_usage.completion_tokens = 20

    mock_response = MagicMock()
    mock_response.choices = [mock_choice]
    mock_response.model = "gpt-4o"
    mock_response.usage = mock_usage

    llm._client.chat.completions.create = AsyncMock(return_value=mock_response)

    tools = [{"name": "get_current_time", "description": "Get time", "input_schema": {"type": "object"}}]
    result = await llm.generate(
        [{"role": "user", "content": "what time?"}],
        tools=tools,
    )

    assert len(result.tool_calls) == 1
    assert result.tool_calls[0].name == "get_current_time"
    assert result.tool_calls[0].id == "call_abc123"
    assert result.tool_calls[0].input == {"timezone": "UTC"}
    assert result.stop_reason == "tool_use"
    assert result.raw_content is not None

    # Verify tools were converted to OpenAI format
    call_kwargs = llm._client.chat.completions.create.call_args
    passed_tools = call_kwargs.kwargs.get("tools")
    assert passed_tools[0]["type"] == "function"
    assert passed_tools[0]["function"]["name"] == "get_current_time"
    assert passed_tools[0]["function"]["parameters"] == {"type": "object"}


async def test_openai_converts_anthropic_messages():
    """OpenAILLM converts Anthropic-format tool messages to OpenAI format."""
    config = LLMConfig(provider=LLMProvider.OPENAI, api_key="test-key")
    llm = OpenAILLM(config)

    messages = [
        {"role": "user", "content": "what time is it?"},
        {"role": "assistant", "content": [
            {"type": "text", "text": "Let me check."},
            {"type": "tool_use", "id": "tc_1", "name": "get_time", "input": {"tz": "UTC"}},
        ]},
        {"role": "user", "content": [
            {"type": "tool_result", "tool_use_id": "tc_1", "content": "14:30 UTC"},
        ]},
    ]

    mock_message = MagicMock()
    mock_message.content = "It's 14:30 UTC."
    mock_message.tool_calls = None
    mock_choice = MagicMock()
    mock_choice.message = mock_message
    mock_choice.finish_reason = "stop"
    mock_response = MagicMock()
    mock_response.choices = [mock_choice]
    mock_response.model = "gpt-4o"
    mock_response.usage = None

    llm._client.chat.completions.create = AsyncMock(return_value=mock_response)

    result = await llm.generate(messages)

    # Verify the messages were converted
    call_args = llm._client.chat.completions.create.call_args
    passed_msgs = call_args.kwargs["messages"]

    assert passed_msgs[0] == {"role": "user", "content": "what time is it?"}
    assert passed_msgs[1]["role"] == "assistant"
    assert "tool_calls" in passed_msgs[1]
    assert passed_msgs[1]["tool_calls"][0]["function"]["name"] == "get_time"
    assert passed_msgs[2]["role"] == "tool"
    assert passed_msgs[2]["tool_call_id"] == "tc_1"
    assert passed_msgs[2]["content"] == "14:30 UTC"

    assert result.content == "It's 14:30 UTC."
    assert result.stop_reason == "end_turn"


async def test_openai_text_only_no_tools():
    """OpenAILLM works for text-only responses without tools."""
    config = LLMConfig(provider=LLMProvider.OPENAI, api_key="test-key")
    llm = OpenAILLM(config)

    mock_message = MagicMock()
    mock_message.content = "Hello!"
    mock_message.tool_calls = None

    mock_choice = MagicMock()
    mock_choice.message = mock_message
    mock_choice.finish_reason = "stop"

    mock_response = MagicMock()
    mock_response.choices = [mock_choice]
    mock_response.model = "gpt-4o"
    mock_response.usage = None

    llm._client.chat.completions.create = AsyncMock(return_value=mock_response)

    result = await llm.generate([{"role": "user", "content": "hi"}])
    assert result.content == "Hello!"
    assert result.tool_calls == []
    assert result.stop_reason == "end_turn"


async def test_openai_malformed_tool_args():
    """OpenAILLM handles malformed JSON in tool arguments gracefully."""
    config = LLMConfig(provider=LLMProvider.OPENAI, api_key="test-key")
    llm = OpenAILLM(config)

    mock_tc = MagicMock()
    mock_tc.id = "call_bad"
    mock_tc.function.name = "test_tool"
    mock_tc.function.arguments = "not valid json"

    mock_message = MagicMock()
    mock_message.content = None
    mock_message.tool_calls = [mock_tc]

    mock_choice = MagicMock()
    mock_choice.message = mock_message
    mock_choice.finish_reason = "tool_calls"

    mock_response = MagicMock()
    mock_response.choices = [mock_choice]
    mock_response.model = "gpt-4o"
    mock_response.usage = None

    llm._client.chat.completions.create = AsyncMock(return_value=mock_response)

    result = await llm.generate([{"role": "user", "content": "test"}])
    assert len(result.tool_calls) == 1
    assert result.tool_calls[0].input == {}


# --- Gemini tool support tests ---


async def test_gemini_tool_use_parsing():
    """GeminiLLM converts tools and parses function_call from response."""
    config = LLMConfig(provider=LLMProvider.GEMINI, api_key="test-key")
    llm = GeminiLLM(config)

    # Mock Gemini response with function_call
    mock_text_part = MagicMock()
    mock_text_part.text = "Let me check."
    mock_text_part.function_call = None

    mock_fc = MagicMock()
    mock_fc.name = "get_current_time"
    mock_fc.args = {"timezone": "UTC"}

    mock_tool_part = MagicMock()
    mock_tool_part.text = None
    mock_tool_part.function_call = mock_fc

    mock_content = MagicMock()
    mock_content.parts = [mock_text_part, mock_tool_part]

    mock_candidate = MagicMock()
    mock_candidate.content = mock_content

    mock_usage = MagicMock()
    mock_usage.prompt_token_count = 50
    mock_usage.candidates_token_count = 20

    mock_response = MagicMock()
    mock_response.candidates = [mock_candidate]
    mock_response.usage_metadata = mock_usage

    llm._client.aio.models.generate_content = AsyncMock(return_value=mock_response)

    tools = [{"name": "get_current_time", "description": "Get time", "input_schema": {"type": "object"}}]
    result = await llm.generate(
        [{"role": "user", "content": "what time?"}],
        tools=tools,
    )

    assert result.content == "Let me check."
    assert len(result.tool_calls) == 1
    assert result.tool_calls[0].name == "get_current_time"
    assert result.tool_calls[0].input == {"timezone": "UTC"}
    assert result.stop_reason == "tool_use"
    assert result.raw_content is not None
    assert result.tool_calls[0].id.startswith("toolu_")


async def test_gemini_text_only():
    """GeminiLLM works for text-only responses."""
    config = LLMConfig(provider=LLMProvider.GEMINI, api_key="test-key")
    llm = GeminiLLM(config)

    mock_text_part = MagicMock()
    mock_text_part.text = "Hello!"
    mock_text_part.function_call = None

    mock_content = MagicMock()
    mock_content.parts = [mock_text_part]

    mock_candidate = MagicMock()
    mock_candidate.content = mock_content

    mock_response = MagicMock()
    mock_response.candidates = [mock_candidate]
    mock_response.usage_metadata = None

    llm._client.aio.models.generate_content = AsyncMock(return_value=mock_response)

    result = await llm.generate([{"role": "user", "content": "hi"}])
    assert result.content == "Hello!"
    assert result.tool_calls == []
    assert result.stop_reason == "end_turn"


# --- Model alias resolution tests ---


def test_resolve_model_alias_with_file(tmp_path, monkeypatch):
    """Alias found in models.yaml resolves to full model ID."""
    import yaml

    from fliiq.runtime.llm.providers import resolve_model_alias

    models_file = tmp_path / "models.yaml"
    models_file.write_text(yaml.dump({"aliases": {"opus-4.6": "claude-opus-4-6"}}))
    monkeypatch.setattr("fliiq.runtime.config.GLOBAL_DIR", tmp_path)

    assert resolve_model_alias("opus-4.6") == "claude-opus-4-6"


def test_resolve_model_alias_passthrough(tmp_path, monkeypatch):
    """Unknown alias passes through as-is."""
    import yaml

    from fliiq.runtime.llm.providers import resolve_model_alias

    models_file = tmp_path / "models.yaml"
    models_file.write_text(yaml.dump({"aliases": {"opus-4.6": "claude-opus-4-6"}}))
    monkeypatch.setattr("fliiq.runtime.config.GLOBAL_DIR", tmp_path)

    assert resolve_model_alias("some-custom-model") == "some-custom-model"


def test_resolve_model_alias_no_file(tmp_path, monkeypatch):
    """Missing models.yaml passes through."""
    from fliiq.runtime.llm.providers import resolve_model_alias

    monkeypatch.setattr("fliiq.runtime.config.GLOBAL_DIR", tmp_path)

    assert resolve_model_alias("opus-4.6") == "opus-4.6"


def test_model_override_in_resolve_llm_config(tmp_path, monkeypatch):
    """--model flag overrides env var."""
    import yaml

    monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
    monkeypatch.setenv("ANTHROPIC_MODEL", "claude-sonnet-4-5-20250929")
    monkeypatch.delenv("FLIIQ_PROVIDER", raising=False)

    models_file = tmp_path / "models.yaml"
    models_file.write_text(yaml.dump({"aliases": {"opus-4.6": "claude-opus-4-6"}}))
    monkeypatch.setattr("fliiq.runtime.config.GLOBAL_DIR", tmp_path)

    from fliiq.runtime.agent.setup import resolve_llm_config

    config = resolve_llm_config(model_override="opus-4.6")
    assert config.model == "claude-opus-4-6"


def test_model_override_raw_passthrough(monkeypatch, tmp_path):
    """--model with a raw model ID (not an alias) passes through."""
    monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
    monkeypatch.delenv("ANTHROPIC_MODEL", raising=False)
    monkeypatch.delenv("FLIIQ_PROVIDER", raising=False)
    monkeypatch.setattr("fliiq.runtime.config.GLOBAL_DIR", tmp_path)

    from fliiq.runtime.agent.setup import resolve_llm_config

    config = resolve_llm_config(model_override="claude-opus-4-6")
    assert config.model == "claude-opus-4-6"
