"""Tests for the spaCy extraction backend."""

import asyncio

import pytest
import spacy

from aurora_lens.pef.span import Span
from aurora_lens.pef.state import PEFState
from aurora_lens.interpret.spacy_backend import SpacyBackend, detect_span
from aurora_lens.interpret.pef_updater import update_pef


@pytest.fixture(scope="module")
def nlp():
    return spacy.load("en_core_web_sm")


@pytest.fixture
def backend(nlp):
    return SpacyBackend(nlp=nlp)


@pytest.fixture
def pef():
    return PEFState()


# ── Span detection ───────────────────────────────────────────────────

class TestSpanDetection:
    def test_present_tense(self, nlp):
        doc = nlp("Emma has a red book.")
        assert detect_span(doc) == Span.PRESENT

    def test_past_tense(self, nlp):
        doc = nlp("Emma had a red book.")
        assert detect_span(doc) == Span.PAST

    def test_used_to_pattern(self, nlp):
        doc = nlp("Emma used to live in London.")
        assert detect_span(doc) == Span.PAST

    def test_present_signal_words(self, nlp):
        doc = nlp("Emma currently lives in Melbourne.")
        assert detect_span(doc) == Span.PRESENT


# ── Entity extraction ────────────────────────────────────────────────

class TestEntityExtraction:
    def test_named_entity(self, backend, pef):
        result = asyncio.run(backend.extract("Emma has a red book.", pef))
        # "Emma" should appear in entity mentions
        names_lower = [m.lower() for m in result.entity_mentions]
        assert "emma" in names_lower

    def test_multiple_entities(self, backend, pef):
        result = asyncio.run(backend.extract("Emma gave Lucy a book.", pef))
        names_lower = [m.lower() for m in result.entity_mentions]
        # At least one proper noun should be caught
        assert len(result.entity_mentions) >= 1


# ── Claim extraction ─────────────────────────────────────────────────

class TestClaimExtraction:
    def test_simple_has(self, backend, pef):
        result = asyncio.run(backend.extract("Emma has a red book.", pef))
        # Should extract at least one claim
        assert len(result.claims) >= 1
        has_claims = [c for c in result.claims if c.relation == "HAS"]
        assert len(has_claims) >= 1

    def test_negated_claim(self, backend, pef):
        result = asyncio.run(backend.extract("Emma does not have a key.", pef))
        negated = [c for c in result.claims if c.negated]
        # spaCy should detect the negation
        assert len(negated) >= 1 or len(result.claims) >= 1

    def test_claim_span_detection(self, backend, pef):
        result = asyncio.run(backend.extract("Emma had a blue car.", pef))
        assert result.span == Span.PAST
        for claim in result.claims:
            assert claim.span == Span.PAST


# ── PEF state updates ───────────────────────────────────────────────

class TestPEFUpdate:
    def test_entity_creation(self, backend, pef):
        result = asyncio.run(backend.extract("Emma has a red book.", pef))
        update_pef(result, pef)
        entity = pef.find_entity_by_name("Emma")
        # Entity should be created (either from claims or mentions)
        assert entity is not None or len(pef.entities) >= 0

    def test_relationship_creation(self, backend, pef):
        result = asyncio.run(backend.extract("Emma has a red book.", pef))
        update_pef(result, pef)
        # If claims were extracted, relationships should exist
        if result.claims:
            assert len(pef.relationships) >= 1

    def test_multiple_turns(self, backend, pef):
        r1 = asyncio.run(backend.extract("Emma has a red book.", pef))
        update_pef(r1, pef)
        pef.advance_turn()

        r2 = asyncio.run(backend.extract("Emma is a teacher.", pef))
        update_pef(r2, pef)

        emma = pef.find_entity_by_name("Emma")
        if emma:
            rels = pef.get_relationships_for_subject(emma.id)
            # Should accumulate relationships across turns
            assert len(rels) >= 1


# ── Ambiguous referent detection ──────────────────────────────────────

class TestAmbiguousReferentDetection:
    """Verify that possessive pronouns are flagged when 2+ PERSON entities
    appear in the same text — the PEF no-premature-binding axiom."""

    def test_her_sister_ambiguous_two_persons(self, backend, pef):
        # Classic case: "her" could be Emma's or Anna's sister
        result = asyncio.run(
            backend.extract("Emma told Anna her sister was overseas.", pef)
        )
        assert "her" in result.ambiguous_referents

    def test_full_emma_anna_question(self, backend, pef):
        # Mirrors the live proxy test case exactly
        result = asyncio.run(
            backend.extract(
                "Emma told Anna her sister was overseas. Whose sister was overseas?",
                pef,
            )
        )
        assert "her" in result.ambiguous_referents

    def test_single_person_no_flag(self, backend, pef):
        # Only one PERSON entity → pronoun is unambiguous
        result = asyncio.run(
            backend.extract("Emma said her book was missing.", pef)
        )
        # With only one PERSON, "her" should NOT be flagged as ambiguous
        assert "her" not in result.ambiguous_referents

    def test_no_pronoun_no_flag(self, backend, pef):
        # Two people, but no possessive pronoun
        result = asyncio.run(
            backend.extract("Emma and Anna are both teachers.", pef)
        )
        assert result.ambiguous_referents == []

    def test_unambiguous_sentence_no_flag(self, backend, pef):
        # Possessive but only one PERSON candidate
        result = asyncio.run(
            backend.extract("John lost his wallet at the station.", pef)
        )
        assert "his" not in result.ambiguous_referents

    def test_cross_turn_pef_entities_trigger_gate(self, backend, pef):
        """Gate fires even when entity names come from PEF (prior turns), not current doc.

        Turn 1: Both names established via entity_mentions (no claims, resolved=False).
        Turn 2: Only pronouns in current message — NER finds nothing.
        The gate must still fire because PEF has 2+ person-like antecedents.
        """
        from aurora_lens.interpret.pef_updater import update_pef

        # Turn 1: extract introducing Emma and Anna
        pef.advance_turn()
        turn1 = asyncio.run(
            backend.extract("Emma told Anna her sister was overseas.", pef)
        )
        update_pef(turn1, pef)

        # Turn 2: only pronouns — spaCy NER finds zero PERSON entities in this doc
        pef.advance_turn()
        turn2 = asyncio.run(
            backend.extract("What did she tell her sister?", pef)
        )

        # Gate must fire: "her" is possessive, 2 person-like entities are in PEF
        assert "her" in turn2.ambiguous_referents

    def test_seed_history_seeds_pef_for_stateless_clients(self, backend):
        """Lens.seed_history() builds PEF from prior turns so gate fires on next process()."""
        from aurora_lens.lens import Lens
        from aurora_lens.config import LensConfig
        from aurora_lens.adapters.base import LLMAdapter, AdapterResponse

        class NullAdapter(LLMAdapter):
            async def generate(self, messages, **kwargs):
                return AdapterResponse(text="ok", model="mock")

        config = LensConfig(adapter=NullAdapter(), extraction_backend=backend)
        lens = Lens(config)

        history = [
            {"role": "user", "content": "Emma told Anna her sister was overseas."},
            {"role": "assistant", "content": "Emma mentioned that Anna's sister was abroad."},
        ]
        asyncio.run(lens.seed_history(history))

        # After seeding, PEF should have Emma and Anna as entity mentions
        person_names = {e.name for e in lens.pef.entities.values()}
        assert "Emma" in person_names or "Anna" in person_names

        # Now a follow-up message with a possessive pronoun should fire the gate
        result = asyncio.run(lens.process("What did she tell her sister?"))
        # Gate fires before LLM is called: response is the clarification message
        assert "her" in result.response.lower() or result.action.name != "PASS" or \
               "cannot be uniquely resolved" in result.response


# ── Regression: prep-relation guard ─────────────────────────────────────────

class TestPrepRelationGuard:
    """'works as a software engineer' must not emit a spurious AS claim.

    Repair: _find_objects only emits prep-path claims when the preposition
    maps to a CANONICAL_RELATION.  'as' → 'AS' is not canonical, so no claim
    is produced.  This prevented false UNSUPPORTED_EVENT on Turn 1 when the
    LLM paraphrases 'is a software engineer' as 'works as a software engineer'.
    """

    def test_works_as_does_not_produce_AS_claim(self, backend, pef):
        """'works as a software engineer' must not produce an AS relation claim."""
        result = asyncio.run(
            backend.extract("Alice works as a software engineer.", pef)
        )
        as_claims = [c for c in result.claims if c.relation == "AS"]
        assert as_claims == [], (
            f"'works as' must not emit an AS claim; got: {as_claims}"
        )

    def test_works_at_produces_AT_claim(self, backend, pef):
        """'works at Acme' must produce an AT relation claim (canonical preposition)."""
        result = asyncio.run(
            backend.extract("Alice works at Acme Corp.", pef)
        )
        at_claims = [c for c in result.claims if c.relation == "AT"]
        assert at_claims, (
            f"'works at' must emit an AT claim; got claims: {result.claims}"
        )

    def test_is_engineer_at_org_produces_two_claims(self, backend, pef):
        """'is a software engineer at Acme Corp.' must emit IS + AT claims."""
        result = asyncio.run(
            backend.extract("Alice is a software engineer at Acme Corp.", pef)
        )
        relations = [c.relation for c in result.claims if c.subject == "Alice"]
        assert "IS" in relations, f"Expected IS claim; got relations={relations}"
        assert "AT" in relations, f"Expected AT claim; got relations={relations}"


# -- Paraphrase normalization tests ------------------------------------------

class TestParaphraseNormalization:
    """Verify that "holds/occupies [the] position/title/role/post of X" is
    normalized to an IS claim and produces no spurious HOLD/OCCUPY pair."""

    def test_holds_position_of_emits_is_claim(self, backend, pef):
        result = asyncio.run(
            backend.extract("Maria holds the position of lead data scientist.", pef)
        )
        is_claims = [c for c in result.claims if c.relation == "IS"]
        assert len(is_claims) >= 1, (
            f"Expected IS claim; got {[(c.relation, c.obj) for c in result.claims]}"
        )
        assert any("scientist" in c.obj.lower() for c in is_claims), (
            f"IS obj should contain role text; got objs={[c.obj for c in is_claims]}"
        )

    def test_holds_position_no_hold_claim(self, backend, pef):
        result = asyncio.run(
            backend.extract("Maria holds the position of lead data scientist.", pef)
        )
        hold_claims = [c for c in result.claims if c.relation == "HOLD"]
        assert hold_claims == [], (
            f"Spurious HOLD claim must not be emitted; got {hold_claims}"
        )

    def test_holds_title_of_emits_is_claim(self, backend, pef):
        result = asyncio.run(
            backend.extract("Dr. Chen holds the title of Chief Medical Officer.", pef)
        )
        is_claims = [c for c in result.claims if c.relation == "IS"]
        assert len(is_claims) >= 1, (
            f"Expected IS claim; got {[(c.relation, c.obj) for c in result.claims]}"
        )
        # The role text should contain at least part of the title
        role_text = " ".join(c.obj for c in is_claims).lower()
        assert "officer" in role_text or "chief" in role_text, (
            f"IS obj should contain title text; got objs={[c.obj for c in is_claims]}"
        )

    def test_holds_role_of_emits_is_claim(self, backend, pef):
        result = asyncio.run(
            backend.extract("She holds the role of project manager.", pef)
        )
        is_claims = [c for c in result.claims if c.relation == "IS"]
        assert len(is_claims) >= 1, (
            f"Expected IS claim; got {[(c.relation, c.obj) for c in result.claims]}"
        )
        assert any("manager" in c.obj.lower() for c in is_claims), (
            f"IS obj should contain role text; got objs={[c.obj for c in is_claims]}"
        )

    def test_bare_holds_position_no_is_from_pattern(self, backend, pef):
        """'holds a position' with no 'of X' falls through to normal processing.
        The paraphrase rule must not fire and must not emit IS."""
        result = asyncio.run(
            backend.extract("Maria holds a position at the firm.", pef)
        )
        # No IS claim should come from the paraphrase normalization path.
        # (IS might still come from other parsing if spaCy produces one,
        # but the paraphrase rule itself should not trigger.)
        # Primary assertion: no claim that would incorrectly map "position" to IS.
        is_claims = [c for c in result.claims if c.relation == "IS" and "position" in c.obj.lower()]
        assert is_claims == [], (
            f"IS/position claim must not appear from bare 'holds a position'; got {is_claims}"
        )

    def test_occupy_position_of_emits_is_claim(self, backend, pef):
        result = asyncio.run(
            backend.extract("Dr. Chen occupies the position of director.", pef)
        )
        is_claims = [c for c in result.claims if c.relation == "IS"]
        assert len(is_claims) >= 1, (
            f"Expected IS claim from 'occupies the position of'; got {[(c.relation, c.obj) for c in result.claims]}"
        )
        assert any("director" in c.obj.lower() for c in is_claims), (
            f"IS obj should be 'director'; got {[c.obj for c in is_claims]}"
        )


# -- Passive agent extraction tests ------------------------------------------

class TestPassiveAgentExtraction:
    """Verify that passive sentences with explicit by-agents emit a flipped
    active claim, and that the rule does not fire without an agent phrase."""

    def test_passive_by_agent_emits_active_claim(self, backend, pef):
        result = asyncio.run(
            backend.extract("The contract was signed by Meridian Partners.", pef)
        )
        agent_claims = [
            c for c in result.claims
            if "meridian" in c.subject.lower() or "partners" in c.subject.lower()
        ]
        assert len(agent_claims) >= 1, (
            f"Expected claim with Meridian Partners as subject; "
            f"got subjects={[c.subject for c in result.claims]}"
        )

    def test_passive_by_agent_relation_consistency(self, backend, pef):
        """The passive flip and the active form must produce the same relation string."""
        passive_result = asyncio.run(
            backend.extract("The contract was signed by Meridian Partners.", pef)
        )
        active_result = asyncio.run(
            backend.extract("Meridian Partners signed the contract.", pef)
        )
        passive_rels = {c.relation for c in passive_result.claims
                        if "meridian" in c.subject.lower() or "partners" in c.subject.lower()}
        active_rels = {c.relation for c in active_result.claims
                       if "meridian" in c.subject.lower() or "partners" in c.subject.lower()}
        assert passive_rels == active_rels, (
            f"Passive flip relation {passive_rels} must match active relation {active_rels}"
        )

    def test_passive_without_by_phrase_no_agent_claim(self, backend, pef):
        """A passive sentence without a by-phrase must not produce an agent-flipped claim."""
        result = asyncio.run(
            backend.extract("The contract was signed.", pef)
        )
        # No subject should be an agent for "signed" — the passive subject is "contract".
        # Since there are no by-agents, the passive path returns nothing.
        agent_claims = [c for c in result.claims if c.relation == "SIGN"]
        assert agent_claims == [], (
            f"No SIGN claim should appear without an agent; got {agent_claims}"
        )

    def test_active_voice_unchanged(self, backend, pef):
        """Active voice extraction is completely unaffected by the passive rule."""
        result = asyncio.run(
            backend.extract("Meridian Partners signed the contract.", pef)
        )
        mp_claims = [
            c for c in result.claims
            if "meridian" in c.subject.lower() or "partners" in c.subject.lower()
        ]
        assert len(mp_claims) >= 1, (
            f"Active-voice extraction should produce Meridian Partners as subject; "
            f"got {[(c.subject, c.relation) for c in result.claims]}"
        )
