"""Tests for LLM gateway and LLM-powered extraction."""
import json
import os
from unittest.mock import MagicMock, patch

import pytest

from greymatter_core.llm_gateway import OllamaLLM
from greymatter_core.extraction import ExtractionPipeline


# ---- OllamaLLM config tests ----


class TestOllamaConfig:
    """Verify Ollama reads config from env vars."""

    def test_default_url_is_localhost(self):
        with patch.dict(os.environ, {}, clear=True):
            llm = OllamaLLM()
            assert "localhost" in llm.base_url or "127.0.0.1" in llm.base_url

    def test_env_var_overrides_url(self):
        with patch.dict(os.environ, {"GREYMATTER_LLM_URL": "http://custom:1234"}):
            llm = OllamaLLM()
            assert llm.base_url == "http://custom:1234"

    def test_env_var_overrides_model(self):
        with patch.dict(os.environ, {"GREYMATTER_LLM_MODEL": "llama3:8b"}):
            llm = OllamaLLM()
            assert llm.model == "llama3:8b"

    def test_default_model_is_nemotron(self):
        with patch.dict(os.environ, {}, clear=True):
            llm = OllamaLLM()
            assert llm.model == "nemotron-3-nano"

    def test_fallback_url_from_env(self):
        with patch.dict(os.environ, {"GREYMATTER_LLM_FALLBACK_URL": "http://mini:11434"}):
            llm = OllamaLLM()
            assert llm.fallback_url == "http://mini:11434"

    def test_constructor_args_override_env(self):
        with patch.dict(os.environ, {"GREYMATTER_LLM_URL": "http://env:1234"}):
            llm = OllamaLLM(base_url="http://explicit:5678")
            assert llm.base_url == "http://explicit:5678"


# ---- OllamaLLM fallback tests ----


class TestOllamaFallback:
    """Verify fallback chain: primary -> fallback URL -> exception."""

    @patch("greymatter_core.llm_gateway.httpx.post")
    def test_uses_primary_when_available(self, mock_post):
        mock_post.return_value = MagicMock(
            status_code=200,
            json=lambda: {"response": "test output"},
            raise_for_status=lambda: None,
        )
        llm = OllamaLLM(base_url="http://primary:11434", fallback_url="http://fallback:11434")
        result = llm.generate("test")
        assert result == "test output"
        assert "primary" in mock_post.call_args[0][0]

    @patch("greymatter_core.llm_gateway.httpx.post")
    def test_falls_back_when_primary_fails(self, mock_post):
        import httpx
        def side_effect(url, **kwargs):
            if "primary" in url:
                raise httpx.ConnectError("Connection refused")
            return MagicMock(
                status_code=200,
                json=lambda: {"response": "fallback output"},
                raise_for_status=lambda: None,
            )
        mock_post.side_effect = side_effect
        llm = OllamaLLM(base_url="http://primary:11434", fallback_url="http://fallback:11434")
        result = llm.generate("test")
        assert result == "fallback output"

    @patch("greymatter_core.llm_gateway.httpx.post")
    def test_raises_when_both_fail(self, mock_post):
        import httpx
        mock_post.side_effect = httpx.ConnectError("Connection refused")
        llm = OllamaLLM(base_url="http://primary:11434", fallback_url="http://primary:11434")
        with pytest.raises(ConnectionError):
            llm.generate("test")


# ---- OllamaLLM.generate_json tests ----


class TestGenerateJSON:
    @patch("greymatter_core.llm_gateway.httpx.post")
    def test_generate_json_parses_clean_json(self, mock_post):
        """generate_json should parse a clean JSON response from Ollama."""
        expected = [{"category": "decision", "title": "Use PostgreSQL"}]
        mock_post.return_value = MagicMock(
            status_code=200,
            json=lambda: {"response": json.dumps(expected)},
            raise_for_status=lambda: None,
        )

        llm = OllamaLLM(base_url="http://test:11434")
        result = llm.generate_json("Extract observations")

        assert result == expected

    @patch("greymatter_core.llm_gateway.httpx.post")
    def test_generate_json_strips_markdown_fences(self, mock_post):
        """generate_json should strip markdown code fences before parsing."""
        expected = [{"key": "value"}]
        fenced = f"```json\n{json.dumps(expected)}\n```"
        mock_post.return_value = MagicMock(
            status_code=200,
            json=lambda: {"response": fenced},
            raise_for_status=lambda: None,
        )

        llm = OllamaLLM(base_url="http://test:11434")
        result = llm.generate_json("Parse this")

        assert result == expected


# ---- ExtractionPipeline.extract_llm tests ----


class TestExtractLLM:
    def test_extract_llm_returns_observations(self):
        """extract_llm should return structured observations from LLM output."""
        mock_llm = MagicMock(spec=OllamaLLM)
        mock_llm.generate_json.return_value = [
            {
                "category": "decision",
                "title": "Use SQLite for local caching",
                "content": "The team decided to use SQLite for the local cache layer.",
                "confidence": 0.85,
            },
            {
                "category": "insight",
                "title": "Memory usage spikes at boot",
                "content": "I discovered that memory spikes during startup are caused by eager loading.",
                "confidence": 0.7,
            },
        ]

        results = ExtractionPipeline.extract_llm(
            "We decided to use SQLite. I discovered memory spikes at boot.",
            llm=mock_llm,
            session_id="sess-1",
            trigger="pre_compact",
        )

        assert len(results) == 2
        assert results[0]["category"] == "decision"
        assert results[0]["title"] == "[decision] Use SQLite for local caching"
        assert results[0]["confidence"] == 0.85
        assert results[0]["session_id"] == "sess-1"
        assert results[0]["trigger"] == "pre_compact"
        assert results[1]["category"] == "insight"

    def test_extract_llm_falls_back_on_error(self):
        """extract_llm should fall back to heuristic when LLM raises."""
        mock_llm = MagicMock(spec=OllamaLLM)
        mock_llm.generate_json.side_effect = RuntimeError("connection refused")

        text = "We decided to use PostgreSQL for the main database."
        results = ExtractionPipeline.extract_llm(text, llm=mock_llm)

        assert len(results) >= 1
        assert results[0]["category"] == "decision"
        assert results[0]["confidence"] == 0.4

    def test_extract_llm_filters_invalid_categories(self):
        """extract_llm should skip observations with invalid categories."""
        mock_llm = MagicMock(spec=OllamaLLM)
        mock_llm.generate_json.return_value = [
            {
                "category": "decision",
                "title": "Valid one",
                "content": "This is valid.",
                "confidence": 0.8,
            },
            {
                "category": "hallucination",
                "title": "Invalid one",
                "content": "This should be filtered.",
                "confidence": 0.9,
            },
            {
                "category": "reference",
                "title": "Also valid",
                "content": "Reference info here.",
                "confidence": 0.6,
            },
        ]

        results = ExtractionPipeline.extract_llm("some text", llm=mock_llm)

        assert len(results) == 2
        categories = [r["category"] for r in results]
        assert "decision" in categories
        assert "reference" in categories
        assert "hallucination" not in categories

    def test_extract_llm_clamps_confidence(self):
        """extract_llm should clamp confidence to [0.0, 1.0]."""
        mock_llm = MagicMock(spec=OllamaLLM)
        mock_llm.generate_json.return_value = [
            {
                "category": "insight",
                "title": "Too confident",
                "content": "Way over the top.",
                "confidence": 5.0,
            },
            {
                "category": "gotcha",
                "title": "Negative confidence",
                "content": "Below zero somehow.",
                "confidence": -0.5,
            },
        ]

        results = ExtractionPipeline.extract_llm("some text", llm=mock_llm)

        assert len(results) == 2
        assert results[0]["confidence"] == 1.0
        assert results[1]["confidence"] == 0.0


# ---- ExtractionPipeline.extract (smart dispatcher) tests ----


class TestExtractSmart:
    def test_extract_smart_uses_llm_when_provided(self):
        """extract() should delegate to extract_llm when llm is provided."""
        mock_llm = MagicMock(spec=OllamaLLM)
        mock_llm.generate_json.return_value = [
            {
                "category": "pattern",
                "title": "Always run tests first",
                "content": "We always run tests before merging.",
                "confidence": 0.9,
            },
        ]

        results = ExtractionPipeline.extract(
            "We always run tests before merging.",
            session_id="s1",
            llm=mock_llm,
        )

        assert len(results) == 1
        assert results[0]["category"] == "pattern"
        assert results[0]["confidence"] == 0.9
        mock_llm.generate_json.assert_called_once()

    def test_extract_smart_uses_heuristic_when_no_llm(self):
        """extract() should use heuristic extraction when no llm is provided."""
        text = "We decided to go with the microservice architecture for this project."
        results = ExtractionPipeline.extract(text, session_id="s2")

        assert len(results) >= 1
        assert results[0]["category"] == "decision"
        assert results[0]["confidence"] == 0.4
