"""Tests for the Lens orchestrator.

These test the pipeline wiring without requiring a live LLM connection.
Uses a mock adapter and (optionally) a mock extraction backend.
"""

import pytest

from aurora_lens.lens import Lens, LensResult
from aurora_lens.config import LensConfig
from aurora_lens.adapters.base import LLMAdapter, AdapterResponse
from aurora_lens.interpret.base import ExtractionBackend
from aurora_lens.interpret.schema import ExtractedClaim, ExtractionResult
from aurora_lens.pef.span import Span
from aurora_lens.pef.state import PEFState


class MockAdapter(LLMAdapter):
    """Mock LLM adapter that returns canned responses."""

    def __init__(self, responses: list[str] | None = None):
        self._responses = responses or ["I don't know."]
        self._call_count = 0
        self.last_pef_context: str = ""
        self.last_messages: list[dict[str, str]] | None = None

    async def generate(
        self,
        messages: list[dict[str, str]],
        **kwargs,
    ) -> AdapterResponse:
        for m in messages:
            if m.get("role") == "system":
                self.last_pef_context = m.get("content", "")
        # History = all user/assistant except the last (current user message)
        user_assistant = [m for m in messages if m.get("role") in ("user", "assistant")]
        self.last_messages = user_assistant[:-1] if len(user_assistant) > 1 else user_assistant
        idx = min(self._call_count, len(self._responses) - 1)
        self._call_count += 1
        return AdapterResponse(text=self._responses[idx], model="mock-1")


@pytest.fixture
def mock_adapter():
    return MockAdapter(responses=["Emma has a red book.", "I don't know."])


@pytest.fixture
def lens(mock_adapter):
    config = LensConfig(adapter=mock_adapter)
    return Lens(config)


class TestLensPipeline:

    @pytest.mark.asyncio
    async def test_basic_turn(self, lens):
        result = await lens.process("Emma has a red book.")
        assert isinstance(result, LensResult)
        assert result.turn == 1
        assert result.response == "Emma has a red book."
        assert isinstance(result.flags, list)

    @pytest.mark.asyncio
    async def test_turn_advances(self, lens):
        r1 = await lens.process("Hello.")
        r2 = await lens.process("Hello again.")
        assert r1.turn == 1
        assert r2.turn == 2

    @pytest.mark.asyncio
    async def test_pef_context_injected(self, mock_adapter):
        """PEF context should be passed to the adapter."""
        config = LensConfig(adapter=mock_adapter)
        lens = Lens(config)
        await lens.process("Emma has a red book.")
        # After processing, PEF context should have been built
        # (content depends on extraction, but should be non-empty
        # if entities were found)
        assert isinstance(mock_adapter.last_pef_context, str)

    @pytest.mark.asyncio
    async def test_history_accumulates(self, mock_adapter):
        config = LensConfig(adapter=mock_adapter)
        lens = Lens(config)
        await lens.process("Emma has a red book.")
        await lens.process("What does Emma have?")
        # Second call should include history
        assert mock_adapter.last_messages is not None
        assert len(mock_adapter.last_messages) >= 2

    @pytest.mark.asyncio
    async def test_reset(self, lens):
        await lens.process("Emma has a red book.")
        assert lens.pef.current_turn > 0
        lens.reset()
        assert lens.pef.current_turn == 0
        assert len(lens.pef.entities) == 0

    @pytest.mark.asyncio
    async def test_pef_snapshot_in_result(self, lens):
        result = await lens.process("Emma has a red book.")
        assert isinstance(result.pef_snapshot, str)

    @pytest.mark.asyncio
    async def test_span_detection_in_result(self, lens):
        result = await lens.process("Emma had a red book.")
        assert result.span in (Span.PRESENT, Span.PAST)

    @pytest.mark.asyncio
    async def test_no_interpret_mode(self, mock_adapter):
        config = LensConfig(adapter=mock_adapter, auto_interpret=False)
        lens = Lens(config)
        result = await lens.process("Emma has a red book.")
        # With auto_interpret off, PEF should remain empty
        assert len(lens.pef.entities) == 0

    @pytest.mark.asyncio
    async def test_no_verify_mode(self, mock_adapter):
        config = LensConfig(adapter=mock_adapter, auto_verify=False)
        lens = Lens(config)
        result = await lens.process("Emma has a red book.")
        # With auto_verify off, no flags should be produced
        assert result.flags == []


# ── Mock extraction backend ──────────────────────────────────────────

class MockExtractionBackend(ExtractionBackend):
    """A fake backend that returns canned ExtractionResults.

    Proves the pipeline works with any backend, not just spaCy.
    """

    def __init__(self):
        self.call_count = 0
        self.last_text: str = ""
        self.last_pef: PEFState | None = None

    async def extract(self, text: str, pef: PEFState) -> ExtractionResult:
        self.call_count += 1
        self.last_text = text
        self.last_pef = pef
        return ExtractionResult(
            claims=[
                ExtractedClaim(
                    subject="Emma",
                    relation="HAS",
                    obj="red book",
                    span=Span.PRESENT,
                    negated=False,
                    evidence=text,
                ),
            ],
            entity_mentions=["Emma"],
            span=Span.PRESENT,
        )


class TestLensWithMockBackend:
    """Prove the Lens pipeline works with a non-spaCy backend."""

    @pytest.mark.asyncio
    async def test_mock_backend_processes(self):
        mock_backend = MockExtractionBackend()
        adapter = MockAdapter(responses=["Emma has a red book."])
        config = LensConfig(adapter=adapter, extraction_backend=mock_backend)
        lens = Lens(config)

        result = await lens.process("Emma has a red book.")
        assert isinstance(result, LensResult)
        assert result.turn == 1
        assert mock_backend.call_count >= 1

    @pytest.mark.asyncio
    async def test_mock_backend_updates_pef(self):
        mock_backend = MockExtractionBackend()
        adapter = MockAdapter(responses=["Emma has a red book."])
        config = LensConfig(adapter=adapter, extraction_backend=mock_backend)
        lens = Lens(config)

        await lens.process("Emma has a red book.")
        # The mock backend produces an "Emma" entity mention and a HAS claim
        # pef_updater should have created the entity
        emma = lens.pef.find_entity_by_name("Emma")
        assert emma is not None

    @pytest.mark.asyncio
    async def test_mock_backend_receives_pef(self):
        """Backend must receive the existing world, not None."""
        mock_backend = MockExtractionBackend()
        adapter = MockAdapter(responses=["Hello."])
        config = LensConfig(adapter=adapter, extraction_backend=mock_backend)
        lens = Lens(config)

        await lens.process("Hello.")
        assert mock_backend.last_pef is not None
        assert isinstance(mock_backend.last_pef, PEFState)

    @pytest.mark.asyncio
    async def test_mock_backend_used_for_verification(self):
        """Both interpretation and verification should use the backend."""
        mock_backend = MockExtractionBackend()
        adapter = MockAdapter(responses=["Emma has a red book."])
        config = LensConfig(adapter=adapter, extraction_backend=mock_backend)
        lens = Lens(config)

        await lens.process("Emma has a red book.")
        # Called at least twice: once for interpretation, once for verification
        assert mock_backend.call_count >= 2


# ── Pre-LLM referent ambiguity gate ─────────────────────────────────

class MockAmbiguousBackend(ExtractionBackend):
    """Returns ambiguous_referents=["her"] on the first call (user input),
    then a clean result for subsequent calls (LLM response verification).

    Simulates the Emma/Anna referent ambiguity detected at interpretation time.
    """

    def __init__(self):
        self.call_count = 0

    async def extract(self, text: str, pef: PEFState) -> ExtractionResult:
        self.call_count += 1
        if self.call_count == 1:
            # First call = user input interpretation: ambiguous pronoun detected
            return ExtractionResult(
                claims=[],
                entity_mentions=["Emma", "Anna"],
                span=Span.PRESENT,
                ambiguous_referents=["her"],
            )
        # Subsequent calls = LLM response verification: clean
        return ExtractionResult(
            claims=[],
            entity_mentions=[],
            span=Span.PRESENT,
        )


class TestPreLLMReferentGate:
    """Verify that ambiguous referents detected in user input trigger
    a clarification response before the LLM is ever called."""

    @pytest.mark.asyncio
    async def test_ambiguous_referent_blocks_llm(self):
        """When the user input has an ambiguous pronoun, the LLM must not be
        called; instead a clarification response is returned."""
        backend = MockAmbiguousBackend()
        adapter = MockAdapter(responses=["Anna's sister was overseas."])
        config = LensConfig(adapter=adapter, extraction_backend=backend)
        lens = Lens(config)

        result = await lens.process(
            "Emma told Anna her sister was overseas. Whose sister was overseas?"
        )

        # Governance should have intervened (not PASS)
        from aurora_lens.govern.decision import InterventionAction
        assert result.action != InterventionAction.PASS

        # LLM must NOT have been called
        assert adapter._call_count == 0

        # Response should request clarification
        assert "her" in result.response or "cannot" in result.response.lower()

        # Flag must be UNRESOLVED_REFERENT
        from aurora_lens.verify.flags import FlagType
        assert any(f.flag_type == FlagType.UNRESOLVED_REFERENT for f in result.flags)

    @pytest.mark.asyncio
    async def test_no_ambiguity_calls_llm(self):
        """When no ambiguous referent is detected, the LLM is called normally."""
        backend = MockExtractionBackend()  # never sets ambiguous_referents
        adapter = MockAdapter(responses=["Emma has a red book."])
        config = LensConfig(adapter=adapter, extraction_backend=backend)
        lens = Lens(config)

        await lens.process("Emma has a red book.")

        # LLM must have been called
        assert adapter._call_count >= 1
