"""Tests for the read pipeline — retrieval, reranker, OpenAIProvider, and pipeline.

All LLM/Embedding calls are mocked via Protocol-compliant fakes.
"""

import json
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch

from conftest import (
    FailingEmbeddingProvider,
    FakeLLMProvider,
    run_async,
)

# ---------------------------------------------------------------------------
# Fake MemoryFact for testing (avoids ORM dependency)
# ---------------------------------------------------------------------------


@dataclass
class FakeMemoryFact:
    """Mimics MemoryFact ORM object for unit tests."""

    id: uuid.UUID = None
    user_id: str = "user_123"
    entity_type: str = "person"
    entity_key: str = "user:self"
    entity_name: str = "user"
    attribute_key: str = None
    value_text: str = None
    fact_text: str = ""
    confidence: float = 0.9
    importance: float = 0.5
    valid_from: datetime = None
    valid_to: datetime = None
    embedding_vec: list = None

    def __post_init__(self):
        if self.id is None:
            self.id = uuid.uuid4()
        if self.valid_from is None:
            self.valid_from = datetime.now(UTC)


# ===========================================================================
# Test: retrieval scoring helpers
# ===========================================================================


class TestRecencyScore:
    def test_recent_item_scores_high(self):
        from arandu.read.retrieval import recency_score

        now = datetime.now(UTC)
        score = recency_score(now, half_life_days=14)
        assert score > 0.95

    def test_old_item_scores_low(self):
        from arandu.read.retrieval import recency_score

        old = datetime.now(UTC) - timedelta(days=60)
        score = recency_score(old, half_life_days=14)
        assert score < 0.1

    def test_half_life_is_0_5(self):
        from arandu.read.retrieval import recency_score

        half_life = 14
        item_time = datetime.now(UTC) - timedelta(days=half_life)
        score = recency_score(item_time, half_life_days=half_life)
        assert abs(score - 0.5) < 0.05


class TestMergeAndRank:
    def test_deduplicates_by_fact_id(self):
        from arandu.config import MemoryConfig
        from arandu.read.retrieval import RetrievalCandidate, merge_and_rank

        fact = FakeMemoryFact(fact_text="Lives in SP")
        c1 = RetrievalCandidate(fact=fact, scores={"semantic": 0.8})
        c2 = RetrievalCandidate(fact=fact, scores={"keyword": 0.6})

        config = MemoryConfig()
        ranked = merge_and_rank([c1], [c2], config)

        assert len(ranked) == 1
        assert ranked[0].scores["semantic"] == 0.8
        assert ranked[0].scores["keyword"] == 0.6

    def test_ranks_by_combined_score(self):
        from arandu.config import MemoryConfig
        from arandu.read.retrieval import RetrievalCandidate, merge_and_rank

        f1 = FakeMemoryFact(fact_text="High similarity", importance=0.5)
        f2 = FakeMemoryFact(fact_text="Low similarity", importance=0.5)
        c1 = RetrievalCandidate(fact=f1, scores={"semantic": 0.9})
        c2 = RetrievalCandidate(fact=f2, scores={"semantic": 0.3})

        config = MemoryConfig()
        ranked = merge_and_rank([c1, c2], [], config)

        assert ranked[0].fact.fact_text == "High similarity"
        assert ranked[0].final_score > ranked[1].final_score


# ===========================================================================
# Test: reranker
# ===========================================================================


class TestReranker:
    def test_rerank_with_llm_reorders(self):
        from arandu.config import MemoryConfig
        from arandu.read.reranker import rerank_with_llm
        from arandu.read.retrieval import RetrievalCandidate

        f1 = FakeMemoryFact(fact_text="Works at Google", entity_name="user")
        f2 = FakeMemoryFact(fact_text="Lives in SP", entity_name="user")
        f3 = FakeMemoryFact(fact_text="Likes pizza", entity_name="user")

        candidates = [
            RetrievalCandidate(fact=f1, scores={"semantic": 0.9}, final_score=0.9),
            RetrievalCandidate(fact=f2, scores={"semantic": 0.7}, final_score=0.7),
            RetrievalCandidate(fact=f3, scores={"semantic": 0.5}, final_score=0.5),
        ]

        # LLM returns scores that reorder: f2 > f3 > f1
        reranker_response = json.dumps({"scores": [0.2, 0.9, 0.5]})
        llm = FakeLLMProvider([reranker_response])
        config = MemoryConfig(topk_facts=3)

        result = run_async(rerank_with_llm("where does user live?", candidates, llm, config))

        assert result[0].fact.fact_text == "Lives in SP"
        assert result[0].scores["reranker"] == 0.9

    def test_rerank_graceful_degradation_on_failure(self):
        from arandu.config import MemoryConfig
        from arandu.read.reranker import rerank_with_llm
        from arandu.read.retrieval import RetrievalCandidate

        f1 = FakeMemoryFact(fact_text="Fact A", entity_name="user")
        f2 = FakeMemoryFact(fact_text="Fact B", entity_name="user")

        candidates = [
            RetrievalCandidate(fact=f1, scores={"semantic": 0.9}, final_score=0.9),
            RetrievalCandidate(fact=f2, scores={"semantic": 0.7}, final_score=0.7),
        ]

        # LLM returns invalid JSON
        llm = FakeLLMProvider(["not valid json"])
        config = MemoryConfig(topk_facts=2)

        result = run_async(rerank_with_llm("query", candidates, llm, config))

        # Should return original order unchanged
        assert len(result) == 2
        assert result[0].fact.fact_text == "Fact A"

    def test_rerank_wrong_scores_length_fallback(self):
        from arandu.config import MemoryConfig
        from arandu.read.reranker import rerank_with_llm
        from arandu.read.retrieval import RetrievalCandidate

        f1 = FakeMemoryFact(fact_text="Fact A", entity_name="user")
        f2 = FakeMemoryFact(fact_text="Fact B", entity_name="user")

        candidates = [
            RetrievalCandidate(fact=f1, scores={"semantic": 0.9}, final_score=0.9),
            RetrievalCandidate(fact=f2, scores={"semantic": 0.7}, final_score=0.7),
        ]

        # Wrong number of scores
        llm = FakeLLMProvider([json.dumps({"scores": [0.5]})])
        config = MemoryConfig(topk_facts=2)

        result = run_async(rerank_with_llm("query", candidates, llm, config))

        # Fallback: original order
        assert result[0].fact.fact_text == "Fact A"

    def test_rerank_single_candidate_returns_unchanged(self):
        from arandu.config import MemoryConfig
        from arandu.read.reranker import rerank_with_llm
        from arandu.read.retrieval import RetrievalCandidate

        f1 = FakeMemoryFact(fact_text="Only fact", entity_name="user")
        candidates = [RetrievalCandidate(fact=f1, scores={"semantic": 0.9}, final_score=0.9)]

        llm = FakeLLMProvider()
        config = MemoryConfig()

        result = run_async(rerank_with_llm("query", candidates, llm, config))
        assert len(result) == 1


# ===========================================================================
# Test: context formatting
# ===========================================================================


class TestContextFormatting:
    def test_format_context_produces_markdown(self):
        from arandu.read.pipeline import ScoredFact, format_context

        facts = [
            ScoredFact(
                fact_id="1",
                entity_name="user",
                value="Lives in São Paulo",
                score=0.9,
                scores={"confidence": 0.95},
            ),
            ScoredFact(
                fact_id="2",
                entity_name="user",
                value="Works at Google",
                score=0.8,
                scores={"confidence": 0.90},
            ),
        ]

        context = format_context(facts)

        assert "## Known facts about the user:" in context
        assert "Lives in São Paulo" in context
        assert "confidence: 0.95" in context

    def test_format_context_empty(self):
        from arandu.read.pipeline import format_context

        assert format_context([]) == ""


# ===========================================================================
# Test: OpenAIProvider
# ===========================================================================


class TestOpenAIProvider:
    def test_openai_provider_satisfies_protocols(self):
        """Verify OpenAIProvider passes runtime_checkable Protocol checks."""

        # We can't instantiate without openai, but we can check the class structure
        from arandu.providers.openai import OpenAIProvider

        # Check methods exist
        assert hasattr(OpenAIProvider, "complete")
        assert hasattr(OpenAIProvider, "embed")
        assert hasattr(OpenAIProvider, "embed_one")

    def test_openai_provider_complete_mocked(self):
        """Test complete() with a mocked openai client."""
        from arandu.providers.openai import OpenAIProvider

        # Mock the openai import
        mock_response = MagicMock()
        mock_response.choices = [MagicMock()]
        mock_response.choices[0].message.content = '{"result": "ok"}'

        mock_client = AsyncMock()
        mock_client.chat.completions.create = AsyncMock(return_value=mock_response)

        with patch("arandu.providers.openai.OpenAIProvider.__init__", return_value=None):
            provider = OpenAIProvider.__new__(OpenAIProvider)
            provider._client = mock_client
            provider._model = "gpt-4o"
            provider._timeout = 30.0

            result = run_async(
                provider.complete(
                    messages=[{"role": "user", "content": "test"}],
                )
            )

            assert result == '{"result": "ok"}'
            mock_client.chat.completions.create.assert_called_once()

    def test_openai_provider_strips_markdown_fences(self):
        """Test that complete() strips markdown fences from response."""
        from arandu.providers.openai import OpenAIProvider

        mock_response = MagicMock()
        mock_response.choices = [MagicMock()]
        mock_response.choices[0].message.content = '```json\n{"result": "ok"}\n```'

        mock_client = AsyncMock()
        mock_client.chat.completions.create = AsyncMock(return_value=mock_response)

        with patch("arandu.providers.openai.OpenAIProvider.__init__", return_value=None):
            provider = OpenAIProvider.__new__(OpenAIProvider)
            provider._client = mock_client
            provider._model = "gpt-4o"
            provider._timeout = 30.0

            result = run_async(
                provider.complete(
                    messages=[{"role": "user", "content": "test"}],
                )
            )

            assert result == '{"result": "ok"}'

    def test_openai_provider_embed_mocked(self):
        """Test embed() with a mocked openai client."""
        from arandu.providers.openai import OpenAIProvider

        mock_data = [MagicMock(embedding=[0.1] * 1536)]
        mock_response = MagicMock()
        mock_response.data = mock_data

        mock_client = AsyncMock()
        mock_client.embeddings.create = AsyncMock(return_value=mock_response)

        with patch("arandu.providers.openai.OpenAIProvider.__init__", return_value=None):
            provider = OpenAIProvider.__new__(OpenAIProvider)
            provider._client = mock_client
            provider._embedding_model = "text-embedding-3-small"
            provider._timeout = 30.0

            result = run_async(provider.embed(["hello world"]))

            assert len(result) == 1
            assert len(result[0]) == 1536

    def test_openai_provider_embed_one_empty_returns_none(self):
        """Test embed_one() with empty text returns None."""
        from arandu.providers.openai import OpenAIProvider

        with patch("arandu.providers.openai.OpenAIProvider.__init__", return_value=None):
            provider = OpenAIProvider.__new__(OpenAIProvider)
            provider._client = AsyncMock()
            provider._embedding_model = "text-embedding-3-small"
            provider._timeout = 30.0

            result = run_async(provider.embed_one(""))
            assert result is None

            result = run_async(provider.embed_one("   "))
            assert result is None


# ===========================================================================
# Test: read pipeline (mocked DB)
# ===========================================================================


class TestReadPipeline:
    def test_empty_query_returns_empty(self):
        """Pipeline with no candidates returns empty result."""
        from arandu.config import MemoryConfig
        from arandu.read.pipeline import run_read_pipeline

        llm = FakeLLMProvider()
        emb = FailingEmbeddingProvider()
        config = MemoryConfig()

        mock_session = AsyncMock()
        mock_result = MagicMock()
        mock_result.all.return_value = []
        mock_result.scalars.return_value.all.return_value = []
        mock_session.execute = AsyncMock(return_value=mock_result)

        result = run_async(
            run_read_pipeline(
                session=mock_session,
                user_id="user_123",
                query="something",
                llm=llm,
                embeddings=emb,
                config=config,
            )
        )

        assert result.facts == []
        assert result.context == ""
        assert result.total_candidates == 0
        assert result.duration_ms >= 0
