"""Regression test for retrieve() non-determinism bug.

Bug reported by Personal Genius (2026-04-09): running the same
``retrieve()`` call 5 times against a fixed memory returned different
``list[ScoredFact]`` between runs. Root cause: ``semantic_search`` and
``keyword_search`` ordered by ``cosine_distance`` / ``valid_from DESC``
with no stable tiebreaker, so facts with identical timestamps (created
in the same ``write()``) came back in non-deterministic heap order.

This test verifies the fix: after the FactQuery migration (Part 2 of
``fact-query-abstraction``), running ``retrieve_candidates`` multiple
times against a mocked session returning the same facts must produce
an identical ranked list every time.

The mock returns facts with identical ``valid_from`` timestamps,
simulating the production scenario where all facts came from a single
``write()`` call.
"""

from __future__ import annotations

import uuid
from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock

import pytest

from arandu.config import MemoryConfig
from arandu.read.retrieval import retrieve_candidates

NOW = datetime(2026, 4, 9, 12, 0, 0, tzinfo=UTC)


def _make_fact(
    fact_id: uuid.UUID,
    entity_key: str,
    fact_text: str,
) -> MagicMock:
    """Build a MagicMock that looks like a MemoryFact ORM row.

    All facts share the same ``valid_from`` timestamp, which is exactly
    the scenario that triggered the original non-determinism bug.
    """
    mock = MagicMock()
    mock.id = fact_id
    mock.entity_key = entity_key
    mock.entity_name = entity_key.split(":", 1)[1] if ":" in entity_key else entity_key
    mock.entity_type = "person"
    mock.fact_text = fact_text
    mock.confidence = 0.9
    mock.importance = 0.5
    mock.valid_from = NOW  # intentional: identical across facts
    mock.valid_to = None
    mock.created_at = NOW
    mock.cluster_id = None
    mock.attribute_key = None
    mock.category = None
    mock.source_context = None
    mock.speaker = "Pedro"
    return mock


def _make_embedding_provider() -> MagicMock:
    """Fake embedding provider returning a fixed vector."""
    ep = MagicMock()
    ep.embed_one = AsyncMock(return_value=[0.1] * 1536)
    return ep


def _make_session_with_fixed_facts(facts: list[MagicMock]) -> AsyncMock:
    """Build an AsyncSession mock that returns the same facts on every execute().

    The mock is smart enough to answer both the ``semantic_search`` call
    (which reads ``(fact, similarity)`` tuples via ``.all()``) and the
    ``keyword_search`` call (which reads facts via ``.scalars().all()``).
    """
    session = AsyncMock()

    # Similarity score is deterministic: same value for all facts so ties
    # are maximally likely — this stresses the tiebreaker.
    semantic_rows = [(f, 0.75) for f in facts]

    def _execute_side_effect(stmt, *args, **kwargs):  # type: ignore[no-untyped-def]
        compiled = str(stmt)
        result = MagicMock()
        if "similarity" in compiled or "cosine_distance" in compiled:
            # semantic_search path
            result.all = MagicMock(return_value=semantic_rows)
            result.scalars = MagicMock()
            result.scalars.return_value.all = MagicMock(return_value=facts)
        else:
            # keyword_search path (select MemoryFact only)
            result.all = MagicMock(return_value=[(f,) for f in facts])
            result.scalars = MagicMock()
            result.scalars.return_value.all = MagicMock(return_value=facts)
        return result

    session.execute = AsyncMock(side_effect=_execute_side_effect)
    return session


class TestRetrieveDeterminism:
    """Bug regression: running retrieve_candidates multiple times returns
    identical results when the underlying memory is fixed."""

    @pytest.mark.asyncio
    async def test_retrieve_candidates_is_deterministic_across_runs(self) -> None:
        """10 identical retrieve_candidates calls must return identical lists."""
        # Build 5 facts with identical timestamps — the exact scenario
        # that caused the original non-determinism bug.
        facts = [
            _make_fact(
                uuid.UUID("00000000-0000-0000-0000-000000000001"),
                "person:pedro_menezes",
                "Pedro Menezes lives in Brazil.",
            ),
            _make_fact(
                uuid.UUID("00000000-0000-0000-0000-000000000002"),
                "person:pedro_menezes",
                "Pedro Menezes is building Arandu.",
            ),
            _make_fact(
                uuid.UUID("00000000-0000-0000-0000-000000000003"),
                "person:pedro_menezes",
                "Pedro Menezes is a Senior GPM at Stone.",
            ),
            _make_fact(
                uuid.UUID("00000000-0000-0000-0000-000000000004"),
                "person:pedro_menezes",
                "Pedro Menezes uses Obsidian as a hub.",
            ),
            _make_fact(
                uuid.UUID("00000000-0000-0000-0000-000000000005"),
                "person:pedro_menezes",
                "Pedro's full name is Pedro Menezes.",
            ),
        ]

        config = MemoryConfig(
            topk_facts=10,
            min_confidence=0.0,
            min_similarity=0.0,
            enable_reranker=False,
        )
        embeddings = _make_embedding_provider()

        # Run 10 identical retrievals and collect the ordered fact_id lists
        results: list[list[str]] = []
        for _ in range(10):
            session = _make_session_with_fixed_facts(facts)
            candidates, _invalid = await retrieve_candidates(
                session=session,
                agent_id="pedro",
                query_text="where does Pedro work",
                embeddings=embeddings,
                config=config,
            )
            results.append([str(c.fact.id) for c in candidates])

        # Every run must produce the same ordered list
        first = results[0]
        for i, run in enumerate(results[1:], start=2):
            assert run == first, (
                f"Run {i} differs from run 1 — retrieve_candidates is non-deterministic.\n"
                f"Run 1: {first}\nRun {i}: {run}"
            )
