
import time
import uuid
import logging
from typing import Optional, Dict, Any, List
from dataclasses import dataclass
from datetime import datetime

try:
    import numpy as np
except ImportError:
    # Fallback to basic Python math if numpy not available
    import math
    np = None

from ..embeddings import get_embedding_manager, EmbeddingManager
from ..memory_provider.base import MemoryProvider
from ..enums.memory_type import MemoryType
from ..enums.semantic_cache_scope import SemanticCacheScope
from ..memory_unit.semantic_cache_entry import SemanticCacheEntry

logger = logging.getLogger(__name__)


@dataclass  
class SemanticCacheConfig:
    """Configuration for semantic cache behavior."""
    similarity_threshold: float = 0.78  # Lowered for better matching with similar queries
    max_cache_size: int = 1000
    ttl_hours: float = 24.0
    enable_memory_provider_sync: bool = True  # Enable by default to use memory provider consistently
    enable_usage_tracking: bool = True
    enable_session_scoping: bool = False  # Allow cache hits across sessions by default
    scope: SemanticCacheScope = SemanticCacheScope.LOCAL  # LOCAL filters by agent_id, GLOBAL searches all entries
    embedding_provider: Optional[str] = None
    embedding_config: Optional[Dict[str, Any]] = None
    
class SemanticCache:
    """Enhanced semantic cache with vector similarity search and intelligent management."""
    
    def __init__(
        self, 
        config: Optional[SemanticCacheConfig] = None,
        memory_provider: Optional[MemoryProvider] = None,
        embedding_manager: Optional[EmbeddingManager] = None,
        agent_id: Optional[str] = None,
        memory_id: Optional[str] = None
    ):
        """
        Initialize semantic cache with advanced features.
        
        Parameters:
        -----------
        config : Optional[SemanticCacheConfig]
            Configuration for cache behavior
        memory_provider : Optional[MemoryProvider] 
            Memory provider for persistence (optional)
        embedding_manager : Optional[EmbeddingManager]
            Custom embedding manager (uses global if None)
        agent_id : Optional[str]
            Agent ID for scoped caching
        memory_id : Optional[str] 
            Memory ID for scoped caching (enables memory_id-specific isolation)
        """
        self.config = config or SemanticCacheConfig()
        self.memory_provider = memory_provider
        self.agent_id = agent_id
        self.memory_id = memory_id
        
        # Initialize embedding manager - prioritize passed manager for consistency
        if embedding_manager:
            # Use the provided embedding manager (ensures consistency with agent)
            self.embedding_manager = embedding_manager
            logger.debug("Using provided embedding manager for consistency")
        elif self.config.embedding_provider:
            # Create new embedding manager with specific config
            from ..embeddings import EmbeddingManager
            self.embedding_manager = EmbeddingManager(
                self.config.embedding_provider,
                self.config.embedding_config
            )
            logger.debug(f"Created new embedding manager with provider: {self.config.embedding_provider}")
        else:
            # Use global embedding manager as fallback
            self.embedding_manager = get_embedding_manager()
            logger.debug("Using global embedding manager")
            
        # In-memory cache
        self.cache: Dict[str, SemanticCacheEntry] = {}
        
        # Embedding cache to avoid regenerating embeddings for the same query
        self._embedding_cache: Dict[str, List[float]] = {}
        
        # Load existing entries from memory provider if available
        if self.memory_provider and self.config.enable_memory_provider_sync:
            self._load_from_memory_provider()
            
        logger.info(f"SemanticCache initialized with threshold={self.config.similarity_threshold}, "
                   f"agent_id={self.agent_id}, memory_id={self.memory_id}")
    
    def _get_or_generate_embedding(self, query: str) -> List[float]:
        """
        Get embedding from cache or generate if not present.
        
        Parameters:
        -----------
        query : str
            The query to get embedding for
            
        Returns:
        --------
        List[float]
            The query embedding
        """
        if query not in self._embedding_cache:
            self._embedding_cache[query] = self.embedding_manager.get_embedding(query)
        return self._embedding_cache[query]
    
    def _cosine_similarity(self, vec1: List[float], vec2: List[float]) -> float:
        """Calculate cosine similarity between two vectors."""
        try:
            if np is not None:
                # Use numpy if available
                vec1_np = np.array(vec1)
                vec2_np = np.array(vec2) 
                
                # Handle zero vectors
                norm1 = np.linalg.norm(vec1_np)
                norm2 = np.linalg.norm(vec2_np)
                if norm1 == 0 or norm2 == 0:
                    return 0.0
                    
                return float(np.dot(vec1_np, vec2_np) / (norm1 * norm2))
            else:
                # Fallback to pure Python implementation
                if len(vec1) != len(vec2):
                    return 0.0
                    
                dot_product = sum(a * b for a, b in zip(vec1, vec2))
                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_product / (norm1 * norm2)
                
        except Exception as e:
            logger.warning(f"Error calculating cosine similarity: {e}")
            return 0.0
    
    def _should_use_memory_provider(self) -> bool:
        """
        Determine if we should use memory provider for retrieval instead of in-memory matching.
        Only use in-memory when memory provider is None or doesn't support vector search.
        """
        if not self.memory_provider:
            return False
            
        # Check if this is MongoDB provider (supports vector search)
        provider_class_name = self.memory_provider.__class__.__name__
        if provider_class_name == 'MongoDBProvider':
            return True
            
        # For other providers, check if they have vector search capability
        if hasattr(self.memory_provider, 'vector_search') or hasattr(self.memory_provider, 'similarity_search'):
            return True
            
        # Default to in-memory for local/basic providers
        return False
    
    def _search_via_memory_provider(self, query: str, threshold: float, session_id: Optional[str] = None, limit: int = 10) -> Optional[SemanticCacheEntry]:
        """
        Search for similar cache entries using the memory provider's vector search capabilities.
        """
        try:

            search_filter = {}
            
            # Apply agent_id filter only if scope is LOCAL
            if self.config.scope == SemanticCacheScope.LOCAL and self.agent_id:
                search_filter['agent_id'] = self.agent_id
            
            # Apply memory_id filter if present
            if self.memory_id:
                search_filter['memory_id'] = self.memory_id
            
            # Apply session_id filter only if session scoping is enabled
            if self.config.enable_session_scoping and session_id:
                search_filter['session_id'] = session_id

            logger.debug(f"Semantic cache search filter: {search_filter}")
            logger.debug(f"Session scoping enabled: {self.config.enable_session_scoping}")
            
            # Use the proper vector search method for semantic similarity
            results = self.memory_provider.retrieve_by_query(
                query=query,
                memory_store_type=MemoryType.SEMANTIC_CACHE,
                limit=limit,
                **search_filter  # Pass agent_id, memory_id, session_id as kwargs
            )


            
            if not results or len(results) == 0:
                return None
            
            # Vector search returns results sorted by similarity, so take the first (best) match
            best_result = results[0]
            
            # Check if the best result meets the similarity threshold
            result_score = best_result.get('score', 0.0)
            if result_score < threshold:

                return None
            

            
            # Check session scoping if enabled
            if self.config.enable_session_scoping and session_id and best_result.get('session_id') != session_id:
                return None
            
            # Convert to SemanticCacheEntry
            best_match = SemanticCacheEntry(
                query=best_result['query'],
                response=best_result['response'],
                embedding=best_result.get('embedding', []),
                timestamp=best_result['timestamp'],
                session_id=best_result.get('session_id'),
                memory_id=best_result.get('memory_id'),
                agent_id=best_result.get('agent_id'),
                usage_count=best_result.get('usage_count', 0),
                last_accessed=best_result.get('last_accessed'),
                metadata=best_result.get('metadata', {}),
                cache_key=best_result.get('cache_key')
            )
            
            # Store the MongoDB _id for direct updates (following standard pattern)
            if '_id' in best_result:
                # Add _id to the cache entry (following MongoDB standard pattern)
                best_match.metadata['_id'] = str(best_result['_id'])
            
            # Check if best match is expired
            if best_match and self._is_entry_expired(best_match):
                return None
                
            return best_match
            
        except Exception as e:
            logger.error(f"Error searching via memory provider: {e}")
            return None
            
    def _is_entry_expired(self, entry: SemanticCacheEntry) -> bool:
        """Check if cache entry has expired based on TTL."""
        if self.config.ttl_hours <= 0:
            return False  # No expiration
        
        expiry_time = entry.timestamp + (self.config.ttl_hours * 3600)
        return time.time() > expiry_time
    
    def _cleanup_expired_entries(self) -> int:
        """Remove expired entries from cache."""
        expired_keys = [
            key for key, entry in self.cache.items() 
            if self._is_entry_expired(entry)
        ]
        
        for key in expired_keys:
            del self.cache[key]
            
        if expired_keys:
            logger.debug(f"Cleaned up {len(expired_keys)} expired cache entries")
            
        return len(expired_keys)
    
    def _evict_lru_entries(self) -> int:
        """Evict least recently used entries if cache is full."""
        if len(self.cache) <= self.config.max_cache_size:
            return 0
            
        # Sort by last_accessed (oldest first)
        sorted_entries = sorted(
            self.cache.items(),
            key=lambda x: x[1].last_accessed or 0
        )
        
        # Remove oldest entries
        evict_count = len(self.cache) - self.config.max_cache_size
        for i in range(evict_count):
            key, _ = sorted_entries[i]
            del self.cache[key]
            
        if evict_count > 0:
            logger.debug(f"Evicted {evict_count} LRU cache entries")
            
        return evict_count
    
    def _generate_cache_key(self, query: str, session_id: Optional[str] = None) -> str:
        """Generate a unique cache key for the query."""
        key_parts = [query]
        if self.agent_id:
            key_parts.append(f"agent:{self.agent_id}")
        if self.memory_id:
            key_parts.append(f"memory:{self.memory_id}")
        if session_id:
            key_parts.append(f"session:{session_id}")
            
        return str(uuid.uuid5(uuid.NAMESPACE_OID, "|".join(key_parts)))
    
    def get(
        self, 
        query: str, 
        session_id: Optional[str] = None,
        similarity_threshold: Optional[float] = None
    ) -> Optional[str]:
        """
        Retrieve cached response for semantically similar queries.
        Uses memory provider for search when available, falls back to in-memory for local providers.
        
        Parameters:
        -----------
        query : str
            The query to search for
        session_id : Optional[str]
            Session ID for scoped search
        similarity_threshold : Optional[float]
            Override default similarity threshold
            
        Returns:
        --------
        Optional[str]
            Cached response if found, None otherwise
        """
        try:
            threshold = similarity_threshold or self.config.similarity_threshold
            logger.debug(f"Semantic cache query: '{query[:50]}...', threshold={threshold}")
            logger.debug(f"Cache context - agent_id={self.agent_id}, memory_id={self.memory_id}, session_id={session_id}")
            
            # Decide whether to use memory provider or in-memory search
            should_use_provider = self._should_use_memory_provider()
            logger.debug(f"Using memory provider for search: {should_use_provider}")
            
            if should_use_provider:

                logger.debug("Using memory provider for semantic cache retrieval")
                best_match = self._search_via_memory_provider(query, threshold, session_id)
            else:
                # Generate query embedding for in-memory search (with caching)
                query_embedding = self._get_or_generate_embedding(query)

                logger.debug("Using in-memory search for semantic cache retrieval")
                # Clean up expired entries first (only for in-memory)
                self._cleanup_expired_entries()
                
                if not self.cache:
                    return None
                
                # In-memory search (original logic)
                best_match = None
                best_similarity = 0.0
                
                for entry in self.cache.values():
                    # Skip entries that don't match scope (only if session scoping is enabled)
                    if self.config.enable_session_scoping and session_id and entry.session_id != session_id:
                        continue
                        
                    similarity = self._cosine_similarity(query_embedding, entry.embedding)
                    
                    if similarity >= threshold and similarity > best_similarity:
                        best_similarity = similarity
                        best_match = entry
            


            if best_match:
                # Update usage statistics
                if self.config.enable_usage_tracking:
                    best_match.usage_count += 1
                    best_match.last_accessed = time.time()
                    
                    # If using memory provider, also update in the database
                    if self._should_use_memory_provider() and self.config.enable_memory_provider_sync:
                        self._update_usage_in_memory_provider(best_match)
                
                logger.debug(f"Cache HIT: query: {query[:50]}...")
                return best_match.response
            else:
                logger.debug(f"Cache MISS: no similar query found for: {query[:50]}...")
                return None
                
        except Exception as e:
            logger.error(f"Error in semantic cache get: {e}")
            return None
    
    def _update_usage_in_memory_provider(self, entry: SemanticCacheEntry) -> bool:
        """Update usage statistics in the memory provider."""
        try:
            # If we have the document ID from the cache hit, use it directly (standard pattern)
            if entry.metadata and '_id' in entry.metadata:
                update_data = {
                    'usage_count': entry.usage_count,
                    'last_accessed': entry.last_accessed
                }
                
                return self.memory_provider.update_by_id(
                    id=entry.metadata['_id'],
                    data=update_data,
                    memory_store_type=MemoryType.SEMANTIC_CACHE
                )
            else:
                # Fallback: build query to find the entry by cache_key if available, otherwise by query
                if entry.cache_key:
                    search_filter = {'cache_key': entry.cache_key}
                else:
                    search_filter = {
                        'query': entry.query,
                        'agent_id': entry.agent_id
                    }
                    if entry.memory_id:
                        search_filter['memory_id'] = entry.memory_id
                    if entry.session_id:
                        search_filter['session_id'] = entry.session_id
                
                # Use regular MongoDB find to get the document ID
                results = list(self.memory_provider.semantic_cache_collection.find(
                    search_filter, {"_id": 1}
                ).limit(1))
                
                if results and len(results) > 0:
                    doc = results[0]
                    if '_id' in doc:
                        update_data = {
                            'usage_count': entry.usage_count,
                            'last_accessed': entry.last_accessed
                        }
                        return self.memory_provider.update_by_id(
                            id=str(doc['_id']),
                            data=update_data,
                            memory_store_type=MemoryType.SEMANTIC_CACHE
                        )
                
                return False
            
        except Exception as e:
            logger.warning(f"Failed to update usage statistics in memory provider: {e}")
            return False
    
    def set(
        self, 
        query: str, 
        response: str,
        session_id: Optional[str] = None,
        metadata: Optional[Dict[str, Any]] = None
    ) -> bool:
        """
        Store query-response pair in cache with embedding.
        
        Parameters:
        -----------
        query : str
            The query text
        response : str
            The response to cache
        session_id : Optional[str]
            Session ID for scoped storage
        metadata : Optional[Dict[str, Any]]
            Additional metadata to store
            
        Returns:
        --------
        bool
            True if stored successfully, False otherwise
        """
        try:
            # Generate embedding for query (with caching to avoid duplication)
            query_embedding = self._get_or_generate_embedding(query)
            
            # Generate cache key
            cache_key = self._generate_cache_key(query, session_id)
            
            # Create cache entry
            entry = SemanticCacheEntry(
                query=query,
                response=response,
                embedding=query_embedding,
                timestamp=time.time(),
                session_id=session_id,
                memory_id=self.memory_id,
                agent_id=self.agent_id,
                metadata=metadata or {},
                cache_key=cache_key
            )
            
            # Store in memory cache
            self.cache[cache_key] = entry
            
            # Clean up if necessary
            self._evict_lru_entries()
            
            # Sync to memory provider if enabled
            if self.memory_provider and self.config.enable_memory_provider_sync:
                self._sync_to_memory_provider(cache_key, entry)
                
            logger.debug(f"Cache SET: stored query: {query[:50]}...")
            return True
            
        except Exception as e:
            logger.error(f"Error in semantic cache set: {e}")
            return False
    
    def _sync_to_memory_provider(self, cache_key: str, entry: SemanticCacheEntry) -> bool:
        """Sync cache entry to memory provider for persistence."""
        try:
            # Prepare data for storage
            data = entry.model_dump()
            data['cache_key'] = cache_key
            data['created_at'] = datetime.fromtimestamp(entry.timestamp)
            data['agent_id'] = self.agent_id
            data['memory_id'] = self.memory_id
            
            # Store in memory provider using the correct method
            self.memory_provider.store(
                data=data,
                memory_store_type=MemoryType.SEMANTIC_CACHE
            )
            return True
            
        except Exception as e:
            logger.warning(f"Failed to sync cache entry to memory provider: {e}")
            return False
    
    def _load_from_memory_provider(self) -> int:
        """Load existing cache entries from memory provider."""
        try:
            if not self.memory_provider:
                return 0
                
            # Build query for cached entries
            query = {}
            if self.agent_id:
                query['agent_id'] = self.agent_id
            if self.memory_id:
                query['memory_id'] = self.memory_id
                
            # Retrieve cached entries using the correct method
            result = self.memory_provider.retrieve_by_query(
                query=query,
                memory_store_type=MemoryType.SEMANTIC_CACHE,
                limit=self.config.max_cache_size
            )
            
            # Handle both single result and list results
            if result is None:
                entries = []
            elif hasattr(result, '__iter__') and not isinstance(result, (str, dict)):
                # Handle cursor or list-like objects
                entries = list(result)
            elif isinstance(result, dict):
                entries = [result]
            else:
                entries = [result]
            
            loaded_count = 0
            for entry_data in entries:
                try:
                    # Reconstruct cache entry
                    metadata = entry_data.get('metadata', {})
                    # Preserve MongoDB _id in metadata (standard pattern)
                    if '_id' in entry_data:
                        metadata['_id'] = str(entry_data['_id'])
                    
                    cache_entry = SemanticCacheEntry(
                        query=entry_data['query'],
                        response=entry_data['response'], 
                        embedding=entry_data['embedding'],
                        timestamp=entry_data['timestamp'],
                        session_id=entry_data.get('session_id'),
                        memory_id=entry_data.get('memory_id'),
                        agent_id=entry_data.get('agent_id'),
                        usage_count=entry_data.get('usage_count', 0),
                        last_accessed=entry_data.get('last_accessed'),
                        metadata=metadata,
                        cache_key=entry_data.get('cache_key')
                    )
                    
                    # Skip expired entries
                    if self._is_entry_expired(cache_entry):
                        continue
                        
                    cache_key = entry_data.get('cache_key') or self._generate_cache_key(
                        cache_entry.query, cache_entry.session_id
                    )
                    
                    self.cache[cache_key] = cache_entry
                    loaded_count += 1
                    
                except Exception as e:
                    logger.warning(f"Failed to load cache entry: {e}")
                    continue
                    
            logger.info(f"Loaded {loaded_count} cache entries from memory provider")
            return loaded_count
            
        except Exception as e:
            logger.error(f"Failed to load from memory provider: {e}")
            return 0
    
    def clear(
        self, 
        session_id: Optional[str] = None, 
        memory_id: Optional[str] = None,
        clear_persistent: Optional[bool] = None
    ) -> int:
        """
        Clear cache entries with optional filtering.
        
        Parameters:
        -----------
        session_id : Optional[str]
            Clear only entries for this session ID
        memory_id : Optional[str]
            Clear only entries for this memory ID  
        clear_persistent : Optional[bool]
            Whether to also clear from persistent storage (MongoDB).
            If None, defaults to True when enable_memory_provider_sync=True
            
        Returns:
        --------
        int
            Number of entries cleared from in-memory cache
        """
        # Determine if we should clear persistent storage
        if clear_persistent is None:
            clear_persistent = (
                self.memory_provider is not None and 
                self.config.enable_memory_provider_sync
            )
        
        # Count entries before clearing for return value
        memory_cleared = 0
        persistent_cleared = 0
        
        if session_id is None and memory_id is None:
            # Clear all entries
            memory_cleared = len(self.cache)
            self.cache.clear()
            
            # Also clear from persistent storage if enabled
            if clear_persistent and self.memory_provider:
                try:
                    # Clear all entries for this agent
                    persistent_cleared = self.memory_provider.clear_semantic_cache(
                        agent_id=self.agent_id,
                        memory_id=None  # Clear all memory IDs for this agent
                    )
                    logger.info(f"Cleared {persistent_cleared} entries from persistent storage")
                except Exception as e:
                    logger.error(f"Failed to clear persistent cache: {e}")
            
            logger.info(f"Cleared all {memory_cleared} cache entries from memory")
            return memory_cleared
        
        # Clear filtered entries from memory
        keys_to_remove = []
        for key, entry in self.cache.items():
            should_remove = True
            if session_id is not None and entry.session_id != session_id:
                should_remove = False
            if memory_id is not None and entry.memory_id != memory_id:
                should_remove = False
            if should_remove:
                keys_to_remove.append(key)
        
        memory_cleared = len(keys_to_remove)
        for key in keys_to_remove:
            del self.cache[key]
        
        # Also clear from persistent storage if enabled
        if clear_persistent and self.memory_provider and memory_cleared > 0:
            try:
                # Build filter for persistent storage
                # Note: session_id filtering not supported by memory provider currently
                # so we clear by agent_id and memory_id only
                persistent_cleared = self.memory_provider.clear_semantic_cache(
                    agent_id=self.agent_id,
                    memory_id=memory_id  # This will be passed if specified
                )
                logger.info(f"Cleared {persistent_cleared} entries from persistent storage")
                
                if session_id is not None:
                    logger.warning(f"Session-specific clearing from persistent storage is not supported. "
                                 f"Cleared by agent_id and memory_id only.")
            except Exception as e:
                logger.error(f"Failed to clear persistent cache: {e}")
            
        logger.info(f"Cleared {memory_cleared} filtered cache entries from memory")
        return memory_cleared
    
    def get_stats(self) -> Dict[str, Any]:
        """Get cache statistics and health information."""
        if not self.cache:
            return {
                'total_entries': 0,
                'expired_entries': 0,
                'memory_usage_mb': 0,
                'hit_rate': 0.0
            }
            
        expired_count = sum(1 for entry in self.cache.values() if self._is_entry_expired(entry))
        total_usage = sum(entry.usage_count for entry in self.cache.values())
        
        return {
            'total_entries': len(self.cache),
            'expired_entries': expired_count,
            'active_entries': len(self.cache) - expired_count,
            'total_usage_count': total_usage,
            'average_usage': total_usage / len(self.cache) if self.cache else 0,
            'config': self.config.__dict__,
            'agent_id': self.agent_id,
            'memory_id': self.memory_id
        }


# Standalone semantic cache for external frameworks
class StandaloneSemanticCache(SemanticCache):
    """Standalone semantic cache that can be used with any agent framework."""
    
    def __init__(
        self,
        similarity_threshold: float = 0.85,
        max_cache_size: int = 1000,
        ttl_hours: float = 24.0,
        scope: SemanticCacheScope = SemanticCacheScope.LOCAL,
        embedding_provider: str = "openai",
        embedding_config: Optional[Dict[str, Any]] = None,
        enable_persistence: bool = False,
        persistence_config: Optional[Dict[str, Any]] = None
    ):
        """
        Initialize standalone semantic cache for external use.
        
        Parameters:
        -----------
        similarity_threshold : float
            Minimum similarity score for cache hits (0.0-1.0)
        max_cache_size : int
            Maximum number of entries to keep in memory
        ttl_hours : float
            Time-to-live in hours (0 = no expiration)
        scope : SemanticCacheScope
            Cache scope (LOCAL = agent-specific, GLOBAL = cross-agent)
        embedding_provider : str
            Embedding provider ('openai', 'voyageai', 'ollama')
        embedding_config : Optional[Dict[str, Any]]
            Configuration for embedding provider
        enable_persistence : bool
            Whether to enable MongoDB persistence
        persistence_config : Optional[Dict[str, Any]]
            MongoDB configuration for persistence
        """
        config = SemanticCacheConfig(
            similarity_threshold=similarity_threshold,
            max_cache_size=max_cache_size,
            ttl_hours=ttl_hours,
            scope=scope,
            embedding_provider=embedding_provider,
            embedding_config=embedding_config,
            enable_memory_provider_sync=enable_persistence
        )
        
        memory_provider = None
        if enable_persistence and persistence_config:
            try:
                from ..memory_provider.mongodb.provider import MongoDBProvider, MongoDBConfig
                mongodb_config = MongoDBConfig(**persistence_config)
                memory_provider = MongoDBProvider(mongodb_config)
            except Exception as e:
                logger.warning(f"Failed to initialize persistence: {e}")
        
        super().__init__(
            config=config,
            memory_provider=memory_provider,
            agent_id="standalone_cache"
        )
    
    def query(self, text: str, session_id: Optional[str] = None) -> Optional[str]:
        """Simple query interface for external frameworks."""
        return self.get(text, session_id=session_id)
    
    def cache_response(self, query: str, response: str, session_id: Optional[str] = None) -> bool:
        """Simple caching interface for external frameworks."""
        return self.set(query, response, session_id=session_id)


# Factory function for easy creation
def create_semantic_cache(
    agent_id: Optional[str] = None,
    memory_id: Optional[str] = None,
    memory_provider: Optional[MemoryProvider] = None,
    **config_kwargs
) -> SemanticCache:
    """Factory function to create semantic cache with sensible defaults."""
    config = SemanticCacheConfig(**config_kwargs)
    return SemanticCache(
        config=config,
        memory_provider=memory_provider,
        agent_id=agent_id,
        memory_id=memory_id
    )