"""Contradiction detection for GreyMatter knowledge entries.

Detects when a new knowledge entry contradicts an existing one,
enabling automatic staleness flagging to keep the knowledge graph
consistent.

Usage:
    detector = ContradictionDetector()
    conflicts = detector.find_conflicts(new_entry, existing_entries)
"""
from __future__ import annotations

import re
from typing import Any


# Signals that the new entry explicitly replaces/negates something
_REPLACEMENT_SIGNALS = re.compile(
    r"\b(?:"
    r"no longer|not anymore|no more|"
    r"replaced|deprecated|removed|obsolete|retired|"
    r"migrated from|moved to|switched to|changed to|instead of|"
    r"supersedes|superseded|"
    r"was wrong|incorrect|was incorrect|"
    r"previously|used to|formerly|old approach"
    r")\b",
    re.IGNORECASE,
)

# Factual claim patterns for predicate comparison
_CLAIM_PATTERNS = [
    (re.compile(r"(?:runs?|running|deployed|hosted)\s+on\s+([\w.-]+(?:\s+[\w.-]+)?)", re.I), "runs on"),
    (re.compile(r"port\s+(\d+)", re.I), "port"),
    (re.compile(r"(?:written|implemented|built)\s+in\s+([\w#+]+)", re.I), "written in"),
    (re.compile(r"(?:uses?|using)\s+([\w.-]+)", re.I), "uses"),
]

# Stopwords for keyword extraction
_STOPWORDS = frozenset({
    "a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
    "have", "has", "had", "do", "does", "did", "will", "would", "could",
    "should", "may", "might", "shall", "can", "need", "dare", "ought",
    "to", "of", "in", "for", "on", "with", "at", "by", "from", "as",
    "into", "through", "during", "before", "after", "above", "below",
    "between", "out", "off", "over", "under", "again", "further",
    "then", "once", "here", "there", "when", "where", "why", "how",
    "all", "each", "every", "both", "few", "more", "most", "other",
    "some", "such", "nor", "not", "only", "own", "same", "so",
    "than", "too", "very", "just", "but", "and", "or", "if", "because",
    "this", "that", "these", "those", "it", "its",
})


class ContradictionDetector:
    """Detects when a new knowledge entry contradicts existing entries.

    Uses multiple heuristics ordered by reliability:
      1. Explicit negation/replacement signals in new entry text
      2. Correction-type entries with high keyword overlap
      3. Same subject with different factual predicates
    """

    def find_conflicts(
        self, new_entry: dict, existing_entries: list[dict],
    ) -> list[dict[str, Any]]:
        """Find existing entries that the new entry likely contradicts.

        Args:
            new_entry: The newly created entry dict.
            existing_entries: Active entries to check against.

        Returns:
            List of conflict dicts, each with old_entry_id,
            old_entry_title, reason, and confidence (0.0–1.0).
        """
        conflicts: list[dict[str, Any]] = []
        new_text = _full_text(new_entry)
        new_keywords = _extract_keywords(new_text)

        if len(new_keywords) < 3:
            return conflicts

        for existing in existing_entries:
            if existing.get("id") == new_entry.get("id"):
                continue
            result = _check_contradiction(new_entry, existing, new_text, new_keywords)
            if result:
                conflicts.append(result)

        return conflicts

    def find_all_conflicts(self, entries: list[dict]) -> list[dict[str, Any]]:
        """Batch scan: find all contradicting pairs among active entries.

        Groups entries by domain for efficiency.

        Args:
            entries: Active entry dicts to compare pairwise.

        Returns:
            List of conflict dicts with entry_a_id, entry_b_id,
            entry_a_title, entry_b_title, domain, reason, confidence.
        """
        conflicts: list[dict[str, Any]] = []
        seen: set[tuple[str, str]] = set()

        by_domain: dict[str | None, list[dict]] = {}
        for e in entries:
            by_domain.setdefault(e.get("domain"), []).append(e)

        for domain, group in by_domain.items():
            for i, a in enumerate(group):
                a_text = _full_text(a)
                a_kw = _extract_keywords(a_text)
                if len(a_kw) < 3:
                    continue
                for j in range(i + 1, len(group)):
                    b = group[j]
                    pair = (min(a["id"], b["id"]), max(a["id"], b["id"]))
                    if pair in seen:
                        continue
                    result = _check_contradiction(a, b, a_text, a_kw)
                    if result:
                        seen.add(pair)
                        conflicts.append({
                            "entry_a_id": a["id"],
                            "entry_a_title": a["title"],
                            "entry_b_id": b["id"],
                            "entry_b_title": b["title"],
                            "domain": domain,
                            "reason": result["reason"],
                            "confidence": result["confidence"],
                        })

        return conflicts


# ---------------------------------------------------------------------------
# Internal helpers (module-level for testability)
# ---------------------------------------------------------------------------

def _full_text(entry: dict) -> str:
    """Combine title and content into searchable lowercase text."""
    return f"{entry.get('title', '')} {entry.get('content', '')}".lower()


def _extract_keywords(text: str) -> set[str]:
    """Extract significant keywords from text, filtering stopwords."""
    words = re.findall(r"[a-z0-9][\w.-]*", text.lower())
    return {w for w in words if w not in _STOPWORDS and len(w) >= 3}


def _check_contradiction(
    new_entry: dict,
    old_entry: dict,
    new_text: str,
    new_keywords: set[str],
) -> dict[str, Any] | None:
    """Return a conflict dict if *new_entry* contradicts *old_entry*."""
    old_text = _full_text(old_entry)
    old_keywords = _extract_keywords(old_text)

    if len(old_keywords) < 3:
        return None

    # Domain gate: must share a domain or one must be None (cross-cutting)
    new_domain = new_entry.get("domain")
    old_domain = old_entry.get("domain")
    if new_domain and old_domain and new_domain != old_domain:
        return None

    overlap = new_keywords & old_keywords
    if not overlap:
        return None
    overlap_ratio = len(overlap) / min(len(new_keywords), len(old_keywords))
    if overlap_ratio < 0.2:
        return None

    # Heuristic 1 — explicit replacement / negation signals
    if overlap_ratio >= 0.25:
        reason = _check_explicit_negation(new_text, overlap)
        if reason:
            conf = min(0.9, 0.6 + overlap_ratio * 0.3)
            return _build(old_entry, reason, conf)

    # Heuristic 2 — correction type with keyword overlap
    if new_entry.get("entry_type") == "correction" and overlap_ratio >= 0.3:
        return _build(
            old_entry,
            "new correction entry shares significant keywords with existing entry",
            min(0.85, 0.5 + overlap_ratio * 0.35),
        )

    # Heuristic 3 — same subject, different factual predicate
    reason = _check_different_predicates(new_text, old_text, overlap_ratio)
    if reason:
        return _build(old_entry, reason, min(0.8, 0.5 + overlap_ratio * 0.3))

    return None


def _build(old_entry: dict, reason: str, confidence: float) -> dict[str, Any]:
    return {
        "old_entry_id": old_entry["id"],
        "old_entry_title": old_entry["title"],
        "reason": reason,
        "confidence": round(confidence, 2),
    }


def _check_explicit_negation(new_text: str, overlap: set[str]) -> str | None:
    """Detect replacement signals in new_text referencing shared keywords."""
    match = _REPLACEMENT_SIGNALS.search(new_text)
    if not match or len(overlap) < 2:
        return None
    signal = match.group(0)
    kw_sample = ", ".join(sorted(overlap)[:5])
    return f"new entry contains '{signal}' with shared keywords: {kw_sample}"


def _check_different_predicates(
    new_text: str, old_text: str, overlap_ratio: float,
) -> str | None:
    """Detect entries that share a subject but make different factual claims."""
    if overlap_ratio < 0.3:
        return None
    for pattern, claim_type in _CLAIM_PATTERNS:
        new_vals = {m.lower().strip() for m in pattern.findall(new_text)}
        old_vals = {m.lower().strip() for m in pattern.findall(old_text)}
        if new_vals and old_vals and not (new_vals & old_vals):
            return (
                f"different '{claim_type}' claims: "
                f"new='{', '.join(sorted(new_vals)[:2])}', "
                f"old='{', '.join(sorted(old_vals)[:2])}'"
            )
    return None
