# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Licensed under the MIT License - see LICENSE file for details

"""
Memory Consolidation Pipeline v2 — Mem0-style fact extraction and classification.

Extracts facts from conversation exchanges, classifies each against
existing memories as ADD / UPDATE / DELETE / NOOP, and applies the
appropriate mutation to the MemoryTree.

Design:
- Runs as a daemon thread at session end (non-blocking)
- Pure heuristic extraction (no LLM call) for speed
- Each extracted fact is compared against existing memories by
  content similarity (title/body overlap)
- Configurable confidence threshold for auto-application

Usage:
    from familiar.core.memory_consolidation_v2 import consolidate_exchange

    consolidate_exchange(
        user_message="My name is George",
        assistant_response="Nice to meet you, George!",
        user_id="default",
        conversation_id="conv-123",
    )
"""

import logging
import re
import threading
from dataclasses import dataclass
from enum import Enum
from typing import Optional

logger = logging.getLogger(__name__)


class ConsolidationAction(str, Enum):
    """Classification for a fact against existing memories."""

    ADD = "add"  # New fact, no existing match
    UPDATE = "update"  # Existing memory needs updating
    DELETE = "delete"  # Existing memory contradicted
    NOOP = "noop"  # Already known, no change needed


@dataclass
class ExtractedFact:
    """A fact extracted from a conversation exchange."""

    title: str
    body: str
    tags: list[str]
    importance: float = 0.5
    source: str = ""


@dataclass
class ConsolidationResult:
    """Result of classifying a fact against existing memories."""

    fact: ExtractedFact
    action: ConsolidationAction
    existing_id: Optional[str] = None  # ID of matched existing memory
    confidence: float = 0.0
    reason: str = ""


# ── Extraction Patterns ──

# Patterns that indicate factual statements worth extracting
_FACT_PATTERNS = [
    # "My X is Y" / "I am X" / "I like X"
    re.compile(
        r"(?:my|i)\s+(?:name|job|role|title|email|phone|location|city|"
        r"favorite|preferred|preference)\s+(?:is|are)\s+(.+)",
        re.IGNORECASE,
    ),
    re.compile(r"i\s+(?:am|'m)\s+(?:a\s+)?(.+?)(?:\.|,|$)", re.IGNORECASE),
    re.compile(r"i\s+(?:like|love|prefer|enjoy|hate|dislike)\s+(.+?)(?:\.|,|$)", re.IGNORECASE),
    # "Call me X" / "My name is X"
    re.compile(r"(?:call me|my name is|i go by)\s+(.+?)(?:\.|,|$)", re.IGNORECASE),
    # "I work at/for/with X"
    re.compile(r"i\s+work\s+(?:at|for|with|in)\s+(.+?)(?:\.|,|$)", re.IGNORECASE),
    # "I live in X"
    re.compile(r"i\s+live\s+in\s+(.+?)(?:\.|,|$)", re.IGNORECASE),
    # Allergies / dietary
    re.compile(
        r"i(?:'m|\s+am)\s+(?:allergic to|intolerant to|vegetarian|vegan|gluten.free)",
        re.IGNORECASE,
    ),
    # Corrections: "Actually, X" / "No, it's X"
    re.compile(r"(?:actually|no,?\s+it'?s?)\s+(.+?)(?:\.|,|$)", re.IGNORECASE),
]

# Category inference from content
_CATEGORY_PATTERNS = {
    "user_info": re.compile(
        r"\b(?:name|age|birthday|location|city|country|email|phone)\b", re.IGNORECASE
    ),
    "preference": re.compile(
        r"\b(?:like|love|prefer|enjoy|favorite|hate|dislike)\b", re.IGNORECASE
    ),
    "professional": re.compile(
        r"\b(?:work|job|role|company|team|project|career|colleague)\b", re.IGNORECASE
    ),
    "dietary": re.compile(
        r"\b(?:allergic|allergy|vegetarian|vegan|gluten|dairy|diet|food)\b", re.IGNORECASE
    ),
    "health": re.compile(r"\b(?:medication|condition|doctor|health|symptoms)\b", re.IGNORECASE),
}


def extract_facts(
    user_message: str,
    assistant_response: str,
    conversation_id: str = "",
) -> list[ExtractedFact]:
    """Extract factual statements from a conversation exchange.

    Uses heuristic pattern matching — no LLM call required.
    Returns a list of ExtractedFact objects.
    """
    facts: list[ExtractedFact] = []
    source = f"conversation:{conversation_id}" if conversation_id else "auto"

    # Check user message for self-disclosures
    for pattern in _FACT_PATTERNS:
        match = pattern.search(user_message)
        if match:
            raw = match.group(0).strip().rstrip(".!,")
            if len(raw) < 5 or len(raw) > 200:
                continue

            title = _infer_title(raw)
            tags = _infer_tags(raw)
            importance = _infer_importance(tags)

            facts.append(
                ExtractedFact(
                    title=title,
                    body=raw,
                    tags=tags,
                    importance=importance,
                    source=source,
                )
            )

    # Deduplicate by title similarity
    seen_titles: set[str] = set()
    unique_facts: list[ExtractedFact] = []
    for fact in facts:
        key = fact.title.lower().strip()
        if key not in seen_titles:
            seen_titles.add(key)
            unique_facts.append(fact)

    return unique_facts


def _infer_title(raw: str) -> str:
    """Infer a short title from a factual statement."""
    lower = raw.lower()
    if "call me" in lower or "go by" in lower:
        return "User Name"
    if "name" in lower and "is" in lower:
        return "User Name"
    if "work" in lower and any(w in lower for w in ("at", "for", "with", "in")):
        return "User Workplace"
    if "live in" in lower:
        return "User Location"
    if any(w in lower for w in ("like", "love", "prefer", "enjoy")):
        return "User Preference"
    if any(w in lower for w in ("hate", "dislike")):
        return "User Dislike"
    if "allergic" in lower or "intolerant" in lower:
        return "User Allergy"
    if any(w in lower for w in ("vegetarian", "vegan", "gluten")):
        return "User Dietary Restriction"
    if "i am" in lower or "i'm" in lower:
        return "User Self-Description"

    # Fallback: first few words
    words = raw.split()[:4]
    return " ".join(words).title()


def _infer_tags(raw: str) -> list[str]:
    """Infer tags from content."""
    tags = ["auto-consolidated"]
    for category, pattern in _CATEGORY_PATTERNS.items():
        if pattern.search(raw):
            tags.append(category)
    return tags


def _infer_importance(tags: list[str]) -> float:
    """Infer importance score from tags."""
    if "user_info" in tags:
        return 0.8
    if "health" in tags or "dietary" in tags:
        return 0.7
    if "preference" in tags:
        return 0.6
    return 0.5


def classify_fact(
    fact: ExtractedFact,
    existing_memories: list,
) -> ConsolidationResult:
    """Classify a fact against existing memories as ADD/UPDATE/DELETE/NOOP.

    Uses title and body similarity to find matches.
    """
    if not existing_memories:
        return ConsolidationResult(
            fact=fact,
            action=ConsolidationAction.ADD,
            confidence=0.8,
            reason="No existing match found",
        )

    best_match = None
    best_score = 0.0

    for entry in existing_memories:
        score = _similarity_score(fact, entry)
        if score > best_score:
            best_score = score
            best_match = entry

    if best_match is None or best_score < 0.3:
        return ConsolidationResult(
            fact=fact,
            action=ConsolidationAction.ADD,
            confidence=0.8,
            reason="No similar existing memory",
        )

    # High similarity — check if content differs
    if best_score > 0.8:
        return ConsolidationResult(
            fact=fact,
            action=ConsolidationAction.NOOP,
            existing_id=best_match.memory_id,
            confidence=best_score,
            reason="Already known",
        )

    if best_score > 0.5:
        body_sim = _text_overlap(fact.body.lower(), best_match.body.lower())
        if body_sim < 0.5:
            return ConsolidationResult(
                fact=fact,
                action=ConsolidationAction.UPDATE,
                existing_id=best_match.memory_id,
                confidence=best_score,
                reason="Same topic, updated content",
            )
        return ConsolidationResult(
            fact=fact,
            action=ConsolidationAction.NOOP,
            existing_id=best_match.memory_id,
            confidence=best_score,
            reason="Sufficiently similar content",
        )

    return ConsolidationResult(
        fact=fact,
        action=ConsolidationAction.ADD,
        confidence=0.6,
        reason="Different enough to be new",
    )


def _similarity_score(fact: ExtractedFact, entry) -> float:
    """Compute similarity between a fact and an existing memory entry."""
    title_sim = _text_overlap(fact.title.lower(), entry.title.lower())
    body_sim = _text_overlap(fact.body.lower(), entry.body.lower())
    return title_sim * 0.6 + body_sim * 0.4


def _text_overlap(a: str, b: str) -> float:
    """Word-level Jaccard similarity between two strings."""
    words_a = set(a.split())
    words_b = set(b.split())
    if not words_a or not words_b:
        return 0.0
    intersection = words_a & words_b
    union = words_a | words_b
    return len(intersection) / len(union)


def apply_consolidation(
    results: list[ConsolidationResult],
    user_id: str = "default",
    confidence_threshold: float = 0.5,
) -> dict:
    """Apply consolidation results to the MemoryTree.

    Returns stats dict with counts per action type.
    """
    from familiar.core.memory_tree import get_memory_tree

    tree = get_memory_tree()
    stats = {"add": 0, "update": 0, "delete": 0, "noop": 0, "skipped": 0}

    for result in results:
        if result.confidence < confidence_threshold:
            stats["skipped"] += 1
            continue

        try:
            if result.action == ConsolidationAction.ADD:
                tree.store(
                    title=result.fact.title,
                    body=result.fact.body,
                    tier="working",
                    tags=result.fact.tags,
                    source=result.fact.source,
                    importance=result.fact.importance,
                    user_id=user_id,
                )
                stats["add"] += 1

            elif result.action == ConsolidationAction.UPDATE:
                if result.existing_id:
                    tree.update_fact(
                        old_id=result.existing_id,
                        new_body=result.fact.body,
                        reason="consolidation: updated content",
                        tags=result.fact.tags,
                        importance=result.fact.importance,
                    )
                    stats["update"] += 1

            elif result.action == ConsolidationAction.DELETE:
                if result.existing_id:
                    tree.invalidate(
                        result.existing_id,
                        reason=result.reason or "consolidation: contradicted",
                    )
                    stats["delete"] += 1

            elif result.action == ConsolidationAction.NOOP:
                stats["noop"] += 1

        except Exception as e:
            logger.warning("Consolidation apply failed for '%s': %s", result.fact.title, e)
            stats["skipped"] += 1

    return stats


def consolidate_exchange(
    user_message: str,
    assistant_response: str,
    user_id: str = "default",
    conversation_id: str = "",
    confidence_threshold: float = 0.5,
) -> dict:
    """Full consolidation pipeline: extract → classify → apply.

    Runs synchronously. For async use, wrap in a daemon thread
    (see auto_consolidate_async).

    Returns stats dict.
    """
    from familiar.core.memory_tree import get_memory_tree

    # Step 1: Extract facts from the exchange
    facts = extract_facts(user_message, assistant_response, conversation_id)
    if not facts:
        return {"add": 0, "update": 0, "delete": 0, "noop": 0, "skipped": 0}

    # Step 2: Classify each fact against existing memories
    tree = get_memory_tree()
    results: list[ConsolidationResult] = []

    for fact in facts:
        existing = tree.search(
            fact.title,
            tier="working",
            user_id=user_id,
            limit=5,
        )
        result = classify_fact(fact, existing)
        results.append(result)

    # Step 3: Apply
    stats = apply_consolidation(results, user_id, confidence_threshold)

    logger.debug(
        "Consolidation: %d add, %d update, %d delete, %d noop, %d skipped",
        stats["add"],
        stats["update"],
        stats["delete"],
        stats["noop"],
        stats["skipped"],
    )
    return stats


def auto_consolidate_async(
    user_message: str,
    assistant_response: str,
    user_id: str = "default",
    conversation_id: str = "",
) -> None:
    """Run consolidation in a daemon thread (non-blocking).

    Follows the same pattern as markdown_memory.auto_curate_check().
    """

    def _do_consolidate():
        try:
            # Gate: response must be substantial
            if len(assistant_response) < 100:
                return
            # Gate: user message must have substance
            if len(user_message) < 10:
                return

            consolidate_exchange(
                user_message=user_message,
                assistant_response=assistant_response,
                user_id=user_id,
                conversation_id=conversation_id,
            )
        except Exception:
            logger.debug("Auto-consolidation failed", exc_info=True)

    t = threading.Thread(target=_do_consolidate, daemon=True)
    t.start()
