"""
cortex/extraction/tautology_filter.py — Catch self-evident, zero-information claims.

Implements a 4-rule filter that detects tautological claims the LLM sometimes
generates despite prompt instructions.  Runs post-LLM, after predicate_filter
and before filter_unknown_subjects in the extraction pipeline.

Rules:
    1. Reflexive — subject/object refer to the same entity (via alias groups)
    2. Context tautology — always-true-given-system-exists (user↔agent)
    3. Identity tautology — self-evident role assertions (Eva is agent)
    4. Low-information — empty/trivial object values, single system-entity names

Design doc: docs/research/research-tautology-filter-2026-03-19.md
"""
from __future__ import annotations

import logging
import re
from typing import Any, List

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Rule 1: System Entity Aliases
# ---------------------------------------------------------------------------
# Canonical sets of names that refer to the same conceptual entity.
AGENT_ALIASES: set[str] = {"eva", "agent", "assistant", "ai", "companion", "bot"}
USER_ALIASES: set[str] = {"user", "andrew", "human", "owner"}
SYSTEM_ALIASES: set[str] = {"system", "cortex", "evaos", "platform"}

# All system entities combined (for context-tautology object matching).
_ALL_SYSTEM_ENTITIES: set[str] = AGENT_ALIASES | USER_ALIASES | SYSTEM_ALIASES


def _normalize(s: str) -> str:
    """Lowercase, strip, collapse whitespace."""
    return re.sub(r"\s+", " ", s.strip().lower())


# ---------------------------------------------------------------------------
# Rule 2: Context-Tautology Predicates
# ---------------------------------------------------------------------------
# Predicates that, when connecting system entities (user↔agent), are ALWAYS
# true given the system exists.  They carry zero information.
CONTEXT_TAUTOLOGY_PREDICATES: set[str] = {
    "interacts_with",
    "has_agent",
    "uses",
    "serves",
    "works_with",
    "communicates_with",
    "talks_to",
    "has_relationship",
    "assists",
    "helps",
    "supports",
    "responds_to",
    "knows",
    "is_connected_to",
    "has_access_to",
}

# ---------------------------------------------------------------------------
# Rule 3: Identity-Tautology Object Values
# ---------------------------------------------------------------------------
# When subject is an agent alias and predicate is "is"/"is_a"/"has_role",
# these objects make the claim self-evident.
AGENT_IDENTITY_OBJECTS: set[str] = {
    "agent",
    "assistant",
    "ai",
    "companion",
    "bot",
    "ai assistant",
    "ai companion",
    "ai agent",
    "virtual assistant",
}

USER_IDENTITY_OBJECTS: set[str] = {"human", "person", "user", "owner"}

# ---------------------------------------------------------------------------
# Rule 4: Low-Information Thresholds
# ---------------------------------------------------------------------------
MIN_OBJECT_VALUE_LENGTH = 3  # Shorter objects are noise.
MAX_OBJECT_WORD_COUNT_FOR_REFLEXIVE_CHECK = 4

# ---------------------------------------------------------------------------
# Synonym Predicate Groups (for future cross-predicate dedup)
# ---------------------------------------------------------------------------
SYNONYM_PREDICATE_GROUPS: list[set[str]] = [
    {"uses", "interacts_with", "works_with", "utilizes"},
    {"likes", "enjoys", "appreciates", "is_fond_of"},
    {"dislikes", "avoids", "hates", "is_against"},
    {"knows", "understands", "is_aware_of", "is_familiar_with"},
    {"has", "owns", "possesses"},
    {"is", "is_a", "has_role", "has_identity"},
    {"lives_in", "resides_in", "is_located_in", "based_in"},
    {"works_at", "employed_at", "works_for"},
    {"plans", "plans_to", "intends_to", "aims_to"},
    {"decided", "chose", "selected", "picked"},
]

# Pre-build lookup: predicate → canonical (alphabetically first in group).
_PREDICATE_CANONICAL: dict[str, str] = {}
for _grp in SYNONYM_PREDICATE_GROUPS:
    _canonical = sorted(_grp)[0]
    for _p in _grp:
        _PREDICATE_CANONICAL[_p] = _canonical


# ===================================================================
# Helper
# ===================================================================

def _get(obj: Any, key: str, default: str = "") -> Any:
    """Get a field from a dict, dataclass, or Pydantic model."""
    if isinstance(obj, dict):
        return obj.get(key, default)
    return getattr(obj, key, default)


# ===================================================================
# Individual rule functions
# ===================================================================

def _is_reflexive(subject: str, object_value: str, agent_aliases: set[str]) -> bool:
    """Rule 1: Subject and object reference the same entity.

    Catches:
    - Literal identity: ``Eva is Eva``
    - Same alias group: ``agent is assistant``, ``user is human``
    - Subject word is entire object: ``user has_workspace workspace``
    """
    ns = _normalize(subject)
    no = _normalize(object_value)

    # Literal identity
    if ns == no:
        return True

    # Same alias group
    for group in (agent_aliases, USER_ALIASES, SYSTEM_ALIASES):
        if ns in group and no in group:
            return True

    # Subject word appears in a short object — but only flag as reflexive
    # if the object has ≤2 words AND removing the subject word leaves nothing
    # meaningful.  This prevents false positives like "Eva experienced desire"
    # where "eva" appears in subject but "desire" is meaningful content.
    obj_words = no.split()
    if len(obj_words) <= 2:
        remaining = [w for w in obj_words if w != ns]
        if not remaining:
            return True

    return False


def _is_context_tautology(
    subject: str,
    predicate: str,
    object_value: str,
    agent_aliases: set[str],
) -> bool:
    """Rule 2: Facts always true given the system exists.

    Catches user↔agent relationships with context-tautology predicates.
    """
    ns = _normalize(subject)
    np = _normalize(predicate)
    no = _normalize(object_value)

    if np not in CONTEXT_TAUTOLOGY_PREDICATES:
        return False

    all_system = agent_aliases | USER_ALIASES | SYSTEM_ALIASES
    subj_is_system = any(ns in grp for grp in (agent_aliases, USER_ALIASES, SYSTEM_ALIASES))

    # Object may embed a system entity in longer text
    obj_words = set(no.split())
    obj_is_system = bool(obj_words & all_system)

    return subj_is_system and obj_is_system


def _is_identity_tautology(
    subject: str,
    predicate: str,
    object_value: str,
    agent_aliases: set[str],
) -> bool:
    """Rule 3: Self-evident identity assertions.

    Catches ``Eva is agent``, ``user is human``, etc.
    """
    ns = _normalize(subject)
    np = _normalize(predicate)
    no = _normalize(object_value)

    if np not in {"is", "is_a", "has_role", "has_identity"}:
        return False

    if ns in agent_aliases and no in AGENT_IDENTITY_OBJECTS:
        return True

    if ns in USER_ALIASES and no in USER_IDENTITY_OBJECTS:
        return True

    return False


def _is_low_information(
    subject: str,
    predicate: str,
    object_value: str,
    agent_aliases: set[str],
) -> bool:
    """Rule 4: Object value carries no meaningful information.

    Catches empty objects and single system-entity-name objects when the
    subject is also a system entity.
    """
    no = _normalize(object_value)

    # Empty or near-empty
    if len(no) < MIN_OBJECT_VALUE_LENGTH:
        return True

    # Object is just a system entity name and subject is also a system entity
    all_system = agent_aliases | USER_ALIASES | SYSTEM_ALIASES
    if no in all_system and len(no.split()) == 1:
        ns = _normalize(subject)
        if ns in all_system:
            return True

    return False


# ===================================================================
# Canonical predicate lookup (for future synonym dedup)
# ===================================================================

def get_canonical_predicate(predicate: str) -> str:
    """Return the canonical (alphabetically first) predicate for a synonym group.

    Returns the original predicate unchanged if it doesn't belong to any group.
    """
    np = _normalize(predicate)
    return _PREDICATE_CANONICAL.get(np, predicate)


# ===================================================================
# Main entry point
# ===================================================================

def filter_tautologies(claims: list, agent_name: str = "eva") -> list:
    """Remove tautological and zero-information claims.

    Args:
        claims: List of claim dicts or ClaimExtraction dataclass/Pydantic objects.
        agent_name: The agent's display name (added to AGENT_ALIASES dynamically).

    Returns:
        Filtered list with tautologies removed.
    """
    # Build dynamic alias set with the configured agent name
    dynamic_agent_aliases = AGENT_ALIASES | {_normalize(agent_name)} if agent_name else AGENT_ALIASES

    filtered: List[Any] = []
    removed = 0

    for claim in claims:
        subj = str(_get(claim, "subject", "") or "").strip()
        pred = str(_get(claim, "predicate", "") or "").strip()
        obj = str(_get(claim, "object_value", "") or "").strip()

        if _is_reflexive(subj, obj, dynamic_agent_aliases):
            logger.info("Tautology filtered (reflexive): %s %s %s", subj, pred, obj)
            removed += 1
            continue

        if _is_context_tautology(subj, pred, obj, dynamic_agent_aliases):
            logger.info("Tautology filtered (context): %s %s %s", subj, pred, obj)
            removed += 1
            continue

        if _is_identity_tautology(subj, pred, obj, dynamic_agent_aliases):
            logger.info("Tautology filtered (identity): %s %s %s", subj, pred, obj)
            removed += 1
            continue

        if _is_low_information(subj, pred, obj, dynamic_agent_aliases):
            logger.info("Tautology filtered (low-info): %s %s %s", subj, pred, obj)
            removed += 1
            continue

        filtered.append(claim)

    if removed:
        logger.info("Tautology filter removed %d/%d claims", removed, len(claims))

    return filtered
