"""HDBSCAN-based memory consolidation for lesson deduplication (Phase 12.1a).

Clusters lesson memories by embedding similarity and consolidates near-duplicates.
For each cluster, the longest/most detailed memory is kept as the representative;
shorter duplicates are marked as superseded via the G3 mechanism (is_active=0,
superseded_by=representative_id).

Trade-off: uses a "pick longest" heuristic instead of LLM-generated merged summaries.
  Pros: deterministic, fast, no LLM dependency or API cost.
  Cons: doesn't merge complementary insights from cluster members.
A future version could add an optional LLM merge pass for higher-quality consolidation.

Requires: scikit-learn >= 1.3 (ships HDBSCAN in sklearn.cluster).
"""

from __future__ import annotations

import logging
import struct
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from .storage import MemoryStorage

logger = logging.getLogger(__name__)

DEFAULT_EMBED_DIM = 384


# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------


@dataclass
class ClusterInfo:
    """Information about a single cluster of similar memories."""

    cluster_id: int
    member_ids: list[int]
    member_previews: list[str]
    representative_id: int
    representative_preview: str
    superseded_ids: list[int]
    token_savings: int


@dataclass
class ConsolidationReport:
    """Results of a consolidation run."""

    total_lessons: int
    clusters_found: int
    memories_consolidated: int
    tokens_saved: int
    noise_count: int
    dry_run: bool
    clusters: list[ClusterInfo] = field(default_factory=list)


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------


def _deserialize_f32(blob: bytes, dim: int = DEFAULT_EMBED_DIM) -> list[float]:
    """Deserialize a sqlite-vec float32 vector."""
    return list(struct.unpack(f"{dim}f", blob))


def _preview(content: str, max_len: int = 120) -> str:
    """Truncate content for human-readable reports."""
    if len(content) <= max_len:
        return content
    return content[:max_len].rsplit(" ", 1)[0] + "..."


def _estimate_tokens(content: str) -> int:
    """Estimate token count (chars / 3.5)."""
    return max(1, int(len(content) / 3.5))


def _fetch_active_lessons(storage: MemoryStorage) -> list[dict[str, Any]]:
    """Fetch all active lesson memories with their embeddings.

    Uses direct SQL access to join memories + memory_vectors in one query.
    Intentional access to storage._get_conn(): consolidation is tightly
    coupled to storage internals for bulk embedding retrieval.
    """
    conn = storage._get_conn()  # noqa: SLF001
    rows = conn.execute(
        """
        SELECT m.id, m.content, m.token_count, v.embedding
        FROM memories m
        JOIN memory_vectors v ON v.id = m.id
        WHERE m.category = 'lesson'
          AND m.is_active = 1
          AND (m.expires_at IS NULL OR m.expires_at > datetime('now'))
        ORDER BY m.id
        """
    ).fetchall()

    results: list[dict[str, Any]] = []
    for r in rows:
        token_count = r["token_count"] or _estimate_tokens(r["content"])
        results.append(
            {
                "id": r["id"],
                "content": r["content"],
                "token_count": token_count,
                "embedding": _deserialize_f32(r["embedding"]),
            }
        )
    return results


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def consolidate_lessons(
    storage: MemoryStorage,
    min_cluster_size: int = 3,
    min_samples: int = 2,
    dry_run: bool = True,
) -> ConsolidationReport:
    """Cluster lesson memories by embedding similarity and consolidate duplicates.

    Uses HDBSCAN (density-based clustering, no need to specify K) to find
    groups of semantically similar lessons. For each cluster, the longest
    memory is kept as representative; others are superseded.

    Args:
        storage: MemoryStorage instance with active database.
        min_cluster_size: Minimum memories to form a cluster (default: 3).
        min_samples: HDBSCAN min_samples for core point density (default: 2).
        dry_run: If True (default), report without modifying data.

    Returns:
        ConsolidationReport with cluster details and projected token savings.

    Raises:
        ImportError: If scikit-learn >= 1.3 is not installed.
    """
    import numpy as np
    from sklearn.cluster import HDBSCAN

    # 1. Fetch all active lessons with embeddings
    lessons = _fetch_active_lessons(storage)

    if len(lessons) < min_cluster_size:
        return ConsolidationReport(
            total_lessons=len(lessons),
            clusters_found=0,
            memories_consolidated=0,
            tokens_saved=0,
            noise_count=len(lessons),
            dry_run=dry_run,
        )

    # 2. Build embedding matrix
    embeddings = np.array([lesson["embedding"] for lesson in lessons], dtype=np.float32)

    # 3. Run HDBSCAN (cosine metric works well for sentence embeddings)
    clusterer = HDBSCAN(
        min_cluster_size=min_cluster_size,
        min_samples=min_samples,
        metric="cosine",
    )
    labels = clusterer.fit_predict(embeddings)

    # 4. Group by cluster label (-1 = noise, unclustered)
    cluster_map: dict[int, list[int]] = {}
    noise_count = 0
    for idx, label in enumerate(labels):
        if label == -1:
            noise_count += 1
        else:
            cluster_map.setdefault(int(label), []).append(idx)

    # 5. Process each cluster: pick longest as representative, supersede rest
    cluster_infos: list[ClusterInfo] = []
    total_superseded = 0
    total_tokens_saved = 0

    for cluster_label, member_indices in sorted(cluster_map.items()):
        members = [lessons[i] for i in member_indices]

        # Sort by content length descending — longest is most detailed
        members.sort(key=lambda m: len(m["content"]), reverse=True)
        representative = members[0]
        superseded = members[1:]

        superseded_ids = [m["id"] for m in superseded]
        tokens_saved = sum(m["token_count"] for m in superseded)

        cluster_info = ClusterInfo(
            cluster_id=cluster_label,
            member_ids=[m["id"] for m in members],
            member_previews=[_preview(m["content"]) for m in members],
            representative_id=representative["id"],
            representative_preview=_preview(representative["content"], max_len=200),
            superseded_ids=superseded_ids,
            token_savings=tokens_saved,
        )
        cluster_infos.append(cluster_info)

        total_superseded += len(superseded_ids)
        total_tokens_saved += tokens_saved

        # 6. Apply supersession if not dry_run
        if not dry_run:
            for old_id in superseded_ids:
                storage.supersede_memory(old_id, representative["id"])
                logger.info(
                    "Superseded lesson #%d → representative #%d",
                    old_id,
                    representative["id"],
                )

    return ConsolidationReport(
        total_lessons=len(lessons),
        clusters_found=len(cluster_map),
        memories_consolidated=total_superseded,
        tokens_saved=total_tokens_saved,
        noise_count=noise_count,
        dry_run=dry_run,
        clusters=cluster_infos,
    )
