"""Tests for temporal claims — valid_at / invalid_at wiring (#925).

Covers:
- v20 migration: idx_claim_temporal + backfill valid_from from created_at
- Reconciliation: superseded claim gets valid_to set
- Retrieval: default excludes expired; include_expired=True returns all
- Storage: browse_memories include_expired param; get_timeline method
- API: GET /memories?include_expired=true; GET /memories/timeline
"""
from __future__ import annotations

import uuid
from datetime import datetime, timezone
from typing import Optional
from unittest.mock import AsyncMock, MagicMock

import pytest


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _now() -> str:
    return datetime.now(timezone.utc).isoformat()


def _make_claim(
    *,
    subject: str = "eva",
    predicate: str = "location",
    object_value: str = "Bangkok",
    status: str = "active",
    valid_from: Optional[str] = None,
    valid_to: Optional[str] = None,
    created_at: Optional[str] = None,
) -> MagicMock:
    claim = MagicMock()
    claim.id = str(uuid.uuid4())
    claim.owner_id = "default"
    claim.subject = subject
    claim.predicate = predicate
    claim.object_value = object_value
    claim.status = status
    claim.valid_from = valid_from
    claim.valid_to = valid_to
    claim.created_at = created_at or _now()
    claim.updated_at = _now()
    claim.confidence = 0.9
    claim.sensitivity = "normal"
    claim.category = "misc"
    claim.access_count = 0
    claim.metadata_json = None
    claim.memory_class = "semantic"
    claim.salience = "medium"
    claim.explicitness = "inferred"
    claim.stability = "volatile"
    claim.entity_id = None
    claim.entity_type = "agent"
    return claim


# ---------------------------------------------------------------------------
# 1. Migration v20: backfill valid_from + temporal index
# ---------------------------------------------------------------------------

class TestV20Migration:
    def test_migration_version(self) -> None:
        from cortex.storage.migrations.v20_temporal_scoping import VERSION
        assert VERSION == 20

    def test_migration_creates_index(self) -> None:
        import inspect
        from cortex.storage.migrations import v20_temporal_scoping as m
        # Migration uses apply() for idempotency; check index is in module or _POST_SQL
        source = inspect.getsource(m) if hasattr(m, '__dict__') else ""
        # Check _POST_SQL which is executed by apply()
        assert hasattr(m, '_POST_SQL')
        assert "idx_claim_temporal" in m._POST_SQL
        assert "subject" in m._POST_SQL
        assert "predicate" in m._POST_SQL
        assert "valid_to" in m._POST_SQL

    def test_migration_backfills_valid_from(self) -> None:
        from cortex.storage.migrations import v20_temporal_scoping as m
        # Migration uses apply() for idempotency; check backfill is in _POST_SQL
        assert hasattr(m, '_POST_SQL')
        assert "UPDATE semantic_claim" in m._POST_SQL
        assert "valid_from = created_at" in m._POST_SQL
        assert "valid_from IS NULL" in m._POST_SQL

    def test_migration_has_description(self) -> None:
        from cortex.storage.migrations.v20_temporal_scoping import DESCRIPTION
        assert DESCRIPTION


# ---------------------------------------------------------------------------
# 2. RetrievalConstraints: include_expired field
# ---------------------------------------------------------------------------

class TestRetrievalConstraints:
    def test_default_is_false(self) -> None:
        from cortex.core.retrieval import RetrievalConstraints
        c = RetrievalConstraints()
        assert c.include_expired is False

    def test_can_set_true(self) -> None:
        from cortex.core.retrieval import RetrievalConstraints
        c = RetrievalConstraints(include_expired=True)
        assert c.include_expired is True


# ---------------------------------------------------------------------------
# 3. Reconciliation: supersede sets valid_to on old claim
# ---------------------------------------------------------------------------

class TestReconciliationTemporalExpiry:
    @pytest.mark.asyncio
    async def test_supersede_sets_valid_to_on_old_claim(self) -> None:
        from cortex.core.reconciliation import ReconciliationEngine

        engine = object.__new__(ReconciliationEngine)
        storage = AsyncMock()
        new_claim = _make_claim(status="active")
        storage.update_claim = AsyncMock(return_value=new_claim)
        storage.get_changelog = AsyncMock(return_value=[])
        storage.log_changelog = AsyncMock()
        storage.create_claim = AsyncMock(return_value=new_claim)
        storage.get_claims_by_owner = AsyncMock(return_value=[])

        engine.storage = storage
        engine.config = MagicMock()
        engine.config.session_confidence_default = 0.5
        engine.contradiction_tracker = MagicMock()
        engine.contradiction_tracker.record = MagicMock()
        engine._check_anti_compression = AsyncMock(return_value=True)
        engine._record_provenance = AsyncMock()
        engine._supersede_stale_claims = AsyncMock()

        import cortex.core.reconciliation as _recon_mod
        original_persist = getattr(_recon_mod, "_persist_contradiction", None)
        _recon_mod._persist_contradiction = AsyncMock()

        try:
            existing = _make_claim(status="active", valid_to=None)
            candidate = MagicMock()
            candidate.subject = existing.subject
            candidate.predicate = existing.predicate
            candidate.object_value = "Chiang Mai"
            candidate.confidence = 0.9
            candidate.sensitivity = "normal"
            candidate.category = "misc"
            candidate.metadata = {}
            for attr in ("intrinsic_type", "human_family", "memory_class",
                         "event_date", "sequence_order", "salience", "status",
                         "cause", "effect", "state_before", "state_after",
                         "temporal", "temporal_marker", "explicitness", "stability"):
                setattr(candidate, attr, None)

            await engine._supersede_claim(
                candidate=candidate,
                existing=existing,
                session_id="sess-1",
                owner_id="default",
                subject_entity_id=None,
            )
        finally:
            if original_persist is not None:
                _recon_mod._persist_contradiction = original_persist

        first_call_kwargs = storage.update_claim.call_args_list[0]
        call_kwargs = first_call_kwargs[1] if first_call_kwargs[1] else {}
        assert "valid_to" in call_kwargs, (
            f"update_claim must be called with valid_to. Got kwargs: {call_kwargs}"
        )
        assert call_kwargs["valid_to"] is not None
        assert call_kwargs.get("status") == "superseded"


# ---------------------------------------------------------------------------
# 4. Storage: browse_memories include_expired + get_timeline
# ---------------------------------------------------------------------------

class TestStorageTemporalClaims:
    @pytest.mark.asyncio
    async def test_browse_memories_default_excludes_expired(self) -> None:
        from cortex.storage.queries.claim_queries import ClaimQueryMixin

        storage = object.__new__(ClaimQueryMixin)
        conn = AsyncMock()
        count_row = MagicMock()
        count_row.__getitem__ = lambda s, i: 0
        cursor = AsyncMock()
        cursor.__aenter__ = AsyncMock(return_value=cursor)
        cursor.__aexit__ = AsyncMock(return_value=False)
        cursor.fetchone = AsyncMock(return_value=count_row)
        cursor.fetchall = AsyncMock(return_value=[])
        conn.execute = MagicMock(return_value=cursor)
        storage._get_conn = AsyncMock(return_value=conn)

        await storage.browse_memories(owner_id="default", include_expired=False)

        all_queries = " ".join(
            str(call.args[0]) for call in conn.execute.call_args_list
        )
        assert "valid_to IS NULL" in all_queries

    @pytest.mark.asyncio
    async def test_browse_memories_include_expired_skips_filter(self) -> None:
        from cortex.storage.queries.claim_queries import ClaimQueryMixin

        storage = object.__new__(ClaimQueryMixin)
        conn = AsyncMock()
        count_row = MagicMock()
        count_row.__getitem__ = lambda s, i: 0
        cursor = AsyncMock()
        cursor.__aenter__ = AsyncMock(return_value=cursor)
        cursor.__aexit__ = AsyncMock(return_value=False)
        cursor.fetchone = AsyncMock(return_value=count_row)
        cursor.fetchall = AsyncMock(return_value=[])
        conn.execute = MagicMock(return_value=cursor)
        storage._get_conn = AsyncMock(return_value=conn)

        await storage.browse_memories(owner_id="default", include_expired=True)

        all_queries = " ".join(
            str(call.args[0]) for call in conn.execute.call_args_list
        )
        assert "valid_to IS NULL" not in all_queries

    @pytest.mark.asyncio
    async def test_get_timeline_method_exists(self) -> None:
        from cortex.storage.queries.claim_queries import ClaimQueryMixin
        import inspect
        assert hasattr(ClaimQueryMixin, "get_timeline")
        assert inspect.iscoroutinefunction(ClaimQueryMixin.get_timeline)

    @pytest.mark.asyncio
    async def test_get_timeline_orders_by_valid_from(self) -> None:
        from cortex.storage.queries.claim_queries import ClaimQueryMixin

        storage = object.__new__(ClaimQueryMixin)
        conn = AsyncMock()
        cursor = AsyncMock()
        cursor.__aenter__ = AsyncMock(return_value=cursor)
        cursor.__aexit__ = AsyncMock(return_value=False)
        cursor.fetchall = AsyncMock(return_value=[])
        conn.execute = MagicMock(return_value=cursor)
        storage._get_conn = AsyncMock(return_value=conn)

        await storage.get_timeline(subject="eva", owner_id="default")

        call_sql = conn.execute.call_args[0][0]
        assert "coalesce" in call_sql.lower() or "valid_from" in call_sql.lower()


# ---------------------------------------------------------------------------
# 5. Retrieval: expired claims filtered
# ---------------------------------------------------------------------------

class TestRetrievalExpiredFilter:
    def test_include_expired_false_attribute(self) -> None:
        from cortex.core.retrieval import RetrievalConstraints
        c = RetrievalConstraints()
        assert hasattr(c, "include_expired")
        assert c.include_expired is False

    def test_include_expired_true_attribute(self) -> None:
        from cortex.core.retrieval import RetrievalConstraints
        c = RetrievalConstraints(include_expired=True)
        assert c.include_expired is True


# ---------------------------------------------------------------------------
# 6. API models: MemoryTimelineResponse
# ---------------------------------------------------------------------------

class TestMemoryTimelineModel:
    def test_model_importable(self) -> None:
        from cortex.api.models import MemoryTimelineResponse
        assert MemoryTimelineResponse is not None

    def test_model_fields(self) -> None:
        from cortex.api.models import MemoryTimelineResponse
        obj = MemoryTimelineResponse(subject="eva", items=[])
        assert obj.subject == "eva"
        assert obj.items == []
        assert obj.errors == []
        assert obj.total == 0


# ---------------------------------------------------------------------------
# 7. API route: timeline endpoint registered
# ---------------------------------------------------------------------------

class TestTimelineRoute:
    def test_route_exists(self) -> None:
        from cortex.api.routes.memory import _make_routes, router
        _make_routes(lambda: None)

        route_paths = [r.path for r in router.routes]
        assert any("timeline" in p for p in route_paths), (
            f"Expected /api/v1/memories/timeline in routes. Got: {route_paths}"
        )


# ---------------------------------------------------------------------------
# 8. API route: browse_memories include_expired param
# ---------------------------------------------------------------------------

class TestBrowseMemoriesIncludeExpired:
    def test_route_accepts_include_expired(self) -> None:
        from cortex.api.routes.memory import _make_routes, router
        import inspect

        _make_routes(lambda: None)

        for route in router.routes:
            if getattr(route, "operation_id", None) == "browse_memories":
                sig = inspect.signature(route.endpoint)
                assert "include_expired" in sig.parameters
                break
        else:
            pytest.fail("browse_memories operation not found in routes")
