"""Consolidation engine — pattern detection and meta-observations.

Hierarchy:
  L2 Periodic — ``run_consolidation()`` detects patterns from events/facts.
  L3 Profile  — ``run_profile_consolidation()`` refreshes entity summaries via LLM.
"""

from __future__ import annotations

import asyncio
import json
import logging
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any

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

from arandu.config import MemoryConfig
from arandu.constants import VALID_EMOTIONS, VALID_ENERGIES, VALID_OBSERVATION_TYPES
from arandu.models import (
    MemoryEntity,
    MemoryEvent,
    MemoryFact,
    MemoryMetaObservation,
    SessionObservation,
)
from arandu.protocols import EmbeddingProvider, LLMProvider

logger = logging.getLogger(__name__)

__all__ = [
    "ConsolidationResult",
    "run_consolidation",
    "run_profile_consolidation",
    "tag_event_emotion",
]

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

MAX_EMOTION_BATCH = 20
DUPLICATE_SIMILARITY_THRESHOLD = 0.82
MAX_OBSERVATIONS_PER_RUN = 5
MAX_ENTITIES_PER_RUN = 20
ENTITY_SUMMARY_TIMEOUT_SEC = 5.0


# ---------------------------------------------------------------------------
# Result dataclass
# ---------------------------------------------------------------------------


@dataclass
class ConsolidationResult:
    """Result of L2/L3 consolidation.

    Attributes:
        events_processed: Number of events analyzed.
        observations_created: New meta-observations created.
        observations_reinforced: Existing observations reinforced.
        skipped: Whether consolidation was skipped.
        skip_reason: Reason for skipping, if any.
    """

    events_processed: int = 0
    observations_created: int = 0
    observations_reinforced: int = 0
    skipped: bool = False
    skip_reason: str | None = None


# ---------------------------------------------------------------------------
# Cosine similarity helper
# ---------------------------------------------------------------------------


def _cosine_similarity(a: list[float], b: list[float]) -> float:
    """Cosine similarity between two vectors."""
    if not a or not b or len(a) != len(b):
        return 0.0
    dot = sum(x * y for x, y in zip(a, b))
    norm_a = sum(x * x for x in a) ** 0.5
    norm_b = sum(x * x for x in b) ** 0.5
    if norm_a == 0 or norm_b == 0:
        return 0.0
    return float(dot / (norm_a * norm_b))


# ---------------------------------------------------------------------------
# Emotion tagging
# ---------------------------------------------------------------------------


async def tag_event_emotion(
    event_text: str,
    llm: LLMProvider,
) -> dict[str, Any] | None:
    """Infer emotion, intensity, and energy from event text via LLM.

    Args:
        event_text: Text to analyze.
        llm: Injected LLM provider.

    Returns:
        Dict with ``emotion``, ``intensity``, ``energy`` keys, or None on failure.
    """
    if not event_text or not event_text.strip():
        return None
    emotions_str = "|".join(sorted(VALID_EMOTIONS))
    energies_str = "|".join(sorted(VALID_ENERGIES))
    system_prompt = (
        f"Analyze the emotional tone of the message. Return ONLY JSON: "
        f'{{"emotion": "<{emotions_str}>", '
        f'"intensity": <0.0-1.0>, "energy": "<{energies_str}>"}}. No other text.'
    )
    user_content = event_text.strip()[:500]
    messages = [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": user_content},
    ]
    try:
        raw = await llm.complete(messages)
        if not raw or not isinstance(raw, str):
            return None
        raw = raw.strip()
        if raw.startswith("```"):
            raw = raw.split("\n", 1)[-1] if "\n" in raw else raw[3:]
        if raw.endswith("```"):
            raw = raw.rsplit("```", 1)[0].strip()
        data = json.loads(raw)
        if not isinstance(data, dict):
            return None
        emotion = data.get("emotion")
        intensity = data.get("intensity")
        energy = data.get("energy")
        if emotion not in VALID_EMOTIONS or energy not in VALID_ENERGIES:
            return None
        try:
            intensity_f = float(intensity)  # type: ignore[arg-type]
        except (TypeError, ValueError):
            return None
        if not (0.0 <= intensity_f <= 1.0):
            return None
        return {"emotion": emotion, "intensity": intensity_f, "energy": energy}
    except Exception as e:
        logger.debug("tag_event_emotion parse failed: %s", e)
        return None


# ---------------------------------------------------------------------------
# Pattern detection via LLM
# ---------------------------------------------------------------------------


async def _detect_patterns(
    events_data: list[dict[str, Any]],
    facts_data: list[dict[str, Any]],
    existing_observations: list[dict[str, Any]],
    llm: LLMProvider,
) -> list[dict[str, Any]]:
    """Call LLM to detect new patterns/insights/trends."""
    system_prompt = """You are a behavioral pattern analyst. Analyze the user's messages and facts
and identify NEW patterns, insights, or trends that are NOT already covered
by existing observations.

Return ONLY a JSON array:
[
  {
    "type": "pattern" | "insight" | "trend" | "contradiction" | "cross_entity_theme" | "behavioral_preference",
    "title": "Short title (max 80 chars)",
    "text": "Description in 2-3 sentences.",
    "confidence": 0.0-1.0,
    "importance": 0.0-1.0,
    "supporting_event_indices": [0, 3, 7],
    "supporting_fact_indices": [2, 5]
  }
]

MAXIMUM 5 new observations. Do NOT repeat existing observations.
Write titles and descriptions in the same language as the input content."""

    events_blob = "\n".join(
        f"[{i}] {e.get('date', '')} {e.get('text', '')[:300]}"
        + (f" (emotion: {e.get('emotion', '')})" if e.get("emotion") else "")
        for i, e in enumerate(events_data)
    )
    facts_blob = "\n".join(
        f"[{i}] ({f.get('entity', '')}) {f.get('attribute', '')}: {f.get('value', '')[:200]}"
        for i, f in enumerate(facts_data[:40])
    )
    existing_blob = "\n".join(f"- {o.get('title', '')}: {o.get('text', '')[:200]}" for o in existing_observations[:10])
    user_content = (
        f"EVENTS:\n{events_blob}\n\nACTIVE FACTS:\n{facts_blob}\n\n"
        f"EXISTING OBSERVATIONS (do not repeat):\n{existing_blob}"
    )
    messages = [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": user_content},
    ]
    try:
        raw = await llm.complete(messages)
        if not raw or not isinstance(raw, str):
            return []
        raw = raw.strip()
        if raw.startswith("```"):
            raw = raw.split("\n", 1)[-1] if "\n" in raw else raw[3:]
        if raw.endswith("```"):
            raw = raw.rsplit("```", 1)[0].strip()
        arr = json.loads(raw)
        if not isinstance(arr, list):
            return []
        out: list[dict[str, Any]] = []
        for item in arr[:MAX_OBSERVATIONS_PER_RUN]:
            if not isinstance(item, dict):
                continue
            t = item.get("type")
            if t not in VALID_OBSERVATION_TYPES:
                continue
            title = (item.get("title") or "")[:256]
            text = item.get("text") or ""
            if not title or not text:
                continue
            try:
                conf = max(0.0, min(1.0, float(item.get("confidence", 0.7))))
                imp = max(0.0, min(1.0, float(item.get("importance", 0.5))))
            except (TypeError, ValueError):
                continue
            indices = item.get("supporting_event_indices")
            if not isinstance(indices, list):
                indices = []
            indices = [int(x) for x in indices if isinstance(x, (int, float)) and 0 <= int(x) < len(events_data)]
            fact_indices = item.get("supporting_fact_indices")
            if not isinstance(fact_indices, list):
                fact_indices = []
            fact_indices = [
                int(x) for x in fact_indices if isinstance(x, (int, float)) and 0 <= int(x) < len(facts_data)
            ]
            out.append(
                {
                    "type": t,
                    "title": title,
                    "text": text,
                    "confidence": conf,
                    "importance": imp,
                    "supporting_event_indices": indices,
                    "supporting_fact_indices": fact_indices,
                }
            )
        return out
    except Exception as e:
        logger.warning("detect_patterns failed: %s", e)
        return []


# ---------------------------------------------------------------------------
# L2 Periodic consolidation
# ---------------------------------------------------------------------------


async def run_consolidation(
    session: AsyncSession,
    user_id: str,
    llm_provider: LLMProvider,
    config: MemoryConfig,
    *,
    embedding_provider: EmbeddingProvider | None = None,
) -> ConsolidationResult:
    """L2 Periodic consolidation: detect patterns from events and facts.

    Loads events within the configured lookback window, detects patterns
    via LLM, and creates/reinforces meta-observations with dedup.

    Args:
        session: Database session.
        user_id: User identifier.
        llm_provider: Injected LLM provider.
        config: Memory configuration.
        embedding_provider: Optional embedding provider for dedup.

    Returns:
        ConsolidationResult with stats.
    """
    cutoff = datetime.now(UTC) - timedelta(days=config.consolidation_lookback_days)

    # Load events
    events_stmt = (
        select(MemoryEvent)
        .where(
            MemoryEvent.user_id == user_id,
            MemoryEvent.occurred_at >= cutoff,
        )
        .order_by(MemoryEvent.occurred_at.desc())
    )
    events_result = await session.execute(events_stmt)
    events = list(events_result.scalars().all())

    if len(events) < config.consolidation_min_events:
        return ConsolidationResult(
            skipped=True,
            skip_reason="insufficient_events",
            events_processed=len(events),
        )

    # Load L1 SessionObservations as structured input
    obs_l1_stmt = (
        select(SessionObservation)
        .where(
            SessionObservation.user_id == user_id,
            SessionObservation.is_active.is_(True),
            SessionObservation.created_at >= cutoff,
        )
        .order_by(SessionObservation.created_at.desc())
    )
    obs_l1_result = await session.execute(obs_l1_stmt)
    observations_l1 = list(obs_l1_result.scalars().all())

    observations_data: list[dict[str, Any]] = []
    for obs in observations_l1:
        topic_str = f"[{obs.topic}] " if getattr(obs, "topic", None) else ""
        entities_mentioned = getattr(obs, "entities_mentioned", None) or []
        entities_str = f" (entities: {', '.join(entities_mentioned)})" if entities_mentioned else ""
        observations_data.append(
            {
                "id": str(obs.id),
                "date": obs.created_at.strftime("%Y-%m-%d %H:%M") if obs.created_at else "",
                "text": f"{topic_str}{obs.content}{entities_str}",
                "emotion": getattr(obs, "emotion_label", "") or "",
            }
        )

    # Load facts
    facts_stmt = (
        select(MemoryFact)
        .where(MemoryFact.user_id == user_id, MemoryFact.valid_to.is_(None))
        .order_by(MemoryFact.valid_from.desc())
        .options(defer(MemoryFact.embedding))
    )
    facts_result = await session.execute(facts_stmt)
    facts = list(facts_result.scalars().all())

    # Load existing meta-observations for dedup
    meta_stmt = select(MemoryMetaObservation).where(
        MemoryMetaObservation.user_id == user_id,
        MemoryMetaObservation.is_active.is_(True),
    )
    meta_result = await session.execute(meta_stmt)
    existing_obs = list(meta_result.scalars().all())

    facts_data = [
        {
            "id": str(f.id),
            "attribute": f.attribute_key,
            "value": f.value_text or str(f.value_json),
            "entity": f"{f.entity_type}:{f.entity_key}",
        }
        for f in facts
    ]
    existing_data = [{"title": o.title, "text": o.text} for o in existing_obs]

    # Use observations as input for pattern detection (L2 uses L1 observations)
    input_data = (
        observations_data
        if observations_data
        else [{"id": str(e.id), "date": str(e.occurred_at)[:16], "text": e.text[:300], "emotion": ""} for e in events]
    )

    new_list = await _detect_patterns(input_data, facts_data, existing_data, llm_provider)

    observations_created = 0
    observations_reinforced = 0
    obs_id_by_index = {i: input_data[i]["id"] for i in range(len(input_data))}

    for ob in new_list:
        title = ob["title"]
        text = ob["text"]
        obs_type = ob["type"]
        confidence = ob["confidence"]
        importance = ob["importance"]
        indices = ob.get("supporting_event_indices", [])
        supporting_ids = [obs_id_by_index[i] for i in indices if i in obs_id_by_index]
        fact_indices = ob.get("supporting_fact_indices", [])
        fact_id_by_index = {i: facts_data[i]["id"] for i in range(len(facts_data))}
        supporting_fact_ids = [fact_id_by_index[i] for i in fact_indices if i in fact_id_by_index]

        # Embed title for dedup
        title_embedding: list[float] | None = None
        if embedding_provider:
            try:
                title_embedding = await embedding_provider.embed_one(title)
            except Exception as e:
                logger.debug("observation_title_embedding_failed: %s", e)

        duplicate = None
        if title_embedding and existing_obs:
            for ex in existing_obs:
                if ex.embedding_vec is None:
                    continue
                if _cosine_similarity(title_embedding, list(ex.embedding_vec)) >= DUPLICATE_SIMILARITY_THRESHOLD:
                    duplicate = ex
                    break

        if duplicate:
            duplicate.times_reinforced = (duplicate.times_reinforced or 1) + 1
            duplicate.last_reinforced_at = datetime.now(UTC)
            observations_reinforced += 1
        else:
            new_obs = MemoryMetaObservation(
                user_id=user_id,
                observation_type=obs_type,
                title=title,
                text=text,
                supporting_event_ids=supporting_ids,
                supporting_fact_ids=supporting_fact_ids,
                confidence=confidence,
                importance=importance,
                times_reinforced=1,
                is_active=True,
                embedding_vec=title_embedding,
            )
            session.add(new_obs)
            observations_created += 1

    logger.info(
        "run_consolidation: user=%s, events=%d, created=%d, reinforced=%d",
        user_id,
        len(events),
        observations_created,
        observations_reinforced,
    )
    return ConsolidationResult(
        events_processed=len(events),
        observations_created=observations_created,
        observations_reinforced=observations_reinforced,
    )


# ---------------------------------------------------------------------------
# L3 Profile consolidation
# ---------------------------------------------------------------------------


async def run_profile_consolidation(
    session: AsyncSession,
    user_id: str,
    llm_provider: LLMProvider,
) -> dict[str, Any]:
    """L3 Profile consolidation: refresh entity summaries via LLM.

    For each active MemoryEntity, fetches active facts and generates
    a summary. Caps at 20 entities per run.

    Args:
        session: Database session.
        user_id: User identifier.
        llm_provider: Injected LLM provider.

    Returns:
        Dict with ``entities_processed``, ``summaries_generated``, ``summaries_failed``.
    """
    stmt = (
        select(MemoryEntity)
        .where(MemoryEntity.user_id == user_id, MemoryEntity.is_active.is_(True))
        .order_by(MemoryEntity.last_seen_at.desc())
        .limit(MAX_ENTITIES_PER_RUN)
    )
    result = await session.execute(stmt)
    entities = list(result.scalars().all())

    summaries_generated = 0
    summaries_failed = 0

    for entity in entities:
        try:
            facts_stmt = (
                select(MemoryFact)
                .where(
                    MemoryFact.user_id == user_id,
                    MemoryFact.entity_key == entity.canonical_key,
                    MemoryFact.valid_to.is_(None),
                )
                .order_by(MemoryFact.valid_from.desc())
                .limit(20)
                .options(defer(MemoryFact.embedding))
            )
            facts_result = await session.execute(facts_stmt)
            facts = list(facts_result.scalars().all())

            if not facts:
                continue

            facts_blob = "\n".join(
                f"- {f.attribute_key}: {f.value_text or str(f.value_json)}"
                + (f" (since {f.valid_from.strftime('%Y-%m-%d')})" if f.valid_from else "")
                for f in facts
            )

            messages = [
                {
                    "role": "system",
                    "content": (
                        "Generate a concise summary (2-4 sentences) of this entity based on the facts below. "
                        "The summary should capture who/what the entity is and the most relevant facts. "
                        "Return ONLY the summary text, no formatting. "
                        "Write in the same language as the fact content."
                    ),
                },
                {
                    "role": "user",
                    "content": (
                        f"Entity: {entity.display_name or entity.canonical_key} "
                        f"(type: {entity.entity_type})\n\nFacts:\n{facts_blob}"
                    ),
                },
            ]

            summary = await asyncio.wait_for(
                llm_provider.complete(messages),
                timeout=ENTITY_SUMMARY_TIMEOUT_SEC,
            )

            if summary and isinstance(summary, str):
                entity.summary_text = summary.strip()[:2000]
                summaries_generated += 1
        except Exception as e:
            summaries_failed += 1
            logger.warning("entity_summary_failed: entity=%s error=%s", entity.canonical_key, e)

    logger.info(
        "profile_consolidation: user=%s, generated=%d, failed=%d",
        user_id,
        summaries_generated,
        summaries_failed,
    )
    return {
        "entities_processed": len(entities),
        "summaries_generated": summaries_generated,
        "summaries_failed": summaries_failed,
    }
