"""Tests for the write pipeline — extract, entity_resolution, reconcile, upsert, and end-to-end.

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

import json
from unittest.mock import AsyncMock, MagicMock

from conftest import FakeEmbeddingProvider, FakeLLMProvider, run_async


def _make_extraction_response(
    entities: list[dict] | None = None,
    facts: list[dict] | None = None,
    relations: list[dict] | None = None,
) -> str:
    return json.dumps(
        {
            "entities": entities or [],
            "facts": facts or [],
            "relations": relations or [],
        }
    )


# ===========================================================================
# Test: extract
# ===========================================================================


class TestExtract:
    def test_extract_parses_entities_and_facts(self):
        from arandu.write.extract import extract_from_message

        response = _make_extraction_response(
            entities=[
                {"name": "user", "type": "person"},
                {"name": "São Paulo", "type": "place"},
            ],
            facts=[
                {"subject": "user", "fact": "Lives in São Paulo", "confidence": 0.95},
            ],
            relations=[],
        )
        llm = FakeLLMProvider([response])

        result, _meta = run_async(extract_from_message("Eu moro em São Paulo", llm=llm, speaker_name="Pedro"))

        assert len(result.entities) == 2
        assert len(result.facts) == 1
        assert result.facts[0].subject == "user"
        assert result.facts[0].confidence == 0.95

    def test_extract_returns_empty_on_failure(self):
        from arandu.write.extract import extract_from_message

        llm = FakeLLMProvider(["not valid json {{{"])

        result, _meta = run_async(extract_from_message("hello", llm=llm, speaker_name="Pedro"))

        assert len(result.entities) == 0
        assert len(result.facts) == 0

    def test_extract_handles_markdown_fences(self):
        from arandu.write.extract import extract_from_message

        response = (
            "```json\n"
            + _make_extraction_response(
                entities=[{"name": "user", "type": "person"}],
                facts=[{"subject": "user", "fact": "Works at Google", "confidence": 0.9}],
            )
            + "\n```"
        )
        llm = FakeLLMProvider([response])

        result, _meta = run_async(extract_from_message("I work at Google", llm=llm, speaker_name="Pedro"))

        assert len(result.facts) == 1

    def test_extract_parses_aliases(self):
        from arandu.write.extract import extract_from_message

        response = json.dumps(
            {
                "entities": [
                    {"name": "Guilherme Maturana", "type": "person", "aliases": ["Guili"]},
                    {"name": "user", "type": "person"},
                ],
                "facts": [
                    {"subject": "Guilherme Maturana", "fact": "Is a friend of user", "confidence": 0.95},
                ],
                "relations": [],
            }
        )
        llm = FakeLLMProvider([response])

        result, _meta = run_async(
            extract_from_message("Meu amigo Guili (Guilherme Maturana)", llm=llm, speaker_name="Pedro")
        )

        assert len(result.entities) == 2
        guilherme = [e for e in result.entities if e.name == "Guilherme Maturana"][0]
        assert guilherme.aliases == ["Guili"]

    def test_extract_aliases_default_empty(self):
        from arandu.write.extract import extract_from_message

        response = _make_extraction_response(
            entities=[{"name": "user", "type": "person"}],
            facts=[{"subject": "user", "fact": "Lives in SP", "confidence": 0.9}],
        )
        llm = FakeLLMProvider([response])

        result, _meta = run_async(extract_from_message("Eu moro em SP", llm=llm, speaker_name="Pedro"))

        assert result.entities[0].aliases == []

    def test_normalize_subjects_rewrites_alias_to_canonical(self):
        from arandu.constants import ExtractedEntity, ExtractedFact, ExtractedRelation, ExtractionOutput
        from arandu.write.extract import _normalize_extraction_subjects

        output = ExtractionOutput(
            entities=[
                ExtractedEntity(name="Guilherme Maturana", type="person", aliases=["Guili"]),
            ],
            facts=[
                ExtractedFact(subject="Guili", fact="Lives in São Paulo", confidence=0.9),
                ExtractedFact(subject="Guilherme Maturana", fact="Is a developer", confidence=0.95),
            ],
            relations=[
                ExtractedRelation(source="Guili", target="user", rel_type="friend_of"),
            ],
        )

        result = _normalize_extraction_subjects(output)

        assert result.facts[0].subject == "Guilherme Maturana"
        assert result.facts[1].subject == "Guilherme Maturana"
        assert result.relations[0].source == "Guilherme Maturana"

    def test_normalize_subjects_removes_same_as_self_relations(self):
        from arandu.constants import ExtractedEntity, ExtractedRelation, ExtractionOutput
        from arandu.write.extract import _normalize_extraction_subjects

        output = ExtractionOutput(
            entities=[
                ExtractedEntity(name="Guilherme Maturana", type="person", aliases=["Guili"]),
            ],
            facts=[],
            relations=[
                ExtractedRelation(source="Guili", target="Guilherme Maturana", rel_type="same_as"),
                ExtractedRelation(source="Guilherme Maturana", target="user", rel_type="friend_of"),
            ],
        )

        result = _normalize_extraction_subjects(output)

        # same_as between alias and canonical should be removed
        assert len(result.relations) == 1
        assert result.relations[0].rel_type == "friend_of"


# ===========================================================================
# Test: relation extraction retry
# ===========================================================================


class TestRelationExtractionRetry:
    def test_retries_when_zero_relations_and_two_or_more_entities(self):
        from arandu.constants import ExtractedEntity
        from arandu.write.extract import _extract_relations

        entities = [
            ExtractedEntity(name="user", type="person"),
            ExtractedEntity(name="São Paulo", type="place"),
        ]

        # First call returns empty relations, second returns one relation
        empty_response = json.dumps({"relations": []})
        good_response = json.dumps({"relations": [{"source": "user", "target": "São Paulo", "rel_type": "lives_in"}]})
        llm = FakeLLMProvider([empty_response, good_response])

        relations, retry_triggered = run_async(
            _extract_relations(llm, "Eu moro em São Paulo", entities, speaker_name="Pedro")
        )

        assert len(relations) == 1
        assert relations[0].rel_type == "lives_in"
        assert retry_triggered is True
        # Verify both calls were consumed (retry happened)
        assert llm._call_count == 2

    def test_no_retry_when_less_than_two_entities(self):
        from arandu.constants import ExtractedEntity
        from arandu.write.extract import _extract_relations

        entities = [ExtractedEntity(name="user", type="person")]

        empty_response = json.dumps({"relations": []})
        llm = FakeLLMProvider([empty_response])

        relations, retry_triggered = run_async(_extract_relations(llm, "Oi", entities, speaker_name="Pedro"))

        assert len(relations) == 0
        assert retry_triggered is False
        # Only one call — no retry for < 2 entities
        assert llm._call_count == 1

    def test_accepts_empty_if_retry_also_returns_empty(self):
        from arandu.constants import ExtractedEntity
        from arandu.write.extract import _extract_relations

        entities = [
            ExtractedEntity(name="user", type="person"),
            ExtractedEntity(name="São Paulo", type="place"),
        ]

        empty_response = json.dumps({"relations": []})
        llm = FakeLLMProvider([empty_response, empty_response])

        relations, retry_triggered = run_async(
            _extract_relations(llm, "Eu moro em São Paulo", entities, speaker_name="Pedro")
        )

        assert len(relations) == 0
        assert retry_triggered is True
        # Both calls consumed — retry happened but still empty
        assert llm._call_count == 2


# ===========================================================================
# Test: entity_resolution
# ===========================================================================


class TestEntityResolution:
    def test_speaker_pronoun_resolves_to_speaker(self):
        from arandu.constants import ExtractedEntity
        from arandu.write.entity_resolution import _resolve_single

        entity = ExtractedEntity(name="eu", type="person")
        llm = FakeLLMProvider()

        result = run_async(
            _resolve_single(
                session=MagicMock(),
                agent_id="user_123",
                entity=entity,
                existing_entities=[],
                alias_cache={},
                llm=llm,
                speaker_name="Pedro",
            )
        )

        assert result.entity_key == "person:pedro"
        assert result.display_name == "Pedro"
        assert result.resolution_method == "speaker"

    def test_exact_match_by_alias(self):
        from arandu.write.entity_resolution import _exact_match

        # Create a mock entity
        mock_entity = MagicMock()
        mock_entity.canonical_key = "person:kevin"
        mock_entity.display_name = "Kevin"
        mock_entity.entity_type = "person"

        alias_cache = {"kevin": "person:kevin"}

        result = _exact_match("kevin", [mock_entity], alias_cache)
        assert result is not None
        assert result.canonical_key == "person:kevin"

    def test_fuzzy_match_returns_similar(self):
        from conftest import run_async

        from arandu.write.entity_resolution import _fuzzy_match

        mock_entity = MagicMock()
        mock_entity.canonical_key = "person:kevin_silva"
        mock_entity.display_name = "Kevin Silva"
        mock_entity.entity_type = "person"
        mock_entity.embedding_vec = None

        result = run_async(_fuzzy_match("Kevin", [mock_entity]))
        assert len(result) >= 1
        entity, score = result[0]
        assert entity.canonical_key == "person:kevin_silva"
        assert score > 0.5

    def test_slugify(self):
        from arandu.write.entity_resolution import _slugify

        assert _slugify("Kevin Silva") == "kevin_silva"
        assert _slugify("São Paulo") == "sao_paulo"
        assert _slugify("") == "unknown"

    def test_hint_parsing(self):
        from arandu.write.entity_resolution import _parse_subject_hint

        name, hint = _parse_subject_hint("Carol (namorada de Rafael)")
        assert name == "Carol"
        assert hint == "namorada de Rafael"

        name, hint = _parse_subject_hint("Kevin")
        assert name == "Kevin"
        assert hint is None

    def test_create_entity_registers_aliases(self):
        from arandu.constants import ExtractedEntity
        from arandu.write.entity_resolution import _create_entity

        entity = ExtractedEntity(name="Guilherme Maturana", type="person", aliases=["Guili", "Gui"])

        mock_session = AsyncMock()
        mock_session.add = MagicMock()
        mock_session.flush = AsyncMock()
        mock_session.begin_nested = MagicMock()
        mock_session.begin_nested.return_value.__aenter__ = AsyncMock()
        mock_session.begin_nested.return_value.__aexit__ = AsyncMock(return_value=False)

        result = run_async(
            _create_entity(
                session=mock_session,
                agent_id="user_123",
                entity=entity,
                aliases=["Guili", "Gui"],
            )
        )

        assert result.is_new is True
        assert result.entity_key == "person:guilherme_maturana"
        # The entity creation uses 1 begin_nested (for entity + primary alias),
        # plus 2 more for the additional aliases (Guili, Gui)
        assert mock_session.begin_nested.call_count == 3


# ===========================================================================
# Test: reconcile
# ===========================================================================


class TestReconcile:
    def test_all_add_when_no_existing_facts(self):
        from arandu.constants import ExtractedFact
        from arandu.write.entity_resolution import ResolvedEntity
        from arandu.write.reconcile import _reconcile_subject_batch

        entity = ResolvedEntity(
            entity_type="person",
            entity_key="user:self",
            display_name="user",
            is_new=False,
            resolution_method="self",
        )
        facts = [
            ExtractedFact(subject="user", fact="Lives in São Paulo", confidence=0.95),
            ExtractedFact(subject="user", fact="Works at Google", confidence=0.9),
        ]

        # Mock session to return empty result for existing facts
        mock_session = AsyncMock()
        mock_result = MagicMock()
        mock_result.scalars.return_value.all.return_value = []
        mock_session.execute = AsyncMock(return_value=mock_result)

        llm = FakeLLMProvider()
        emb = FakeEmbeddingProvider()

        decisions = run_async(
            _reconcile_subject_batch(
                mock_session,
                "user_123",
                entity,
                facts,
                llm,
                emb,
            )
        )

        assert len(decisions) == 2
        assert all(d.action == "ADD" for d in decisions)

    def test_reconcile_groups_by_subject(self):
        from arandu.constants import ExtractedFact
        from arandu.write.entity_resolution import ResolvedEntity
        from arandu.write.reconcile import reconcile_facts

        entity_map = {
            "user": ResolvedEntity("person", "user:self", "user", False, "self"),
        }
        facts = [
            ExtractedFact(subject="user", fact="Lives in SP", confidence=0.9),
        ]

        mock_session = AsyncMock()
        mock_result = MagicMock()
        mock_result.scalars.return_value.all.return_value = []
        mock_session.execute = AsyncMock(return_value=mock_result)
        mock_session.begin_nested = MagicMock()
        mock_session.begin_nested.return_value.__aenter__ = AsyncMock()
        mock_session.begin_nested.return_value.__aexit__ = AsyncMock(return_value=False)

        llm = FakeLLMProvider()
        emb = FakeEmbeddingProvider()

        decisions = run_async(
            reconcile_facts(
                mock_session,
                "user_123",
                facts,
                entity_map,
                llm,
                emb,
            )
        )

        assert len(decisions) == 1
        assert decisions[0].action == "ADD"


# ===========================================================================
# Test: upsert
# ===========================================================================


class TestUpsert:
    def test_execute_upsert_add(self):
        from arandu.write.entity_resolution import ResolvedEntity
        from arandu.write.reconcile import ReconciliationDecision
        from arandu.write.upsert import execute_upsert

        entity_map = {
            "user": ResolvedEntity("person", "user:self", "user", False, "self"),
        }
        decisions = [
            ReconciliationDecision(
                fact_text="Lives in São Paulo",
                subject="user",
                action="ADD",
                confidence=0.95,
            ),
        ]

        mock_session = AsyncMock()
        mock_session.add = MagicMock()
        mock_session.flush = AsyncMock()
        mock_session.begin_nested = MagicMock()
        mock_session.begin_nested.return_value.__aenter__ = AsyncMock()
        mock_session.begin_nested.return_value.__aexit__ = AsyncMock(return_value=False)

        emb = FakeEmbeddingProvider()

        result = run_async(
            execute_upsert(
                mock_session,
                "user_123",
                "evt_1",
                decisions,
                entity_map,
                emb,
            )
        )

        assert result.added == 1
        assert result.updated == 0
        assert mock_session.add.called

    def test_execute_upsert_noop_returns_list(self):
        from arandu.write.entity_resolution import ResolvedEntity
        from arandu.write.reconcile import ReconciliationDecision
        from arandu.write.upsert import execute_upsert

        entity_map = {
            "user": ResolvedEntity("person", "user:self", "user", False, "self"),
        }
        decisions = [
            ReconciliationDecision(
                fact_text="Lives in São Paulo",
                subject="user",
                action="NOOP",
                confidence=0.95,
                matched_fact_id="fact-123",
            ),
        ]

        mock_session = AsyncMock()
        mock_result = MagicMock()
        mock_result.scalar_one_or_none.return_value = MagicMock()
        mock_session.execute = AsyncMock(return_value=mock_result)
        mock_session.add = MagicMock()

        emb = FakeEmbeddingProvider()

        result = run_async(
            execute_upsert(
                mock_session,
                "user_123",
                "evt_1",
                decisions,
                entity_map,
                emb,
            )
        )

        assert isinstance(result.confirmed, list)
        assert len(result.confirmed) == 1
        assert result.confirmed[0]["fact_text"] == "Lives in São Paulo"
        assert result.confirmed[0]["subject"] == "user"

    def test_execute_upsert_delete_returns_list(self):
        from arandu.write.entity_resolution import ResolvedEntity
        from arandu.write.reconcile import ReconciliationDecision
        from arandu.write.upsert import execute_upsert

        entity_map = {
            "user": ResolvedEntity("person", "user:self", "user", False, "self"),
        }
        decisions = [
            ReconciliationDecision(
                fact_text="Works at Google",
                subject="user",
                action="DELETE",
                confidence=0.9,
                matched_fact_id="fact-456",
            ),
        ]

        mock_session = AsyncMock()
        mock_result = MagicMock()
        mock_result.scalar_one_or_none.return_value = MagicMock()
        mock_session.execute = AsyncMock(return_value=mock_result)
        mock_session.add = MagicMock()

        emb = FakeEmbeddingProvider()

        result = run_async(
            execute_upsert(
                mock_session,
                "user_123",
                "evt_1",
                decisions,
                entity_map,
                emb,
            )
        )

        assert isinstance(result.deleted, list)
        assert len(result.deleted) == 1
        assert result.deleted[0]["fact_text"] == "Works at Google"
        assert result.deleted[0]["matched_fact_id"] == "fact-456"


# ===========================================================================
# Test: end-to-end pipeline
# ===========================================================================


class TestWritePipeline:
    def test_end_to_end_write(self):
        from arandu.config import MemoryConfig
        from arandu.write.pipeline import run_write_pipeline

        extraction_response = _make_extraction_response(
            entities=[
                {"name": "user", "type": "person"},
                {"name": "São Paulo", "type": "place"},
            ],
            facts=[
                {"subject": "user", "fact": "Lives in São Paulo", "confidence": 0.95},
            ],
            relations=[],
        )
        llm = FakeLLMProvider([extraction_response])
        emb = FakeEmbeddingProvider()
        config = MemoryConfig()

        # Mock session
        mock_session = AsyncMock()
        mock_session.add = MagicMock()
        mock_session.flush = AsyncMock()
        mock_session.begin_nested = MagicMock()
        mock_session.begin_nested.return_value.__aenter__ = AsyncMock()
        mock_session.begin_nested.return_value.__aexit__ = AsyncMock(return_value=False)

        # Mock execute for different queries (entity cache, alias cache, fact fetch)
        mock_result = MagicMock()
        mock_result.scalars.return_value.all.return_value = []
        mock_result.scalar_one_or_none.return_value = None
        mock_session.execute = AsyncMock(return_value=mock_result)

        result = run_async(
            run_write_pipeline(
                session=mock_session,
                agent_id="user_123",
                message="Eu moro em São Paulo",
                llm=llm,
                embeddings=emb,
                config=config,
                speaker_name="Pedro",
            )
        )

        assert result.event_id
        assert len(result.facts_added) >= 1
        assert result.duration_ms >= 0
        assert len(result.entities_resolved) >= 1

    def test_entity_upsert_called_for_all_resolved_entities(self):
        """create_or_update_entity is called for every resolved entity, including existing ones."""
        from unittest.mock import patch

        from arandu.config import MemoryConfig
        from arandu.write.pipeline import run_write_pipeline

        extraction_response = _make_extraction_response(
            entities=[
                {"name": "user", "type": "person"},
                {"name": "São Paulo", "type": "place"},
            ],
            facts=[
                {"subject": "user", "fact": "Lives in São Paulo", "confidence": 0.95},
            ],
            relations=[],
        )
        llm = FakeLLMProvider([extraction_response])
        emb = FakeEmbeddingProvider()
        config = MemoryConfig()

        mock_session = AsyncMock()
        mock_session.add = MagicMock()
        mock_session.flush = AsyncMock()
        mock_session.begin_nested = MagicMock()
        mock_session.begin_nested.return_value.__aenter__ = AsyncMock()
        mock_session.begin_nested.return_value.__aexit__ = AsyncMock(return_value=False)

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

        async def _run():
            with patch(
                "arandu.write.pipeline.create_or_update_entity",
                new_callable=AsyncMock,
            ) as mock_entity_upsert:
                await run_write_pipeline(
                    session=mock_session,
                    agent_id="user_123",
                    message="Eu moro em São Paulo",
                    llm=llm,
                    embeddings=emb,
                    config=config,
                    speaker_name="Pedro",
                )
                return mock_entity_upsert

        mock_entity_upsert = run_async(_run())

        # Should be called for every resolved entity (at least 2: user + São Paulo)
        assert mock_entity_upsert.call_count >= 2
        # Verify agent_id is passed correctly in all calls
        for call in mock_entity_upsert.call_args_list:
            assert call.args[1] == "user_123"  # agent_id is 2nd positional arg

    def test_empty_message_returns_empty_result(self):
        from arandu.config import MemoryConfig
        from arandu.write.pipeline import run_write_pipeline

        llm = FakeLLMProvider()  # returns empty extraction
        emb = FakeEmbeddingProvider()
        config = MemoryConfig()

        mock_session = AsyncMock()
        mock_session.add = MagicMock()
        mock_session.flush = AsyncMock()

        result = run_async(
            run_write_pipeline(
                session=mock_session,
                agent_id="user_123",
                message="oi",
                llm=llm,
                embeddings=emb,
                config=config,
                speaker_name="Pedro",
            )
        )

        assert result.event_id
        assert len(result.facts_added) == 0
        assert len(result.facts_unchanged) == 0

    def test_whitespace_message_skips_pipeline(self):
        """Empty/whitespace messages return immediately without creating event or calling LLM."""
        from arandu.config import MemoryConfig
        from arandu.write.pipeline import run_write_pipeline

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

        mock_session = AsyncMock()
        mock_session.add = MagicMock()
        mock_session.flush = AsyncMock()

        for empty_msg in ["", "   ", "\n\t  "]:
            result = run_async(
                run_write_pipeline(
                    session=mock_session,
                    agent_id="user_123",
                    message=empty_msg,
                    llm=llm,
                    embeddings=emb,
                    config=config,
                    speaker_name="Pedro",
                )
            )

            assert result.event_id == ""
            assert result.success is True
            assert len(result.facts_added) == 0
            # No event created — session.add should not have been called
            mock_session.add.assert_not_called()
            mock_session.reset_mock()

    def test_trace_contains_extraction_metadata(self):
        """Trace extraction step includes relation_retry_triggered."""
        from unittest.mock import patch

        from arandu.config import MemoryConfig
        from arandu.constants import ExtractionOutput
        from arandu.tracing import PipelineTrace
        from arandu.write.pipeline import run_write_pipeline

        llm = FakeLLMProvider()
        emb = FakeEmbeddingProvider()
        config = MemoryConfig()
        trace = PipelineTrace()

        mock_session = AsyncMock()
        mock_session.add = MagicMock()
        mock_session.flush = AsyncMock()

        extraction_meta = {"relation_retry_triggered": False}

        with patch("arandu.write.pipeline.extract_from_message", return_value=(ExtractionOutput(), extraction_meta)):
            run_async(
                run_write_pipeline(
                    session=mock_session,
                    agent_id="user_123",
                    message="some message",
                    llm=llm,
                    embeddings=emb,
                    config=config,
                    speaker_name="Pedro",
                    trace=trace,
                )
            )

        extraction_step = next((s for s in trace.steps if s.name == "extraction"), None)
        assert extraction_step is not None
        assert extraction_step.data["relation_retry_triggered"] is False


# ===========================================================================
# Test: speaker-aware extraction
# ===========================================================================


class TestSpeakerAwareExtraction:
    def test_extract_uses_speaker_name_in_entities(self):
        """Extraction should use speaker_name, not 'user', as entity."""
        from arandu.write.extract import extract_from_message

        response = _make_extraction_response(
            entities=[
                {"name": "Pedro", "type": "person"},
                {"name": "São Paulo", "type": "place"},
            ],
            facts=[
                {"subject": "Pedro", "fact": "Pedro lives in São Paulo", "confidence": 0.95},
            ],
            relations=[],
        )
        llm = FakeLLMProvider([response])

        result, _meta = run_async(extract_from_message("Eu moro em SP", llm=llm, speaker_name="Pedro"))

        assert len(result.entities) == 2
        entity_names = [e.name for e in result.entities]
        assert "Pedro" in entity_names
        assert "user" not in entity_names
        assert result.facts[0].subject == "Pedro"

    def test_no_user_entity_created(self):
        """Even if LLM returns 'user' entity, speaker_name should be used in prompts."""
        from arandu.write.extract import extract_from_message

        # Simulate LLM correctly using speaker_name (no "user")
        response = _make_extraction_response(
            entities=[{"name": "Maria", "type": "person"}],
            facts=[{"subject": "Maria", "fact": "Maria likes coffee", "confidence": 0.9}],
        )
        llm = FakeLLMProvider([response])

        result, _meta = run_async(extract_from_message("Eu gosto de café", llm=llm, speaker_name="Maria"))

        entity_names = [e.name for e in result.entities]
        assert "user" not in entity_names
        fact_subjects = [f.subject for f in result.facts]
        assert "user" not in fact_subjects

    def test_pronouns_resolve_to_speaker(self):
        """Speaker pronouns (eu, me, I, etc.) should resolve to speaker entity."""
        from arandu.constants import ExtractedEntity
        from arandu.write.entity_resolution import _resolve_single

        for pronoun in ["eu", "me", "i", "myself", "mim", "user"]:
            entity = ExtractedEntity(name=pronoun, type="person")
            llm = FakeLLMProvider()

            result = run_async(
                _resolve_single(
                    session=MagicMock(),
                    agent_id="agent_123",
                    entity=entity,
                    existing_entities=[],
                    alias_cache={},
                    llm=llm,
                    speaker_name="Pedro",
                )
            )

            assert result.entity_key == "person:pedro", f"Failed for pronoun '{pronoun}'"
            assert result.display_name == "Pedro"
            assert result.resolution_method == "speaker"

    def test_no_mirror_facts_in_prompt(self):
        """Fact extraction prompt should prohibit mirror facts."""
        from arandu.write.extract import FACT_EXTRACTION_PROMPT_TEMPLATE

        prompt = FACT_EXTRACTION_PROMPT_TEMPLATE.format(speaker_name="Pedro")
        assert "mirror facts" in prompt.lower()
        assert "NEVER create mirror facts" in prompt

    def test_speaker_name_required_in_write(self):
        """MemoryClient.write() should raise ValueError for empty speaker_name."""
        import pytest

        from arandu.client import MemoryClient

        # We can't actually create a MemoryClient without a real DB,
        # but we can test the validation by checking the ValueError is raised
        # before any DB operations
        client = MemoryClient.__new__(MemoryClient)
        client._config = None
        client._llm = None
        client._embeddings = None

        with pytest.raises(ValueError, match="speaker_name"):
            run_async(client.write(agent_id="u1", message="hi", speaker_name=""))

        with pytest.raises(ValueError, match="speaker_name"):
            run_async(client.write(agent_id="u1", message="hi", speaker_name="   "))
