"""
Tests for issue #660 — Retrieve metadata is null.

Validates that RetrievedItem.metadata is populated across ALL code paths:
1. Lightweight retrieve (non-rerank)
2. Voyage rerank path (_rerank_results)
3. Agentic rerank path (rerank_with_feedback_and_recency)
4. API serialisation preserves metadata
"""

from __future__ import annotations

import asyncio
import json
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from cortex.core.retrieval import (
    RetrievalConstraints,
    RetrievalPipeline,
    RetrievedItem,
)
from cortex.core.agentic_retrieval import rerank_with_feedback_and_recency
from cortex.api.deps import _serialise


# ---------------------------------------------------------------------------
# Helpers (copied from test_retrieval.py for standalone use)
# ---------------------------------------------------------------------------

@dataclass
class MockConfig:
    retrieval_token_budget: int = 500
    total_memory_token_budget: int = 4000
    cornerstone_token_budget: int = 800
    embedding_dim: int = 4
    embedding_model: str = "mock"


@dataclass
class MockFTSResult:
    item_id: str
    content: str
    item_type: str = "claim"
    rank: float = -1.0


@dataclass
class MockVectorResult:
    item_id: str
    content: str
    score: float
    item_type: str = "claim"


def _mock_claim(claim_id: str, category: str = "preferences"):
    obj = MagicMock()
    obj.subject = "Andrew"
    obj.predicate = "likes"
    obj.object_value = "coffee"
    obj.event_date = None
    obj.cause = None
    obj.effect = None
    obj.state_before = None
    obj.state_after = None
    obj.memory_class = "semantic"
    obj.salience = "high"
    obj.status = "active"
    obj.category = category
    obj.explicitness = "explicit"
    obj.stability = "stable"
    return obj


def make_storage(fts_items, vector_items):
    storage = MagicMock()
    storage.embed = AsyncMock(return_value=[0.1, 0.2, 0.3, 0.4])

    _claims = [i for i in fts_items if i.item_type == "claim"]
    _events = [i for i in fts_items if i.item_type == "event"]

    async def _fts(query, table="claims", top_k=50, owner_id="default"):
        items = _claims if table == "claims" else _events
        return items[:top_k]

    storage.fts_search = AsyncMock(side_effect=_fts)

    async def _vec(embedding, item_type=None, top_k=50, owner_id="default"):
        return vector_items[:top_k]

    storage.vector_search = AsyncMock(side_effect=_vec)

    async def _get_claim(claim_id, owner_id=None):
        return _mock_claim(claim_id)

    storage.get_claim = AsyncMock(side_effect=_get_claim)

    async def _sens_batch(claim_ids):
        return {cid: "normal" for cid in claim_ids}

    storage.get_claims_sensitivity_batch = AsyncMock(side_effect=_sens_batch)
    storage.log_retrieval = AsyncMock()

    return storage


def make_pipeline(storage):
    return RetrievalPipeline(
        storage=storage,
        config=MockConfig(),
        token_budget=500,
    )


# ---------------------------------------------------------------------------
# Test 1: Lightweight path populates metadata
# ---------------------------------------------------------------------------

class TestLightweightMetadata:
    @pytest.mark.asyncio
    async def test_metadata_not_null_on_claim_items(self):
        """Lightweight retrieve should populate metadata on all claim items."""
        fts = [MockFTSResult("c1", "Andrew likes coffee")]
        vec = [MockVectorResult("c1", "Andrew likes coffee", 0.9)]
        storage = make_storage(fts, vec)
        pipeline = make_pipeline(storage)

        result = await pipeline.retrieve(
            "What does Andrew like?",
            constraints=RetrievalConstraints(top_k=10),
        )

        assert len(result.items) > 0
        for item in result.items:
            if item.item_type == "claim":
                assert item.metadata is not None, (
                    f"metadata is null on claim {item.item_id}"
                )
                assert "memory_class" in item.metadata
                assert "salience" in item.metadata
                assert "status" in item.metadata
                assert "category" in item.metadata


# ---------------------------------------------------------------------------
# Test 2: Agentic rerank preserves metadata (#660)
# ---------------------------------------------------------------------------

class TestAgenticRerankMetadata:
    def test_rerank_with_feedback_preserves_metadata(self):
        """rerank_with_feedback_and_recency must carry metadata to new items."""
        items = [
            RetrievedItem(
                item_type="claim",
                item_id="c1",
                content="Andrew likes coffee",
                score=0.9,
                metadata={
                    "memory_class": "semantic",
                    "salience": "high",
                    "status": "active",
                    "category": "preferences",
                },
            ),
            RetrievedItem(
                item_type="claim",
                item_id="c2",
                content="Andrew uses dark mode",
                score=0.8,
                metadata={
                    "memory_class": "semantic",
                    "salience": "medium",
                    "status": "active",
                    "category": "preferences",
                },
            ),
        ]

        reranked = rerank_with_feedback_and_recency(
            items=items,
            feedback_scores={"c1": 0.8, "c2": 0.6},
            item_ages_days={"c1": 1, "c2": 5},
        )

        assert len(reranked) == 2
        for item in reranked:
            assert item.metadata is not None, (
                f"metadata dropped by rerank on {item.item_id}"
            )
            assert item.metadata["status"] == "active"
            assert "category" in item.metadata


# ---------------------------------------------------------------------------
# Test 3: API serialisation preserves metadata
# ---------------------------------------------------------------------------

class TestAPISerialisationMetadata:
    def test_serialise_preserves_metadata(self):
        """_serialise should not drop metadata from RetrievedItem."""
        from cortex.core.retrieval import RetrievalResult

        item = RetrievedItem(
            item_type="claim",
            item_id="c1",
            content="test",
            score=0.9,
            metadata={"category": "decisions", "status": "active"},
        )
        result = RetrievalResult(
            items=[item],
            retrieval_id="r-1",
            mode="lightweight",
            latency_ms=50,
            tokens_used=10,
        )

        data = _serialise(result.__dict__)
        assert data["items"][0]["metadata"] is not None
        assert data["items"][0]["metadata"]["category"] == "decisions"

    def test_serialise_metadata_null_when_not_set(self):
        """metadata=None should serialise as null (not omitted)."""
        item = RetrievedItem(
            item_type="working_memory",
            item_id="wm:1",
            content="test",
            score=1.0,
        )
        data = _serialise(item.__dict__)
        assert "metadata" in data
        assert data["metadata"] is None


# ---------------------------------------------------------------------------
# Test 4: SemanticClaim missing category field (#660 sub-issue)
# ---------------------------------------------------------------------------

class TestSemanticClaimCategory:
    def test_semantic_claim_has_category_field(self):
        """SemanticClaim should have a category field for DB round-tripping."""
        from cortex.types import SemanticClaim
        assert "category" in SemanticClaim.__dataclass_fields__, (
            "SemanticClaim is missing 'category' field — "
            "category from DB is silently dropped by _row_to_claim, "
            "causing metadata.category to always be 'misc'"
        )
