"""Tests for mirror facts routing through reconcile → execute_upsert.

Covers:
- Mirror facts built as ExtractedFact are passed through reconcile_facts
- Mirror facts that duplicate existing facts become NOOP (no new row)
- Speaker is propagated into mirror fact creation
"""

from __future__ import annotations

from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from arandu.constants import ExtractedFact
from arandu.write.entity_resolution import ResolvedEntity
from arandu.write.reconcile import ReconciliationDecision
from arandu.write.upsert import UpsertResult, upsert_relations


def _make_entity(
    display_name: str = "Ana",
    entity_key: str = "person:ana",
    entity_type: str = "person",
) -> ResolvedEntity:
    return ResolvedEntity(
        entity_type=entity_type,
        entity_key=entity_key,
        display_name=display_name,
        is_new=False,
        resolution_method="exact",
    )


def _make_relation(source: str, target: str, rel_type: str) -> MagicMock:
    rel = MagicMock()
    rel.source = source
    rel.target = target
    rel.rel_type = rel_type
    return rel


class TestMirrorFactsGoThroughReconcile:
    """Mirror facts must flow through reconcile_facts, not session.add() direct."""

    @pytest.mark.asyncio
    async def test_mirror_fact_calls_reconcile(self):
        """When a relation has no evidence match, reconcile_facts is called with
        a synthetic ExtractedFact built from the relation."""
        source = _make_entity("Ana", "person:ana")
        target = _make_entity("Curitiba", "place:curitiba", "place")

        entity_map = {"ana": source, "curitiba": target}
        relations = [_make_relation("Ana", "Curitiba", "lives_in")]

        # No existing facts → heuristic match will return None → mirror path triggers
        session = AsyncMock()
        # The relation insert at the end of upsert_relations uses a savepoint
        session.begin_nested = MagicMock(return_value=AsyncMock())
        session.execute = AsyncMock()

        llm = MagicMock()
        embeddings = MagicMock()

        reconcile_decision = ReconciliationDecision(
            fact_text="Ana lives in Curitiba",
            subject="Ana",
            action="ADD",
            confidence=0.60,
        )

        with (
            patch(
                "arandu.write.upsert.reconcile_facts",
                new_callable=AsyncMock,
                return_value=[reconcile_decision],
            ) as mock_reconcile,
            patch(
                "arandu.write.upsert.execute_upsert",
                new_callable=AsyncMock,
                return_value=UpsertResult(added=1),
            ) as mock_upsert,
        ):
            await upsert_relations(
                session=session,
                agent_id="pedro",
                source_event_id="event-1",
                relations=relations,
                entity_map=entity_map,
                created_facts=None,
                embeddings=embeddings,
                llm=llm,
                speaker="Personal Genius",
            )

            # reconcile_facts must be called with a batch containing our mirror fact
            mock_reconcile.assert_called_once()
            call_kwargs = mock_reconcile.call_args[1]
            new_facts = call_kwargs["new_facts"]
            assert len(new_facts) == 1
            assert isinstance(new_facts[0], ExtractedFact)
            assert new_facts[0].fact == "Ana lives in Curitiba"
            assert new_facts[0].subject == "Ana"
            assert new_facts[0].confidence == 0.60

            # execute_upsert must be called with the decisions + speaker
            mock_upsert.assert_called_once()
            upsert_kwargs = mock_upsert.call_args[1]
            assert upsert_kwargs["decisions"] == [reconcile_decision]
            assert upsert_kwargs["speaker"] == "Personal Genius"

    @pytest.mark.asyncio
    async def test_mirror_fact_speaker_propagated(self):
        """Speaker passed to upsert_relations reaches execute_upsert via the
        mirror fact batch."""
        source = _make_entity("Pedro", "person:pedro")
        target = _make_entity("Brasil", "place:brasil", "place")

        entity_map = {"pedro": source, "brasil": target}
        relations = [_make_relation("Pedro", "Brasil", "lives_in")]

        session = AsyncMock()
        session.begin_nested = MagicMock(return_value=AsyncMock())
        session.execute = AsyncMock()

        llm = MagicMock()
        embeddings = MagicMock()

        with (
            patch(
                "arandu.write.upsert.reconcile_facts",
                new_callable=AsyncMock,
                return_value=[
                    ReconciliationDecision(
                        fact_text="Pedro lives in Brasil",
                        subject="Pedro",
                        action="ADD",
                        confidence=0.60,
                    )
                ],
            ),
            patch(
                "arandu.write.upsert.execute_upsert",
                new_callable=AsyncMock,
                return_value=UpsertResult(added=1),
            ) as mock_upsert,
        ):
            await upsert_relations(
                session=session,
                agent_id="pedro",
                source_event_id="event-1",
                relations=relations,
                entity_map=entity_map,
                created_facts=None,
                embeddings=embeddings,
                llm=llm,
                speaker="Personal Genius",
            )

            assert mock_upsert.call_args[1]["speaker"] == "Personal Genius"


class TestMirrorFactsDedup:
    """Mirror facts must be deduplicated via reconcile when an equivalent fact
    already exists."""

    @pytest.mark.asyncio
    async def test_mirror_fact_dedup_as_noop(self):
        """If reconcile marks the mirror fact as NOOP, execute_upsert does not
        create a new row (added=0)."""
        source = _make_entity("Pedro", "person:pedro")
        target = _make_entity("Brasil", "place:brasil", "place")

        entity_map = {"pedro": source, "brasil": target}
        relations = [_make_relation("Pedro", "Brasil", "lives_in")]

        session = AsyncMock()
        session.begin_nested = MagicMock(return_value=AsyncMock())
        session.execute = AsyncMock()

        llm = MagicMock()
        embeddings = MagicMock()

        # reconcile decides NOOP (fact already exists)
        noop_decision = ReconciliationDecision(
            fact_text="Pedro lives in Brasil",
            subject="Pedro",
            action="NOOP",
            confidence=0.60,
        )

        with (
            patch(
                "arandu.write.upsert.reconcile_facts",
                new_callable=AsyncMock,
                return_value=[noop_decision],
            ),
            patch(
                "arandu.write.upsert.execute_upsert",
                new_callable=AsyncMock,
                return_value=UpsertResult(added=0, confirmed=[{"fact_text": "Pedro lives in Brasil"}]),
            ) as mock_upsert,
        ):
            await upsert_relations(
                session=session,
                agent_id="pedro",
                source_event_id="event-1",
                relations=relations,
                entity_map=entity_map,
                created_facts=None,
                embeddings=embeddings,
                llm=llm,
                speaker="Personal Genius",
            )

            # execute_upsert was called with a NOOP decision → no new fact
            mock_upsert.assert_called_once()
            result = mock_upsert.return_value
            assert result.added == 0


class TestMirrorFactSkippedWithoutLLM:
    """Backward compat: if llm is not provided, mirror fact batch is skipped
    entirely (current code paths that only pass embeddings still work)."""

    @pytest.mark.asyncio
    async def test_no_llm_skips_mirror_batch(self):
        source = _make_entity("Ana", "person:ana")
        target = _make_entity("Curitiba", "place:curitiba", "place")

        entity_map = {"ana": source, "curitiba": target}
        relations = [_make_relation("Ana", "Curitiba", "lives_in")]

        session = AsyncMock()
        session.begin_nested = MagicMock(return_value=AsyncMock())
        session.execute = AsyncMock()

        embeddings = MagicMock()

        with (
            patch("arandu.write.upsert.reconcile_facts", new_callable=AsyncMock) as mock_reconcile,
            patch("arandu.write.upsert.execute_upsert", new_callable=AsyncMock) as mock_upsert,
        ):
            await upsert_relations(
                session=session,
                agent_id="pedro",
                source_event_id="event-1",
                relations=relations,
                entity_map=entity_map,
                created_facts=None,
                embeddings=embeddings,
                llm=None,  # no LLM → mirror batch skipped
                speaker=None,
            )

            mock_reconcile.assert_not_called()
            mock_upsert.assert_not_called()
