"""spaCy-based extraction backend.

Deterministic, local NLP — independent of any LLM.
Implements ExtractionBackend using spaCy for entity/relationship extraction.

spaCy is imported lazily — this module is importable without spaCy installed.
Instantiating SpacyBackend without spaCy raises a clear runtime error.
"""

from __future__ import annotations

import asyncio
import re
from typing import Any, TYPE_CHECKING

from aurora_lens.pef.span import Span
from aurora_lens.pef.state import (
    PEFState,
    CANONICAL_RELATIONS,
    canonicalize_relation,
)
from aurora_lens.interpret.schema import ComparativeAmbiguity, ExtractedClaim, ExtractionResult
from aurora_lens.interpret.base import ExtractionBackend

if TYPE_CHECKING:
    import spacy
    from spacy.tokens import Doc, Token


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

_PAST_SIGNALS = {
    "was", "were", "had", "used to", "formerly", "previously",
    "once", "ago", "before", "back then",
}
_PRESENT_SIGNALS = {
    "is", "are", "has", "have", "now", "currently", "today",
}

# Patterns that strongly indicate past tense context
_PAST_PATTERNS = [
    re.compile(r"\bused\s+to\b", re.IGNORECASE),
    re.compile(r"\bback\s+then\b", re.IGNORECASE),
    re.compile(r"\b\w+\s+ago\b", re.IGNORECASE),
]


def detect_span(doc: Doc) -> Span:
    """Detect the dominant temporal span of a sentence.

    Uses verb tense morphology as primary signal, surface keywords as fallback.
    """
    past_score = 0
    present_score = 0

    for token in doc:
        if token.pos_ in ("VERB", "AUX"):
            morph = token.morph.get("Tense")
            if morph:
                if "Past" in morph:
                    past_score += 2
                elif "Pres" in morph:
                    present_score += 2

        lower = token.text.lower()
        if lower in _PAST_SIGNALS:
            past_score += 1
        elif lower in _PRESENT_SIGNALS:
            present_score += 1

    text = doc.text
    for pattern in _PAST_PATTERNS:
        if pattern.search(text):
            past_score += 3

    return Span.PAST if past_score > present_score else Span.PRESENT


# ── Pronoun detection ────────────────────────────────────────────────

_PRONOUNS = {"he", "she", "it", "they", "him", "her", "them", "his", "its", "their"}

# Possessive pronouns that can be ambiguous when multiple candidates exist
_POSSESSIVE_PRONOUNS = frozenset({"her", "his", "their", "its"})

# Subject- and object-position pronouns that can also be ambiguous
_SUBJECT_OBJECT_PRONOUNS: frozenset[str] = frozenset({
    "he", "him", "she", "her", "it",
    "they", "them", "that", "this", "these", "those",
})

# Determiners that mark definite NPs (presuppose a unique prior referent)
_DEFINITE_DETERMINERS: frozenset[str] = frozenset({"the", "that", "this", "those", "these"})

# Role/occupation paraphrase normalization constants.
# Only these verb lemmas trigger the "holds the position of X" -> IS X rewrite.
# "job" excluded: "holds a job at X" is employment location, not role equivalence.
_ROLE_HOLDER_VERBS: frozenset[str] = frozenset({"hold", "occupy"})
_ROLE_NOUNS: frozenset[str] = frozenset({"position", "title", "role", "post"})


# Common suffixes that strongly indicate an organisation or location rather
# than a person.  Used by the ambiguity gate to exclude ORG/LOC entity
# placeholders from the pef_persons set.
_ORG_SUFFIXES: frozenset[str] = frozenset({
    "hospital", "clinic", "medical", "health",
    "corp", "corporation", "inc", "incorporated", "ltd", "llc", "plc",
    "co", "company", "group", "firm", "associates", "partners",
    "university", "college", "school", "institute", "academy",
    "center", "centre", "department", "ministry", "bureau", "office",
    "authority", "agency", "council", "commission", "foundation",
    "bank", "trust", "fund", "hotel", "club",
})


def _likely_org_name(name: str) -> bool:
    """Return True if name looks like an organisation or location rather than a person."""
    return any(w.lower().rstrip(".") in _ORG_SUFFIXES for w in name.split())


def _is_pronoun(token: Token) -> bool:
    return token.text.lower() in _PRONOUNS or token.pos_ == "PRON"


# ── Negation detection ───────────────────────────────────────────────

_NEGATION_DEPS = {"neg"}
_NEGATION_TOKENS = {"not", "n't", "never", "no", "neither", "nor", "doesn't", "don't", "didn't", "isn't", "aren't", "wasn't", "weren't", "hasn't", "haven't", "hadn't", "won't", "wouldn't", "couldn't", "shouldn't"}


def _is_negated(token: Token) -> bool:
    """Check if a token (typically a verb) is negated."""
    for child in token.children:
        if child.dep_ in _NEGATION_DEPS:
            return True
        if child.text.lower() in _NEGATION_TOKENS:
            return True
    return False


# ── SpacyBackend ────────────────────────────────────────────────────

class SpacyBackend(ExtractionBackend):
    """spaCy-based deterministic extraction backend.

    Requires spaCy to be installed: pip install aurora-lens[spacy]
    """

    def __init__(self, nlp: spacy.language.Language | None = None, model: str = "en_core_web_sm"):
        try:
            import spacy as _spacy
        except ImportError:
            raise ImportError(
                "SpacyBackend requires spaCy. Install it with: "
                "pip install aurora-lens[spacy]"
            ) from None

        if nlp is None:
            self._nlp = _spacy.load(model)
        else:
            self._nlp = nlp

    async def extract(self, text: str, pef: PEFState) -> ExtractionResult:
        """Extract entities, relationships, and span signals from text."""
        doc = await asyncio.to_thread(self._nlp, text)
        result = ExtractionResult()
        result.span = detect_span(doc)

        # Collect named entities and noun chunks
        result.entity_mentions = self._extract_entity_mentions(doc)

        # Extract SVO relationships from dependency parse
        result.claims = self._extract_claims(doc, result.span)

        # Resolve pronouns against the existing world
        result.pronoun_candidates = self._resolve_pronouns(doc, pef)

        # Detect possessive pronouns that cannot be uniquely resolved
        result.ambiguous_referents = self._detect_ambiguous_referents(doc, pef)

        # Detect comparative adjectives whose comparand is underdetermined
        result.comparative_ambiguities = self._detect_comparative_claims(doc, pef)

        return result

    # Dependency relations that indicate a token fills an entity-like argument
    # position (subject, object, appositive, conjunct).  Tokens in predicative
    # or modifier positions (attr, compound, amod, nmod, …) are excluded so
    # that title words like "Chief", "Medical", "Officer" in
    # "is the Chief Medical Officer" are not treated as person names.
    _ENTITY_POSITION_DEPS: frozenset[str] = frozenset(
        {"nsubj", "nsubjpass", "dobj", "pobj", "appos", "conj"}
    )

    def _extract_entity_mentions(self, doc: Doc) -> list[str]:
        """Extract named entities and proper noun chunks from spaCy doc.

        NER entities (all recognised types) are added first.  The PROPN
        fallback adds proper-noun tokens that NER missed, subject to two
        guards that eliminate the entity-explosion problem:

        1. Token-index coverage: skip tokens already inside any NER span.
           This prevents sub-tokens of multi-word named entities (e.g. "Dr."
           and "Chen" from inside the "Dr. Chen" PERSON span, or "Northgate"
           and "Hospital" from the "Northgate Hospital" ORG span) from
           becoming separate PEF entity placeholders.

        2. Dependency-position filter: only add PROPN tokens that occupy
           subject, object, appositive, or conjunct positions.  Tokens in
           predicative/modifier positions (attr, compound, amod, etc.) are
           excluded — this stops title fragments like "Chief", "Medical",
           "Officer" from "is the Chief Medical Officer" from being treated as
           person-name candidates by the ambiguity gate.
        """
        mentions: list[str] = []

        # Build a set of token indices already covered by any NER span so the
        # PROPN fallback can skip tokens that are fragments of a recognised
        # multi-word entity.
        ner_token_indices: set[int] = set()
        for ent in doc.ents:
            for tok in ent:
                ner_token_indices.add(tok.i)

        # All NER entity types — includes ORG/GPE/LOC so that names like
        # "Emma" that spaCy mis-labels ORG are still captured here and don't
        # fall through to the PROPN loop (which the NER coverage check would
        # then block).
        # For PERSON entities, extend backward to include immediately preceding
        # PROPN compound tokens (titles such as "Dr.", "Mrs.", "Prof.") that
        # spaCy omits from the NER span.  Without this, "Dr. Chen" would be
        # split into entity_mentions=["Chen"] (from NER) and claim subject
        # "Dr. Chen" (from _get_span_text subtree), producing two distinct PEF
        # entities for the same person and triggering a spurious multiple-
        # antecedent count in the ambiguity gate.
        for ent in doc.ents:
            if ent.label_ not in ("PERSON", "ORG", "GPE", "LOC", "NORP", "FAC"):
                continue
            full_text = ent.text
            if ent.label_ == "PERSON":
                prefix: list[str] = []
                i = ent.start - 1
                while i >= 0:
                    t = doc[i]
                    # Only absorb PROPN compound tokens not in any other NER span
                    if t.pos_ == "PROPN" and t.dep_ == "compound" and not t.ent_type_:
                        prefix.insert(0, t.text)
                        ner_token_indices.add(t.i)  # Prevent PROPN fallback from re-adding
                        i -= 1
                    else:
                        break
                if prefix:
                    full_text = " ".join(prefix) + " " + ent.text
            mentions.append(full_text)

        # Proper nouns not caught by NER — likely person names the model missed.
        seen: set[str] = {m.lower() for m in mentions}
        for token in doc:
            if token.pos_ != "PROPN":
                continue
            if token.i in ner_token_indices:
                continue
            if token.dep_ not in self._ENTITY_POSITION_DEPS:
                continue
            text = token.text
            if text.lower() not in seen:
                seen.add(text.lower())
                mentions.append(text)

        return mentions

    def _is_interrogative_sentence(self, sent: Any) -> bool:
        """Return True if this sentence is a question.

        Questions must not contribute claims to PEF state — they ask about
        the world rather than asserting facts about it.  Two heuristics:
        1. Sentence text ends with "?" (orthographic signal).
        2. Root verb has a WH-word (WP/WRB/WDT) as its subject (syntactic).
        """
        if sent.text.strip().endswith("?"):
            return True
        for token in sent:
            if token.dep_ == "ROOT":
                for child in token.children:
                    if child.dep_ in ("nsubj", "nsubjpass") and child.tag_ in ("WP", "WRB", "WDT"):
                        return True
        return False

    def _extract_claims(self, doc: Doc, span: Span) -> list[ExtractedClaim]:
        """Extract subject-verb-object claims from dependency parse.

        Interrogative sentences are skipped: questions do not assert facts
        and must not create PEF state entries.

        Each verb may produce multiple claims: one for a direct/attribute object
        (using the verb lemma as relation) and one per prepositional complement
        (using the preposition text as relation).  This ensures that sentences
        like "Alice is a software engineer at Acme Corp." store both
        Alice IS "software engineer" AND Alice AT "Acme Corp." in PEF.
        """
        claims: list[ExtractedClaim] = []

        # Build set of token indices that belong to interrogative sentences.
        interrogative_indices: set[int] = set()
        for sent in doc.sents:
            if self._is_interrogative_sentence(sent):
                for token in sent:
                    interrogative_indices.add(token.i)

        for token in doc:
            if token.i in interrogative_indices:
                continue
            if token.pos_ not in ("VERB", "AUX"):
                continue
            if token.dep_ == "aux":
                continue

            # Find subject
            subject = self._find_subject(token)
            if subject is None:
                continue

            # Collect all (relation, obj) pairs for this verb.
            base_relation = canonicalize_relation(token.lemma_)
            object_pairs = self._find_objects(token, base_relation)

            # Compute before the object_pairs guard: shared by passive extraction
            # and the regular claim loop.
            negated = _is_negated(token)
            evidence = self._get_clause_text(token, doc)

            # Per-verb temporal span: use the verb's own morphology rather than
            # the document-level span.  LLM responses often start with
            # present-tense preamble ("That's correct.", "I understand that...")
            # which biases detect_span() toward PRESENT even when the main verb
            # is past tense.  Verb-local span is more accurate and prevents
            # spurious TIME_SMEAR flags on correct acknowledgement responses.
            verb_span = self._verb_local_span(token, span)

            # Passive-agent extraction. Purely structural — no NER/PROPN heuristics.
            # "The contract was signed by Meridian Partners." emits a flipped active
            # claim so the agent is grounded in PEF before the LLM response is checked.
            passive_claims = self._extract_passive_agent_claims(
                token, base_relation, span, negated, evidence,
            )
            claims.extend(passive_claims)

            if not object_pairs:
                continue

            for rel, obj in object_pairs:
                claims.append(ExtractedClaim(
                    subject=subject,
                    relation=rel,
                    obj=obj,
                    span=verb_span,
                    negated=negated,
                    evidence=evidence,
                    provenance="user_input",
                    extractor_backend="spacy",
                ))

        return claims

    def _verb_local_span(self, verb: "Token", doc_span: Span) -> Span:
        """Detect the temporal span for a single verb from its own morphology.

        Claims must not inherit a misleading document-level span.  LLM
        responses often start with present-tense preamble ("That's correct.",
        "I understand that...") which pulls detect_span() toward PRESENT even
        when the substantive verb is past tense ("signed", "was hired", etc.).

        Check the verb's own morphological tense, then its auxiliaries, then
        fall back to the document-level span if no signal is found.  This
        applies to both passive and active verbs.
        """
        morph = verb.morph.get("Tense")
        if "Past" in morph:
            return Span.PAST
        if "Pres" in morph:
            return Span.PRESENT
        for child in verb.children:
            if child.dep_ in ("aux", "auxpass"):
                aux_morph = child.morph.get("Tense")
                if "Past" in aux_morph:
                    return Span.PAST
                if "Pres" in aux_morph:
                    return Span.PRESENT
                # Surface-form fallback for common auxiliaries
                if child.text.lower() in {"was", "were", "had"}:
                    return Span.PAST
                if child.text.lower() in {"is", "are", "am", "has", "have"}:
                    return Span.PRESENT
        return doc_span

    def _extract_passive_agent_claims(
        self,
        verb: "Token",
        base_relation: str,
        span: Span,
        negated: bool,
        evidence: str,
    ) -> list[ExtractedClaim]:
        """For passive-voice verbs with explicit by-agents, emit a flipped active claim.

        Three structural requirements -- all must be satisfied:
        1. verb has an nsubjpass child  (structurally passive sentence)
        2. verb has a dep_=="agent" child with a pobj grandchild, OR a dep_=="prep"
           child with text=="by" and a pobj grandchild
           (spaCy en_core_web_sm labels passive "by" as dep_=="agent"; the "prep/by"
            branch covers older model versions for robustness)
        3. Both the passive subject and the agent NP are non-empty after _get_span_text

        Relation consistency: canonicalize_relation(verb.lemma_) runs identically on
        both the passive path (here) and the active equivalent, so PEF storage and
        LLM-response extraction produce identical relation strings for the same verb.
        """
        # Requirement 1: structural passive
        passive_subj_token: Token | None = None
        for child in verb.children:
            if child.dep_ == "nsubjpass":
                passive_subj_token = child
                break
        if passive_subj_token is None:
            return []

        passive_subj = self._get_span_text(passive_subj_token)
        if not passive_subj.strip():
            return []

        # Requirement 2: explicit agent phrase.
        # spaCy labels passive "by X" as dep_=="agent" in en_core_web_sm.
        # The dep_=="prep" / text=="by" branch handles older model versions.
        agent_pobj: Token | None = None
        for child in verb.children:
            if child.dep_ == "agent":
                for gc in child.children:
                    if gc.dep_ == "pobj":
                        agent_pobj = gc
                        break
            elif child.dep_ == "prep" and child.text.lower() == "by":
                for gc in child.children:
                    if gc.dep_ == "pobj":
                        agent_pobj = gc
                        break
            if agent_pobj is not None:
                break
        if agent_pobj is None:
            return []

        # Requirement 3: non-empty agent NP
        agent = self._get_span_text(agent_pobj)
        if not agent.strip():
            return []

        return [ExtractedClaim(
            subject=agent,
            relation=base_relation,
            obj=passive_subj,
            span=self._verb_local_span(verb, span),
            negated=negated,
            evidence=evidence,
            provenance="user_input",
            extractor_backend="spacy",
        )]

    def _find_subject(self, verb: Token) -> str | None:
        """Find the subject of a verb from its dependency children."""
        for child in verb.children:
            if child.dep_ in ("nsubj", "nsubjpass"):
                return self._get_span_text(child)
        # Check head for passives / aux chains
        if verb.dep_ in ("xcomp", "ccomp", "conj"):
            return self._find_subject(verb.head)
        return None

    def _find_objects(self, verb: Token, base_relation: str) -> list[tuple[str, str]]:
        """Find all valid objects for a verb, returning (relation, obj) pairs.

        Direct/attribute/complement objects use the verb lemma's canonical relation
        (with prepositional complements stripped from the head noun text).
        Prepositional objects — whether attached directly to the verb or to the
        attribute noun — use the preposition text as the canonical relation, so
        "works at Acme" → ("AT", "Acme") and "is a software engineer at Acme Corp."
        yields [("IS", "software engineer"), ("AT", "Acme Corp.")].
        """
        results: list[tuple[str, str]] = []
        for child in verb.children:
            if child.dep_ in ("dobj", "attr", "oprd", "acomp"):
                # Paraphrase normalization: "holds/occupies [the] position/title/role/post of X"
                # -> IS X. Three-part structural trigger (all must be true simultaneously):
                #   1. verb lemma in _ROLE_HOLDER_VERBS
                #   2. dobj lemma in _ROLE_NOUNS
                #   3. dobj has a "of" prep child with a pobj
                # When triggered: emit IS X only, skip raw ("HOLD","position") pair.
                # When not triggered (no "of" pobj): fall through to normal processing.
                normalized_role: str | None = None
                if (verb.lemma_.lower() in _ROLE_HOLDER_VERBS
                        and child.lemma_.lower() in _ROLE_NOUNS):
                    for gc in child.children:
                        if gc.dep_ == "prep" and gc.text.lower() == "of":
                            for ggc in gc.children:
                                if ggc.dep_ == "pobj":
                                    normalized_role = self._get_head_noun_text(ggc)
                                    break
                            break

                if normalized_role is not None:
                    results.append(("IS", normalized_role))
                    # Skip raw ("HOLD", "position") and its prep complements.
                else:
                    # Use a clean head-noun text (without prepositional sub-phrases).
                    results.append((base_relation, self._get_head_noun_text(child)))
                    # Also emit a separate claim for each prepositional complement
                    # attached to this noun (e.g. "at Acme Corp." on "engineer").
                    # Guard: only emit when the preposition maps to a known canonical
                    # relation — this prevents "works as a software engineer" from
                    # producing a spurious "Alice AS software engineer" claim.
                    for grandchild in child.children:
                        if grandchild.dep_ == "prep":
                            prep_rel = canonicalize_relation(grandchild.text)
                            if prep_rel in CANONICAL_RELATIONS:
                                for ggchild in grandchild.children:
                                    if ggchild.dep_ == "pobj":
                                        results.append((prep_rel, self._get_head_noun_text(ggchild)))
            elif child.dep_ == "prep":
                # Prepositional phrase attached directly to the verb.
                # Same guard: only canonical-relation prepositions.
                prep_rel = canonicalize_relation(child.text)
                if prep_rel in CANONICAL_RELATIONS:
                    for grandchild in child.children:
                        if grandchild.dep_ == "pobj":
                            # Use head-noun text to strip relative clauses so
                            # "Acme (which is part of Acme Corp.)" → "Acme".
                            results.append((prep_rel, self._get_head_noun_text(grandchild)))
            # Predicative adverbs on copula: "was overseas", "is home", "is abroad".
            # Guard: only accept advmod when the verb is a form of "be" — this
            # prevents manner adverbs ("ran quickly") from becoming claim objects.
            elif child.dep_ == "advmod" and verb.lemma_ == "be":
                results.append((base_relation, child.text))
        return results

    # Dependency relations whose subtrees should be stripped when extracting
    # the head-noun text of a noun phrase. Prepositional phrases are kept as
    # separate claims; relative clauses / adverbial clauses are not claims.
    _EXCL_NOUN_DEPS: frozenset[str] = frozenset({"prep", "relcl", "advcl", "acl"})

    def _get_head_noun_text(self, token: Token) -> str:
        """Get noun phrase text excluding prepositional and clausal sub-phrases.

        For "software engineer at Acme Corp.", returns "software engineer"
        so that the prepositional part can be emitted as a separate claim.
        For "Acme (which is part of Acme Corp.)", returns "Acme" so that the
        relative clause does not bloat the object string.
        """
        excl_indices: set[int] = set()
        for child in token.children:
            if child.dep_ in self._EXCL_NOUN_DEPS:
                for t in child.subtree:
                    excl_indices.add(t.i)
        subtree_tokens = sorted(token.subtree, key=lambda t: t.i)
        meaningful = [
            t for t in subtree_tokens
            if t.i not in excl_indices
            and t.pos_ != "PUNCT"
            and (t.dep_ != "det" or t.pos_ == "PROPN")
        ]
        return " ".join(t.text for t in meaningful) if meaningful else token.text

    def _get_span_text(self, token: Token) -> str:
        """Get the full noun phrase text for a token (including children)."""
        # Use subtree for compound nouns and adjective modifiers
        subtree_tokens = sorted(token.subtree, key=lambda t: t.i)
        # Filter to meaningful tokens (skip determiners for cleaner output)
        meaningful = [
            t for t in subtree_tokens
            if t.dep_ not in ("det",) or t.pos_ == "PROPN"
        ]
        if meaningful:
            return " ".join(t.text for t in meaningful)
        return token.text

    def _get_clause_text(self, verb: Token, doc: Doc) -> str:
        """Get the approximate clause text around a verb."""
        subtree = sorted(verb.subtree, key=lambda t: t.i)
        if subtree:
            start = subtree[0].i
            end = subtree[-1].i + 1
            return doc[start:end].text
        return verb.text

    def _detect_ambiguous_referents(self, doc: Doc, pef: PEFState) -> list[str]:
        """Return pronouns and definite NPs that cannot be uniquely resolved.

        Detects three categories:
        1. Possessive pronouns (dep_==poss) when 2+ person antecedents exist.
        2. Subject/object pronouns (dep_ in nsubj/nsubjpass/dobj/pobj) when
           2+ person antecedents exist.
        3. Definite NPs ("the medication", "that condition") when 2+ PEF
           entities or object literals match the head noun.

        PEF axiom: binding without explicit grounding is not permitted.
        """
        doc_persons: set[str] = {ent.text for ent in doc.ents if ent.label_ == "PERSON"}

        # Include PEF-resident entities as additional antecedents.
        # Heuristic: short (≤2 tokens), capitalized, not org-like → likely a person.
        # Do NOT filter on resolved=True: entity mentions are added as resolved=False
        # (unresolved placeholder) when no claims are extracted about them, but they
        # are still valid pronoun antecedents for ambiguity purposes.
        pef_persons: set[str] = {
            ent.name for ent in pef.entities.values()
            if ent.name not in doc_persons
            and len(ent.name.split()) <= 2
            and ent.name[0].isupper()
            and not _likely_org_name(ent.name)
        }

        all_persons = doc_persons | pef_persons
        has_multiple_persons = len(all_persons) >= 2

        ambiguous: list[str] = []
        seen: set[str] = set()
        for token in doc:
            lower = token.text.lower()

            # 1. Possessive pronouns (original behaviour)
            if lower in _POSSESSIVE_PRONOUNS and token.dep_ == "poss":
                if has_multiple_persons and lower not in seen:
                    seen.add(lower)
                    ambiguous.append(lower)
                continue

            # 2. Subject/object pronouns — same person-antecedent threshold
            if lower in _SUBJECT_OBJECT_PRONOUNS and token.dep_ in (
                "nsubj", "nsubjpass", "dobj", "pobj"
            ):
                if has_multiple_persons and lower not in seen:
                    seen.add(lower)
                    ambiguous.append(lower)
                continue

            # 3. Definite NP: determiner + NOUN/PROPN head
            # "the medication" is ambiguous when 2+ PEF entities or literals
            # match the noun, meaning the referent cannot be uniquely resolved.
            if lower in _DEFINITE_DETERMINERS and token.dep_ == "det":
                head = token.head
                if head.pos_ in ("NOUN", "PROPN"):
                    noun_lower = head.text.lower()
                    matching = [
                        ent.name for ent in pef.entities.values()
                        if noun_lower in ent.name.lower()
                        or any(
                            noun_lower in str(rel.object_literal or "").lower()
                            for rel in pef.get_relationships_for_subject(ent.id)
                        )
                    ]
                    key = f"{lower} {noun_lower}"
                    if len(matching) >= 2 and key not in seen:
                        seen.add(key)
                        ambiguous.append(key)

        return ambiguous

    def _detect_comparative_claims(
        self, doc: Doc, pef: PEFState
    ) -> list[ComparativeAmbiguity]:
        """Detect comparative adjectives whose comparand cannot be uniquely determined.

        Fires when a JJR/RBR token (comparative adjective/adverb) modifies "be"
        and 2+ eligible comparands exist in PEF.

        - 0 candidates: ungrounded assertion — accept silently (no gate).
        - 1 candidate: forced collapse — accept silently (exactly one referent).
        - 2+ candidates: returned for pre-LLM gate to ask one clarifying question.
        """
        ambiguities: list[ComparativeAmbiguity] = []
        for token in doc:
            if token.tag_ not in ("JJR", "RBR"):
                continue
            if token.dep_ not in ("acomp", "advmod"):
                continue
            verb = token.head
            if verb.lemma_ != "be":
                continue

            # Find the subject token
            subj_token = None
            for child in verb.children:
                if child.dep_ in ("nsubj", "nsubjpass"):
                    subj_token = child
                    break
            if subj_token is None:
                continue

            # The noun being compared is the nsubj head ("stick" in "James's stick")
            noun = subj_token.lemma_

            # Identify the possessor, if any ("James" in "James's stick")
            possessor: str | None = None
            for grandchild in subj_token.children:
                if grandchild.dep_ == "poss":
                    possessor = grandchild.text
                    break

            candidates = self._find_comparand_candidates(noun, possessor, pef)
            if len(candidates) >= 2:
                ambiguities.append(ComparativeAmbiguity(
                    adjective=token.text,
                    noun=noun,
                    candidates=candidates,
                ))

        return ambiguities

    def _find_comparand_candidates(
        self, noun: str, possessor: str | None, pef: PEFState
    ) -> list[str]:
        """Find PEF entities that are eligible comparands for a comparative claim.

        If possessor is given (e.g., "James" in "James's stick is bigger"):
            → find other entities that HAS an object containing the noun ("stick").
        If no possessor (e.g., "James is bigger"):
            → find other short, capitalised entities in PEF (likely persons).
        """
        candidates: list[str] = []
        noun_lower = noun.lower()

        if possessor is not None:
            possessor_lower = possessor.lower()
            for entity in pef.entities.values():
                if entity.name.lower() == possessor_lower:
                    continue
                for rel in pef.get_relationships_for_subject(entity.id):
                    if rel.relation == "HAS":
                        obj = str(rel.object_literal or "").lower()
                        if noun_lower in obj:
                            candidates.append(entity.name)
                            break
        else:
            for entity in pef.entities.values():
                if entity.name.lower() == noun_lower:
                    continue
                if len(entity.name.split()) <= 2 and entity.name[0].isupper():
                    candidates.append(entity.name)

        return candidates

    def _resolve_pronouns(
        self, doc: Doc, pef: PEFState
    ) -> dict[str, str]:
        """Attempt pronoun resolution using recency heuristic.

        Returns a dict of pronoun_key -> entity_name.
        """
        candidates: dict[str, str] = {}

        for token in doc:
            if not _is_pronoun(token):
                continue

            key = f"{token.text.lower()}@token_{token.i}"
            resolved = self._resolve_single_pronoun(token.text, pef)
            if resolved:
                candidates[key] = resolved

        return candidates

    def _resolve_single_pronoun(
        self, pronoun: str, pef: PEFState
    ) -> str | None:
        """Resolve a pronoun to the most recently active matching entity.

        Uses gender heuristic for he/she/him/her; recency for others.
        """
        resolved_entities = [
            e for e in pef.entities.values() if e.resolved
        ]
        if not resolved_entities:
            return None

        by_recency = sorted(
            resolved_entities,
            key=lambda e: e.turn_last_active,
            reverse=True,
        )

        if by_recency:
            return by_recency[0].name

        return None
