"""Tests for LLM signal engine (python/horizon/llm.py)."""

import sys
import time
from unittest.mock import patch, MagicMock

import pytest


# ---------------------------------------------------------------------------
# Config defaults
# ---------------------------------------------------------------------------


def test_llm_config_defaults():
    from horizon.llm import LLMConfig

    cfg = LLMConfig()
    assert cfg.provider == "anthropic"
    assert cfg.model == ""
    assert cfg.news_sources == []
    assert cfg.newsapi_query == ""
    assert cfg.exa_query == ""
    assert cfg.tavily_query == ""
    assert cfg.max_news_items == 5
    assert cfg.cache_ttl == 300.0
    assert cfg.rate_limit_per_minute == 10
    assert cfg.max_tokens == 1024
    assert cfg.temperature == 0.2


def test_llm_config_custom():
    from horizon.llm import LLMConfig

    cfg = LLMConfig(provider="openai", model="gpt-4o", max_news_items=10)
    assert cfg.provider == "openai"
    assert cfg.model == "gpt-4o"
    assert cfg.max_news_items == 10


# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------


def test_news_item_frozen():
    from horizon.llm import NewsItem

    item = NewsItem(
        title="Test", summary="Summary", url="https://example.com",
        published=1700000000.0, source="rss",
    )
    assert item.title == "Test"
    assert item.source == "rss"
    with pytest.raises(AttributeError):
        item.title = "Changed"


def test_llm_forecast_fields():
    from horizon.llm import LLMForecast

    fc = LLMForecast(
        market_id="m1", market_title="Will X?", llm_prob=0.75,
        reasoning="Because reasons", confidence=0.8, edge_bps=500.0,
        market_price=0.70, model="claude-sonnet-4-20250514",
        news_sources=["https://a.com"], timestamp=1700000000.0,
    )
    assert fc.market_id == "m1"
    assert fc.llm_prob == 0.75
    assert fc.edge_bps == 500.0
    assert len(fc.news_sources) == 1


# ---------------------------------------------------------------------------
# Prompt building
# ---------------------------------------------------------------------------


def test_build_forecast_prompt_with_news():
    from horizon.llm import _build_forecast_prompt, NewsItem

    news = [
        NewsItem("Headline 1", "Summary 1", "https://a.com", 1700000000.0, "rss"),
        NewsItem("Headline 2", "Summary 2", "https://b.com", 1700000001.0, "rss"),
    ]
    prompt = _build_forecast_prompt("Will BTC hit 100k?", 0.65, news)
    assert "Will BTC hit 100k?" in prompt
    assert "0.65" in prompt or "65%" in prompt
    assert "Headline 1" in prompt
    assert "Headline 2" in prompt


def test_build_forecast_prompt_no_news():
    from horizon.llm import _build_forecast_prompt

    prompt = _build_forecast_prompt("Test market", 0.50, [])
    assert "Test market" in prompt
    assert "No recent news" in prompt


# ---------------------------------------------------------------------------
# Response parsing
# ---------------------------------------------------------------------------


def test_parse_valid_json():
    from horizon.llm import _parse_llm_response

    text = '{"probability": 0.72, "reasoning": "Strong evidence", "confidence": 0.85}'
    prob, reasoning, conf = _parse_llm_response(text)
    assert abs(prob - 0.72) < 0.001
    assert reasoning == "Strong evidence"
    assert abs(conf - 0.85) < 0.001


def test_parse_json_in_markdown():
    from horizon.llm import _parse_llm_response

    text = '```json\n{"probability": 0.60, "reasoning": "Test", "confidence": 0.70}\n```'
    prob, reasoning, conf = _parse_llm_response(text)
    assert abs(prob - 0.60) < 0.001


def test_parse_clamping():
    from horizon.llm import _parse_llm_response

    # Probability out of range
    text = '{"probability": 1.5, "reasoning": "Over", "confidence": 2.0}'
    prob, reasoning, conf = _parse_llm_response(text)
    assert prob == 0.99
    assert conf == 1.0


def test_parse_clamping_low():
    from horizon.llm import _parse_llm_response

    text = '{"probability": -0.5, "reasoning": "Under", "confidence": -1.0}'
    prob, reasoning, conf = _parse_llm_response(text)
    assert prob == 0.01
    assert conf == 0.0


def test_parse_invalid_json_regex_fallback():
    from horizon.llm import _parse_llm_response

    text = 'probability: 0.65, reasoning: "Some reason", confidence: 0.80'
    prob, reasoning, conf = _parse_llm_response(text)
    assert abs(prob - 0.65) < 0.001
    assert reasoning == "Some reason"
    assert abs(conf - 0.80) < 0.001


def test_parse_garbage_returns_defaults():
    from horizon.llm import _parse_llm_response

    prob, reasoning, conf = _parse_llm_response("totally garbage text")
    assert prob == 0.5
    assert conf == 0.5


# ---------------------------------------------------------------------------
# Caching TTL
# ---------------------------------------------------------------------------


def test_caching_returns_cached_within_ttl():
    from horizon.llm import LLMConfig, LLMForecast, _forecast_cache

    # Pre-populate cache
    fc = LLMForecast(
        market_id="cached-1", market_title="Test", llm_prob=0.70,
        reasoning="Cached", confidence=0.9, edge_bps=200.0,
        market_price=0.68, model="test", news_sources=[], timestamp=time.time(),
    )
    _forecast_cache["cached-1"] = (time.time(), fc)

    config = LLMConfig(cache_ttl=300.0)

    # Should return cached without calling LLM
    from horizon.llm import llm_forecast
    result = llm_forecast("cached-1", "Test", 0.68, config=config)
    assert result.llm_prob == 0.70
    assert result.reasoning == "Cached"

    # Clean up
    del _forecast_cache["cached-1"]


# ---------------------------------------------------------------------------
# Rate limiting
# ---------------------------------------------------------------------------


def test_rate_limiting():
    from horizon.llm import _check_rate_limit, LLMConfig, _call_timestamps

    config = LLMConfig(rate_limit_per_minute=2)

    # Clear timestamps
    _call_timestamps.clear()

    assert _check_rate_limit(config) is True
    assert _check_rate_limit(config) is True
    assert _check_rate_limit(config) is False  # Exceeded

    # Clean up
    _call_timestamps.clear()


# ---------------------------------------------------------------------------
# Pipeline factory
# ---------------------------------------------------------------------------


def test_llm_signal_returns_callable():
    from horizon.llm import llm_signal

    fn = llm_signal()
    assert callable(fn)
    assert fn.__name__ == "llm_signal"


# ---------------------------------------------------------------------------
# _resolve_model
# ---------------------------------------------------------------------------


def test_resolve_model_anthropic_default():
    from horizon.llm import _resolve_model

    assert _resolve_model("anthropic", "") == "anthropic/claude-sonnet-4-20250514"


def test_resolve_model_openai_default():
    from horizon.llm import _resolve_model

    assert _resolve_model("openai", "") == "openai/gpt-4o-mini"


def test_resolve_model_custom_model():
    from horizon.llm import _resolve_model

    assert _resolve_model("anthropic", "claude-haiku-4-5-20251001") == "anthropic/claude-haiku-4-5-20251001"


def test_resolve_model_openrouter_passthrough():
    from horizon.llm import _resolve_model

    assert _resolve_model("anthropic", "openrouter/meta-llama/llama-3-70b") == "openrouter/meta-llama/llama-3-70b"


def test_resolve_model_qualified():
    from horizon.llm import _resolve_model

    # Already contains / so should be returned as-is
    assert _resolve_model("openai", "together/mistral-7b") == "together/mistral-7b"


# ---------------------------------------------------------------------------
# _call_llm_raw
# ---------------------------------------------------------------------------


def test_call_llm_raw_no_litellm_no_key():
    """Without litellm or API key, returns empty string."""
    from horizon.llm import _call_llm_raw

    with patch.dict("os.environ", {}, clear=True):
        # Force litellm ImportError and no API key
        with patch.dict("sys.modules", {"litellm": None}):
            result = _call_llm_raw("test prompt", "anthropic", "")
    assert result == ""


def test_call_llm_raw_with_litellm_mock():
    """When litellm is available, it should be used."""
    from horizon.llm import _call_llm_raw

    mock_response = MagicMock()
    mock_response.choices = [MagicMock()]
    mock_response.choices[0].message.content = "test response"

    mock_litellm = MagicMock()
    mock_litellm.completion.return_value = mock_response

    with patch.dict("sys.modules", {"litellm": mock_litellm}):
        result = _call_llm_raw("test", "anthropic", "")
    assert result == "test response"
    mock_litellm.completion.assert_called_once()


# ---------------------------------------------------------------------------
# Graceful degradation
# ---------------------------------------------------------------------------


def test_call_llm_returns_neutral_when_unavailable():
    """_call_llm returns neutral forecast when LLM is unavailable."""
    from horizon.llm import _call_llm, LLMConfig

    config = LLMConfig()
    with patch("horizon.llm._call_llm_raw", return_value=""):
        prob, reasoning, conf = _call_llm("test prompt", config)
    assert prob == 0.5
    assert conf == 0.0


# ---------------------------------------------------------------------------
# Exa / Tavily config
# ---------------------------------------------------------------------------


def test_config_exa_tavily_fields():
    from horizon.llm import LLMConfig

    cfg = LLMConfig(exa_query="bitcoin news", tavily_query="crypto market")
    assert cfg.exa_query == "bitcoin news"
    assert cfg.tavily_query == "crypto market"


# ---------------------------------------------------------------------------
# _fetch_news_exa
# ---------------------------------------------------------------------------


def test_fetch_news_exa_no_key():
    from horizon.llm import _fetch_news_exa

    with patch.dict("os.environ", {}, clear=True):
        assert _fetch_news_exa("bitcoin") == []


def test_fetch_news_exa_no_query():
    from horizon.llm import _fetch_news_exa

    assert _fetch_news_exa("") == []


def test_fetch_news_exa_mocked():
    from horizon.llm import _fetch_news_exa

    mock_result = MagicMock()
    mock_result.title = "BTC hits 100k"
    mock_result.text = "Bitcoin reached..."
    mock_result.url = "https://example.com"
    mock_result.published_date = None

    mock_results = MagicMock()
    mock_results.results = [mock_result]

    mock_exa = MagicMock()
    mock_exa.return_value.search_and_contents.return_value = mock_results

    with patch.dict("os.environ", {"EXA_API_KEY": "test-key"}):
        with patch.dict("sys.modules", {"exa_py": MagicMock(Exa=mock_exa)}):
            items = _fetch_news_exa("bitcoin", max_items=5)
    assert len(items) == 1
    assert items[0].title == "BTC hits 100k"
    assert items[0].source == "exa"


# ---------------------------------------------------------------------------
# _fetch_news_tavily
# ---------------------------------------------------------------------------


def test_fetch_news_tavily_no_key():
    from horizon.llm import _fetch_news_tavily

    with patch.dict("os.environ", {}, clear=True):
        assert _fetch_news_tavily("bitcoin") == []


def test_fetch_news_tavily_no_query():
    from horizon.llm import _fetch_news_tavily

    assert _fetch_news_tavily("") == []


def test_fetch_news_tavily_mocked():
    from horizon.llm import _fetch_news_tavily

    mock_client = MagicMock()
    mock_client.return_value.search.return_value = {
        "results": [
            {
                "title": "Crypto update",
                "content": "Market moves...",
                "url": "https://example.com",
                "published_date": "",
            }
        ]
    }

    mock_tavily_mod = MagicMock(TavilyClient=mock_client)

    with patch.dict("os.environ", {"TAVILY_API_KEY": "test-key"}):
        with patch.dict("sys.modules", {"tavily": mock_tavily_mod}):
            items = _fetch_news_tavily("crypto", max_items=5)
    assert len(items) == 1
    assert items[0].title == "Crypto update"
    assert items[0].source == "tavily"


# ---------------------------------------------------------------------------
# Exports
# ---------------------------------------------------------------------------


def test_exports():
    import horizon

    assert hasattr(horizon, "LLMForecast")
    assert hasattr(horizon, "LLMConfig")
    assert hasattr(horizon, "NewsItem")
    assert hasattr(horizon, "llm_forecast")
    assert hasattr(horizon, "llm_scan")
    assert hasattr(horizon, "llm_signal")
