"""Fact retrieval — semantic search + keyword matching + recency scoring.

Ported from advisor_api memory_retrieval.py (1945 lines → ~250 lines v0).
Only 3 signals: semantic (pgvector cosine), keyword (ILIKE), recency (exp decay).
All advanced features (graph, spreading activation, clusters, query expansion,
emotional trends, context compression) are anti-scope for v0.
"""

import logging
import math
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any

from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import defer

from arandu.config import MemoryConfig
from arandu.models import MemoryFact
from arandu.protocols import EmbeddingProvider

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Result types
# ---------------------------------------------------------------------------
@dataclass
class RetrievalCandidate:
    """A fact candidate with scoring breakdown."""

    fact: Any  # MemoryFact ORM object
    scores: dict[str, float] = field(default_factory=dict)
    final_score: float = 0.0


# ---------------------------------------------------------------------------
# Scoring helpers
# ---------------------------------------------------------------------------
def recency_score(item_time: datetime, half_life_days: float = 14.0) -> float:
    """Exponential decay based on age. Ported from memory_retrieval.py."""
    now = datetime.now(UTC)
    if item_time.tzinfo is None:
        item_time = item_time.replace(tzinfo=UTC)
    age_days = (now - item_time).total_seconds() / 86400
    return math.exp(-math.log(2) * age_days / half_life_days)


def compute_combined_score(
    scores: dict[str, float],
    weights: dict[str, float],
) -> float:
    """Weighted combination of signal scores."""
    total = 0.0
    for key, weight in weights.items():
        total += weight * scores.get(key, 0.0)
    return total


# ---------------------------------------------------------------------------
# Signal 1: Semantic search (pgvector cosine similarity)
# ---------------------------------------------------------------------------
async def semantic_search(
    session: AsyncSession,
    user_id: str,
    query_embedding: list[float],
    config: MemoryConfig,
    limit: int | None = None,
) -> list[RetrievalCandidate]:
    """Search facts by cosine similarity using pgvector.

    Returns candidates sorted by similarity descending.
    Filters: user_id, active facts (valid_to IS NULL), confidence >= min.
    """
    pool_size = (limit or config.topk_facts) * 5

    from sqlalchemy import select

    stmt = (
        select(
            MemoryFact,
            (1 - MemoryFact.embedding_vec.cosine_distance(query_embedding)).label("similarity"),
        )
        .where(
            MemoryFact.user_id == user_id,
            MemoryFact.valid_to.is_(None),
            MemoryFact.confidence >= config.min_confidence,
            MemoryFact.embedding_vec.isnot(None),
        )
        .order_by(MemoryFact.embedding_vec.cosine_distance(query_embedding))
        .limit(pool_size)
        .options(defer(MemoryFact.embedding))
    )

    result = await session.execute(stmt)
    rows = result.all()

    candidates = []
    for fact, similarity in rows:
        sim = float(similarity)
        if sim < config.min_similarity:
            continue
        candidates.append(
            RetrievalCandidate(
                fact=fact,
                scores={"semantic": sim},
            )
        )

    logger.info(
        "semantic_search: user=%s, pool=%d, after_threshold=%d",
        user_id,
        len(rows),
        len(candidates),
    )
    return candidates


# ---------------------------------------------------------------------------
# Signal 2: Keyword search (SQL ILIKE fallback)
# ---------------------------------------------------------------------------
async def keyword_search(
    session: AsyncSession,
    user_id: str,
    query_text: str,
    config: MemoryConfig,
    limit: int = 20,
) -> list[RetrievalCandidate]:
    """Search facts by keyword matching using ILIKE on fact_text.

    Simple but effective fallback for queries with specific terms.
    No external dependencies — pure SQL.
    """
    if not query_text or not query_text.strip():
        return []

    # Extract significant words (>2 chars) for ILIKE matching
    words = [w for w in query_text.strip().split() if len(w) > 2]
    if not words:
        return []

    from sqlalchemy import or_, select

    # Build ILIKE conditions for each word against fact_text
    conditions = []
    for word in words[:5]:  # Cap at 5 words to avoid expensive queries
        pattern = f"%{word}%"
        conditions.append(MemoryFact.fact_text.ilike(pattern))

    stmt = (
        select(MemoryFact)
        .where(
            MemoryFact.user_id == user_id,
            MemoryFact.valid_to.is_(None),
            MemoryFact.confidence >= config.min_confidence,
            or_(*conditions),
        )
        .order_by(MemoryFact.valid_from.desc())
        .limit(limit)
        .options(defer(MemoryFact.embedding))
    )

    result = await session.execute(stmt)
    facts = result.scalars().all()

    candidates = []
    for fact in facts:
        # Score: fraction of query words found in fact_text
        fact_lower = (fact.fact_text or "").lower()
        matches = sum(1 for w in words if w.lower() in fact_lower)
        kw_score = matches / len(words) if words else 0.0
        candidates.append(
            RetrievalCandidate(
                fact=fact,
                scores={"keyword": kw_score},
            )
        )

    logger.info("keyword_search: user=%s, hits=%d", user_id, len(candidates))
    return candidates


# ---------------------------------------------------------------------------
# Merge and rank candidates
# ---------------------------------------------------------------------------
def merge_and_rank(
    semantic_candidates: list[RetrievalCandidate],
    keyword_candidates: list[RetrievalCandidate],
    config: MemoryConfig,
) -> list[RetrievalCandidate]:
    """Merge candidates from semantic and keyword signals, apply recency, and rank.

    Deduplicates by fact ID. Computes combined score using config weights.
    """
    weights = config.score_weights
    half_life = config.recency_half_life_days

    # Index by fact ID for deduplication
    by_id: dict[Any, RetrievalCandidate] = {}

    for c in semantic_candidates:
        by_id[c.fact.id] = c

    for c in keyword_candidates:
        fid = c.fact.id
        if fid in by_id:
            # Merge keyword score into existing candidate
            by_id[fid].scores["keyword"] = c.scores.get("keyword", 0.0)
        else:
            by_id[fid] = c

    # Apply recency + confidence decay to all candidates
    from arandu.read._helpers import compute_effective_confidence

    for candidate in by_id.values():
        candidate.scores.setdefault("semantic", 0.0)
        candidate.scores.setdefault("keyword", 0.0)

        valid_from = candidate.fact.valid_from
        if valid_from:
            candidate.scores["recency"] = recency_score(valid_from, half_life)
        else:
            candidate.scores["recency"] = 0.5

        # importance signal (direct from fact)
        candidate.scores["importance"] = candidate.fact.importance or 0.5

        # Apply confidence decay — older facts get lower effective confidence
        raw_conf = candidate.fact.confidence or 0.5
        eff_conf = compute_effective_confidence(raw_conf, valid_from)
        candidate.scores["confidence"] = eff_conf

        candidate.final_score = compute_combined_score(candidate.scores, weights)

    # Sort by final_score descending
    ranked = sorted(by_id.values(), key=lambda c: c.final_score, reverse=True)
    return ranked


# ---------------------------------------------------------------------------
# Signal 3: Event retrieval (semantic similarity + recency)
# ---------------------------------------------------------------------------
async def retrieve_relevant_events(
    session: AsyncSession,
    user_id: str,
    query_embedding: list[float],
    config: MemoryConfig,
    limit: int | None = None,
) -> list[dict[str, Any]]:
    """Retrieve relevant events by embedding similarity + recency.

    Args:
        session: Database session.
        user_id: User identifier.
        query_embedding: Query embedding vector.
        config: Memory configuration.
        limit: Max events to return.

    Returns:
        List of event dicts with ``date``, ``text``, ``score``.
    """
    from sqlalchemy import select as sa_select

    from arandu.models import MemoryEvent

    max_events = limit or config.topk_events

    stmt = (
        sa_select(
            MemoryEvent,
            (1 - MemoryEvent.embedding_vec.cosine_distance(query_embedding)).label("similarity"),
        )
        .where(
            MemoryEvent.user_id == user_id,
            MemoryEvent.embedding_vec.isnot(None),
        )
        .order_by(MemoryEvent.embedding_vec.cosine_distance(query_embedding))
        .limit(config.event_max_scan)
    )

    result = await session.execute(stmt)
    rows = result.all()

    events: list[dict[str, Any]] = []
    for event, similarity in rows:
        sim = float(similarity)
        if sim < config.min_similarity:
            continue
        rec = recency_score(event.occurred_at, config.recency_half_life_days)
        combined = 0.6 * sim + 0.3 * rec + 0.1 * (event.importance or 0.5)
        events.append(
            {
                "date": str(event.occurred_at.date()) if event.occurred_at else "",
                "text": event.text or "",
                "score": combined,
                "event_id": str(event.id),
            }
        )

    events.sort(key=lambda e: e["score"], reverse=True)
    logger.info(
        "retrieve_relevant_events: user=%s, scanned=%d, returned=%d", user_id, len(rows), min(len(events), max_events)
    )
    return events[:max_events]


# ---------------------------------------------------------------------------
# Pattern signal: boost facts that recur across multiple events
# ---------------------------------------------------------------------------
def compute_pattern_signal(
    candidates: list[RetrievalCandidate],
) -> list[RetrievalCandidate]:
    """Boost facts with high reinforcement counts (pattern signal).

    Facts that have been confirmed/reinforced multiple times get a small
    additive score boost.

    Args:
        candidates: Current ranked candidates.

    Returns:
        Candidates with updated scores (sorted by final_score).
    """
    for c in candidates:
        reinforcement = getattr(c.fact, "reinforcement_count", 0) or 0
        if reinforcement > 0:
            # Bounded pattern boost: up to 0.1 extra
            pattern_boost = min(0.1, reinforcement * 0.02)
            c.scores["pattern"] = pattern_boost
            c.final_score += pattern_boost

    candidates.sort(key=lambda x: x.final_score, reverse=True)
    return candidates


# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
async def retrieve_candidates(
    session: AsyncSession,
    user_id: str,
    query_text: str,
    embeddings: EmbeddingProvider,
    config: MemoryConfig,
) -> list[RetrievalCandidate]:
    """Retrieve and rank fact candidates using semantic + keyword + recency.

    Args:
        session: Database session.
        user_id: User identifier.
        query_text: The query to search for.
        embeddings: Injected embedding provider.
        config: Memory configuration.

    Returns:
        Ranked list of RetrievalCandidate objects.
    """
    # Get query embedding
    query_embedding = await embeddings.embed_one(query_text)

    # Run semantic search (primary signal)
    semantic_candidates: list[RetrievalCandidate] = []
    if query_embedding:
        semantic_candidates = await semantic_search(
            session,
            user_id,
            query_embedding,
            config,
        )
    else:
        logger.warning("retrieve_candidates: embedding failed, skipping semantic search")

    # Run keyword search (complementary signal)
    keyword_candidates = await keyword_search(
        session,
        user_id,
        query_text,
        config,
    )

    # Merge, apply recency, rank
    ranked = merge_and_rank(semantic_candidates, keyword_candidates, config)

    logger.info(
        "retrieve_candidates: user=%s, semantic=%d, keyword=%d, merged=%d",
        user_id,
        len(semantic_candidates),
        len(keyword_candidates),
        len(ranked),
    )
    return ranked
