"""
Embedding Service for semantic similarity.

Provides embedding generation and similarity search using multiple providers
(OpenAI, Anthropic, local models). Configuration loaded from embeddings.yaml.

Usage:
    from lightwave.ai.embeddings import (
        EmbeddingService,
        compute_embedding,
        find_similar_entities,
    )

    # Compute embedding for a single entity
    result = compute_embedding(
        entity_type="task",
        entity_id="abc123",
        text="Implement user authentication",
    )

    # Find similar entities
    matches = find_similar_entities(
        query_text="authentication system",
        target_types=["task", "document"],
    )
"""

from __future__ import annotations

import hashlib
import logging
import os
from datetime import datetime, timezone
from typing import Any

from lightwave.schema.pydantic.models.embeddings import (
    DuplicateCandidate,
    EmbeddingRequest,
    EmbeddingResult,
    EmbeddingsConfig,
    SimilarityMatch,
)

logger = logging.getLogger(__name__)


# =============================================================================
# EMBEDDING SERVICE
# =============================================================================


class EmbeddingService:
    """
    Service for computing embeddings and finding similar entities.

    Supports multiple providers with caching and batch processing.

    Usage:
        service = EmbeddingService(provider="openai")
        result = service.embed("task", "abc123", "Implement feature X")
        matches = service.find_similar(result.embedding, max_results=10)
    """

    def __init__(
        self,
        provider: str = "openai",
        model: str | None = None,
        cache_enabled: bool | None = None,
    ):
        """
        Initialize embedding service.

        Args:
            provider: Embedding provider (openai, anthropic, local)
            model: Model override (uses default from YAML if None)
            cache_enabled: Override cache setting (uses YAML if None)
        """
        if not EmbeddingsConfig.is_valid_provider(provider):
            valid = EmbeddingsConfig.get_providers()
            raise ValueError(f"Invalid provider '{provider}'. Valid: {valid}")

        self.provider = provider
        self.model = model or EmbeddingsConfig.get_default_model(provider)
        self.dimensions = EmbeddingsConfig.get_model_dimensions(provider, self.model)
        self.cache_enabled = cache_enabled if cache_enabled is not None else EmbeddingsConfig.is_caching_enabled()
        self._cache: dict[str, EmbeddingResult] = {}
        self._client = None

    def _get_client(self) -> Any:
        """Get or create the API client for the provider."""
        if self._client is not None:
            return self._client

        if self.provider == "openai":
            try:
                from openai import OpenAI

                self._client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY"))
            except ImportError:
                raise RuntimeError("OpenAI package not installed. Run: pip install openai")

        elif self.provider == "anthropic":
            # Anthropic uses Voyage for embeddings
            try:
                import voyageai

                self._client = voyageai.Client(api_key=os.environ.get("VOYAGE_API_KEY"))
            except ImportError:
                raise RuntimeError("Voyage AI package not installed. Run: pip install voyageai")

        elif self.provider == "local":
            try:
                from sentence_transformers import SentenceTransformer

                self._client = SentenceTransformer(self.model)
            except ImportError:
                raise RuntimeError("sentence-transformers not installed. Run: pip install sentence-transformers")

        return self._client

    def _compute_text_hash(self, text: str) -> str:
        """Compute hash of input text for cache keys."""
        return hashlib.sha256(text.encode()).hexdigest()[:16]

    def _get_cache_key(self, entity_type: str, entity_id: str, text_hash: str) -> str:
        """Generate cache key for an embedding."""
        return f"{self.provider}:{self.model}:{entity_type}:{entity_id}:{text_hash}"

    def embed(
        self,
        entity_type: str,
        entity_id: str,
        text: str,
        metadata: dict[str, Any] | None = None,
    ) -> EmbeddingResult:
        """
        Compute embedding for a single entity.

        Args:
            entity_type: Type of entity (task, document, etc.)
            entity_id: Unique identifier
            text: Text to embed
            metadata: Additional metadata

        Returns:
            EmbeddingResult with the embedding vector
        """
        text_hash = self._compute_text_hash(text)
        cache_key = self._get_cache_key(entity_type, entity_id, text_hash)

        # Check cache
        if self.cache_enabled and cache_key in self._cache:
            cached = self._cache[cache_key]
            return EmbeddingResult(
                entity_type=cached.entity_type,
                entity_id=cached.entity_id,
                embedding=cached.embedding,
                dimensions=cached.dimensions,
                provider=cached.provider,
                model=cached.model,
                tokens_used=0,
                cached=True,
                text_hash=text_hash,
            )

        # Compute embedding
        embedding, tokens_used = self._compute_embedding(text)

        result = EmbeddingResult(
            entity_type=entity_type,
            entity_id=entity_id,
            embedding=embedding,
            dimensions=len(embedding),
            provider=self.provider,
            model=self.model,
            tokens_used=tokens_used,
            cached=False,
            text_hash=text_hash,
            created_at=datetime.now(timezone.utc),
        )

        # Cache result
        if self.cache_enabled:
            self._cache[cache_key] = result

        return result

    def _compute_embedding(self, text: str) -> tuple[list[float], int]:
        """
        Compute embedding using the configured provider.

        Returns:
            Tuple of (embedding vector, tokens used)
        """
        client = self._get_client()

        if self.provider == "openai":
            response = client.embeddings.create(model=self.model, input=text)
            embedding = response.data[0].embedding
            tokens = response.usage.total_tokens
            return embedding, tokens

        elif self.provider == "anthropic":
            # Voyage AI
            result = client.embed([text], model=self.model)
            return result.embeddings[0], len(text.split())  # Approximate tokens

        elif self.provider == "local":
            # sentence-transformers
            embedding = client.encode(text).tolist()
            return embedding, len(text.split())

        raise ValueError(f"Unsupported provider: {self.provider}")

    def embed_batch(
        self,
        requests: list[EmbeddingRequest],
    ) -> list[EmbeddingResult]:
        """
        Compute embeddings for a batch of entities.

        More efficient than individual calls for OpenAI.

        Args:
            requests: List of embedding requests

        Returns:
            List of EmbeddingResult objects
        """
        results = []
        uncached_requests = []
        uncached_indices = []

        # Check cache first
        for i, req in enumerate(requests):
            text_hash = self._compute_text_hash(req.text)
            cache_key = self._get_cache_key(req.entity_type, req.entity_id, text_hash)

            if self.cache_enabled and cache_key in self._cache:
                cached = self._cache[cache_key]
                results.append(
                    EmbeddingResult(
                        entity_type=cached.entity_type,
                        entity_id=cached.entity_id,
                        embedding=cached.embedding,
                        dimensions=cached.dimensions,
                        provider=cached.provider,
                        model=cached.model,
                        tokens_used=0,
                        cached=True,
                        text_hash=text_hash,
                    )
                )
            else:
                results.append(None)  # Placeholder
                uncached_requests.append(req)
                uncached_indices.append(i)

        # Batch compute uncached
        if uncached_requests:
            batch_results = self._compute_embedding_batch([r.text for r in uncached_requests])

            for i, (req, (embedding, tokens)) in enumerate(zip(uncached_requests, batch_results, strict=False)):
                text_hash = self._compute_text_hash(req.text)
                result = EmbeddingResult(
                    entity_type=req.entity_type,
                    entity_id=req.entity_id,
                    embedding=embedding,
                    dimensions=len(embedding),
                    provider=self.provider,
                    model=self.model,
                    tokens_used=tokens,
                    cached=False,
                    text_hash=text_hash,
                    created_at=datetime.now(timezone.utc),
                )

                results[uncached_indices[i]] = result

                # Cache
                if self.cache_enabled:
                    cache_key = self._get_cache_key(req.entity_type, req.entity_id, text_hash)
                    self._cache[cache_key] = result

        return results

    def _compute_embedding_batch(self, texts: list[str]) -> list[tuple[list[float], int]]:
        """Compute embeddings for a batch of texts."""
        client = self._get_client()

        if self.provider == "openai":
            response = client.embeddings.create(model=self.model, input=texts)
            results = []
            tokens_per_text = response.usage.total_tokens // len(texts)
            for item in response.data:
                results.append((item.embedding, tokens_per_text))
            return results

        elif self.provider == "anthropic":
            result = client.embed(texts, model=self.model)
            return [(emb, len(t.split())) for emb, t in zip(result.embeddings, texts, strict=False)]

        elif self.provider == "local":
            embeddings = client.encode(texts).tolist()
            return [(emb, len(t.split())) for emb, t in zip(embeddings, texts, strict=False)]

        raise ValueError(f"Unsupported provider: {self.provider}")

    def cosine_similarity(self, vec1: list[float], vec2: list[float]) -> float:
        """Compute cosine similarity between two vectors."""
        import math

        dot = sum(a * b for a, b in zip(vec1, vec2, strict=False))
        norm1 = math.sqrt(sum(a * a for a in vec1))
        norm2 = math.sqrt(sum(b * b for b in vec2))

        if norm1 == 0 or norm2 == 0:
            return 0.0

        return dot / (norm1 * norm2)

    def find_similar(
        self,
        query_embedding: list[float],
        candidate_embeddings: list[tuple[str, str, str, list[float]]],
        min_score: float = 0.5,
        max_results: int = 10,
    ) -> list[SimilarityMatch]:
        """
        Find similar entities from a list of candidates.

        Args:
            query_embedding: Query embedding vector
            candidate_embeddings: List of (entity_type, entity_id, label, embedding)
            min_score: Minimum similarity score
            max_results: Maximum results to return

        Returns:
            List of SimilarityMatch objects sorted by score
        """
        matches = []

        for entity_type, entity_id, label, embedding in candidate_embeddings:
            score = self.cosine_similarity(query_embedding, embedding)
            if score >= min_score:
                matches.append(
                    SimilarityMatch.from_score(
                        entity_type=entity_type,
                        entity_id=entity_id,
                        score=score,
                        label=label,
                    )
                )

        # Sort by score descending
        matches.sort(key=lambda m: m.score, reverse=True)
        return matches[:max_results]

    def clear_cache(self) -> int:
        """Clear the embedding cache. Returns number of entries cleared."""
        count = len(self._cache)
        self._cache.clear()
        return count


# =============================================================================
# CONVENIENCE FUNCTIONS
# =============================================================================


def compute_embedding(
    entity_type: str,
    entity_id: str,
    text: str,
    provider: str = "openai",
    model: str | None = None,
) -> EmbeddingResult:
    """
    Compute embedding for a single entity.

    Args:
        entity_type: Type of entity
        entity_id: Entity identifier
        text: Text to embed
        provider: Embedding provider
        model: Model override

    Returns:
        EmbeddingResult
    """
    service = EmbeddingService(provider=provider, model=model)
    return service.embed(entity_type, entity_id, text)


def compute_embeddings_batch(
    requests: list[EmbeddingRequest],
    provider: str = "openai",
    model: str | None = None,
) -> list[EmbeddingResult]:
    """
    Compute embeddings for a batch of entities.

    Args:
        requests: List of EmbeddingRequest objects
        provider: Embedding provider
        model: Model override

    Returns:
        List of EmbeddingResult objects
    """
    service = EmbeddingService(provider=provider, model=model)
    return service.embed_batch(requests)


def find_similar_entities(
    query_text: str,
    candidate_embeddings: list[tuple[str, str, str, list[float]]],
    provider: str = "openai",
    min_score: float = 0.5,
    max_results: int = 10,
) -> list[SimilarityMatch]:
    """
    Find entities similar to a query text.

    Args:
        query_text: Text to find similar entities for
        candidate_embeddings: List of (entity_type, entity_id, label, embedding)
        provider: Embedding provider
        min_score: Minimum similarity score
        max_results: Maximum results

    Returns:
        List of SimilarityMatch objects
    """
    service = EmbeddingService(provider=provider)
    query_result = service.embed("query", "temp", query_text)
    return service.find_similar(
        query_result.embedding,
        candidate_embeddings,
        min_score=min_score,
        max_results=max_results,
    )


def find_duplicates(
    entities: list[tuple[str, str, str, str]],
    provider: str = "openai",
    threshold: float | None = None,
) -> list[DuplicateCandidate]:
    """
    Find potential duplicates among entities.

    Args:
        entities: List of (entity_type, entity_id, label, text)
        provider: Embedding provider
        threshold: Similarity threshold (uses YAML default if None)

    Returns:
        List of DuplicateCandidate objects
    """
    if threshold is None:
        threshold = EmbeddingsConfig.get_similarity_threshold("duplicate_detection")

    service = EmbeddingService(provider=provider)

    # Compute embeddings
    requests = [EmbeddingRequest(entity_type=t, entity_id=i, text=txt) for t, i, _, txt in entities]
    results = service.embed_batch(requests)

    # Build lookup
    entity_data = list(zip(entities, results, strict=False))

    # Find duplicates
    duplicates = []
    seen_pairs: set[tuple[str, str]] = set()

    for i, ((type1, id1, label1, _), result1) in enumerate(entity_data):
        for (type2, id2, label2, _), result2 in entity_data[i + 1 :]:
            # Skip if already seen
            pair_key = tuple(sorted([id1, id2]))
            if pair_key in seen_pairs:
                continue

            score = service.cosine_similarity(result1.embedding, result2.embedding)
            if score >= threshold:
                seen_pairs.add(pair_key)

                # Determine suggested action
                if score >= 0.98:
                    action = "merge"
                elif score >= 0.95:
                    action = "archive_duplicate"
                else:
                    action = "review"

                # Determine confidence
                if score >= 0.95:
                    confidence = "high"
                elif score >= 0.90:
                    confidence = "medium"
                else:
                    confidence = "low"

                duplicates.append(
                    DuplicateCandidate(
                        source_type=type1,
                        source_id=id1,
                        source_label=label1,
                        duplicate_type=type2,
                        duplicate_id=id2,
                        duplicate_label=label2,
                        similarity_score=score,
                        confidence=confidence,
                        suggested_action=action,
                    )
                )

    # Sort by score descending
    duplicates.sort(key=lambda d: d.similarity_score, reverse=True)
    return duplicates


# =============================================================================
# ENTITY TEXT EXTRACTION
# =============================================================================


def extract_entity_text(entity: Any, entity_type: str) -> str:
    """
    Extract text from an entity for embedding.

    Uses field configuration from embeddings.yaml.

    Args:
        entity: Django model instance or entity-like object
        entity_type: Type of entity

    Returns:
        Combined text for embedding
    """
    config = EmbeddingsConfig.get_entity_config(entity_type)
    fields = config.get("fields", [])

    if not fields:
        # Fallback: try common field names
        text_parts = []
        for attr in ["title", "name", "description", "content"]:
            value = getattr(entity, attr, None)
            if value:
                text_parts.append(str(value))
        return " ".join(text_parts)

    # Use configured fields with weights
    weighted_parts = []
    for field in fields:
        field_name = field["name"]
        weight = field.get("weight", 1.0)
        required = field.get("required", False)
        truncate_to = field.get("truncate_to")

        value = getattr(entity, field_name, None)
        if value:
            text = str(value)
            if truncate_to and len(text) > truncate_to:
                text = text[:truncate_to]

            # Apply weight by repeating (simple approach)
            if weight >= 2.0:
                weighted_parts.append(text)
                weighted_parts.append(text)
            elif weight >= 1.5:
                weighted_parts.append(text)
                weighted_parts.append(text[: len(text) // 2])
            else:
                weighted_parts.append(text)
        elif required:
            logger.warning(f"Required field {field_name} missing from {entity_type}")

    return " ".join(weighted_parts)


# =============================================================================
# MODULE EXPORTS
# =============================================================================

__all__ = [
    "EmbeddingService",
    "compute_embedding",
    "compute_embeddings_batch",
    "find_similar_entities",
    "find_duplicates",
    "extract_entity_text",
]
