import json
import logging
import threading
import time
import hashlib
import pickle
from pathlib import Path
from typing import Any, Optional, Dict

# Configure logger
logger = logging.getLogger(__name__)


class Cache:
    """Thread-safe file-based cache with TTL support (Singleton)."""

    _instance: Optional['Cache'] = None
    _lock_init = threading.Lock()

    def __new__(cls, cache_dir: Optional[Path] = None):
        """Create or return singleton instance."""
        if cls._instance is None:
            with cls._lock_init:
                if cls._instance is None:
                    cls._instance = super().__new__(cls)
                    cls._instance._initialized = False
        return cls._instance

    def __init__(self, cache_dir: Optional[Path] = None):
        """Initialize cache with optional custom directory (only once).

        Args:
            cache_dir: Optional cache directory path. Defaults to ~/.cache/renamer/
        """
        # Only initialize once
        if self._initialized:
            return

        # Always use the default cache dir to avoid creating cache in scan dir
        if cache_dir is None:
            cache_dir = Path.home() / ".cache" / "renamer"
        self.cache_dir = cache_dir
        self.cache_dir.mkdir(parents=True, exist_ok=True)
        self._memory_cache: Dict[str, Dict[str, Any]] = {}  # In-memory cache for faster access
        self._lock = threading.RLock()  # Reentrant lock for thread safety
        self._initialized = True

    def _sanitize_key_component(self, component: str) -> str:
        """Sanitize a key component to prevent filesystem escaping.

        Args:
            component: Key component to sanitize

        Returns:
            Sanitized component safe for filesystem use
        """
        # Remove or replace dangerous characters
        dangerous_chars = ['/', '\\', '..', '\0']
        sanitized = component
        for char in dangerous_chars:
            sanitized = sanitized.replace(char, '_')
        return sanitized

    def _get_cache_file(self, key: str) -> Path:
        """Get cache file path with organized subdirectories.

        Supports two key formats:
        1. Prefixed keys: "tmdb_id123", "poster_xyz" -> subdirectories
        2. Plain keys: "anykey" -> general subdirectory

        Args:
            key: Cache key

        Returns:
            Path to cache file
        """
        # Determine subdirectory and subkey based on prefix
        if key.startswith("tmdb_"):
            subdir = "tmdb"
            subkey = key[5:]  # Remove "tmdb_" prefix
        elif key.startswith("poster_"):
            subdir = "posters"
            subkey = key[7:]  # Remove "poster_" prefix
        elif key.startswith("extractor_"):
            subdir = "extractors"
            subkey = key[10:]  # Remove "extractor_" prefix
        else:
            # Default to general subdirectory
            subdir = "general"
            subkey = key

        # Sanitize subdirectory name
        subdir = self._sanitize_key_component(subdir)

        # Create subdirectory
        cache_subdir = self.cache_dir / subdir
        cache_subdir.mkdir(parents=True, exist_ok=True)

        # Hash the subkey for filename (prevents filesystem issues with long/special names)
        key_hash = hashlib.md5(subkey.encode('utf-8')).hexdigest()

        # Use .json extension for all cache files (simplifies logic)
        return cache_subdir / f"{key_hash}.json"

    def get(self, key: str, default: Any = None) -> Any:
        """Get cached value if not expired (thread-safe).

        Args:
            key: Cache key
            default: Value to return if key not found or expired

        Returns:
            Cached value or default if not found/expired
        """
        with self._lock:
            # Check memory cache first
            if key in self._memory_cache:
                data = self._memory_cache[key]
                if time.time() <= data.get('expires', 0):
                    return data.get('value')
                else:
                    # Expired, remove from memory
                    del self._memory_cache[key]
                    logger.debug(f"Memory cache expired for key: {key}")

            # Check file cache
            cache_file = self._get_cache_file(key)
            if not cache_file.exists():
                return default

            try:
                with open(cache_file, 'r') as f:
                    data = json.load(f)

                if time.time() > data.get('expires', 0):
                    # Expired, remove file
                    cache_file.unlink(missing_ok=True)
                    logger.debug(f"File cache expired for key: {key}, removed {cache_file}")
                    return default

                # Store in memory cache for faster future access
                self._memory_cache[key] = data
                return data.get('value')

            except json.JSONDecodeError as e:
                # Corrupted JSON, remove file
                logger.warning(f"Corrupted cache file {cache_file}: {e}")
                cache_file.unlink(missing_ok=True)
                return default
            except IOError as e:
                # File read error
                logger.error(f"Failed to read cache file {cache_file}: {e}")
                return default

    def set(self, key: str, value: Any, ttl_seconds: int) -> None:
        """Set cached value with TTL (thread-safe).

        Args:
            key: Cache key
            value: Value to cache (must be JSON-serializable)
            ttl_seconds: Time-to-live in seconds
        """
        with self._lock:
            data = {
                'value': value,
                'expires': time.time() + ttl_seconds
            }

            # Store in memory cache
            self._memory_cache[key] = data

            # Store in file cache
            cache_file = self._get_cache_file(key)
            try:
                with open(cache_file, 'w') as f:
                    json.dump(data, f, indent=2)
                logger.debug(f"Cached key: {key} to {cache_file} (TTL: {ttl_seconds}s)")
            except (IOError, TypeError) as e:
                logger.error(f"Failed to write cache file {cache_file}: {e}")

    def invalidate(self, key: str) -> None:
        """Remove cache entry (thread-safe).

        Args:
            key: Cache key to invalidate
        """
        with self._lock:
            # Remove from memory cache
            if key in self._memory_cache:
                del self._memory_cache[key]

            # Remove from file cache
            cache_file = self._get_cache_file(key)
            if cache_file.exists():
                cache_file.unlink(missing_ok=True)
                logger.debug(f"Invalidated cache for key: {key}")

    def invalidate_file(self, file_path: Path) -> int:
        """Invalidate all cache entries for a specific file path.

        This invalidates all extractor method caches for the given file by:
        1. Clearing matching keys from memory cache
        2. Removing matching keys from file cache

        Args:
            file_path: File path to invalidate cache for

        Returns:
            Number of cache entries invalidated
        """
        with self._lock:
            # Generate the path hash used in cache keys
            path_hash = hashlib.md5(str(file_path).encode()).hexdigest()[:12]
            prefix = f"extractor_{path_hash}_"

            invalidated_count = 0

            # Remove from memory cache (easy - just check prefix)
            keys_to_remove = [k for k in self._memory_cache.keys() if k.startswith(prefix)]
            for key in keys_to_remove:
                del self._memory_cache[key]
                invalidated_count += 1
                logger.debug(f"Invalidated memory cache for key: {key}")

            # For file cache, we need to invalidate all known extractor methods
            # List of all cached extractor methods
            extractor_methods = [
                'extract_title', 'extract_year', 'extract_source', 'extract_video_codec',
                'extract_audio_codec', 'extract_frame_class', 'extract_hdr', 'extract_order',
                'extract_special_info', 'extract_movie_db', 'extract_extension',
                'extract_video_tracks', 'extract_audio_tracks', 'extract_subtitle_tracks',
                'extract_interlaced', 'extract_size', 'extract_duration', 'extract_bitrate',
                'extract_created', 'extract_modified'
            ]

            # Invalidate each possible cache key
            for method in extractor_methods:
                cache_key = f"extractor_{path_hash}_{method}"
                cache_file = self._get_cache_file(cache_key)
                if cache_file.exists():
                    cache_file.unlink(missing_ok=True)
                    invalidated_count += 1
                    logger.debug(f"Invalidated file cache for key: {cache_key}")

            logger.info(f"Invalidated {invalidated_count} cache entries for file: {file_path.name}")
            return invalidated_count

    def get_image(self, key: str) -> Optional[Path]:
        """Get cached image path if not expired (thread-safe).

        Args:
            key: Cache key

        Returns:
            Path to cached image or None if not found/expired
        """
        with self._lock:
            cache_file = self._get_cache_file(key)
            if not cache_file.exists():
                return None

            try:
                with open(cache_file, 'r') as f:
                    data = json.load(f)

                if time.time() > data.get('expires', 0):
                    # Expired, remove file and image
                    image_path = data.get('image_path')
                    if image_path and Path(image_path).exists():
                        Path(image_path).unlink(missing_ok=True)
                    cache_file.unlink(missing_ok=True)
                    logger.debug(f"Image cache expired for key: {key}")
                    return None

                image_path = data.get('image_path')
                if image_path and Path(image_path).exists():
                    return Path(image_path)
                else:
                    logger.warning(f"Image path in cache but file missing: {image_path}")
                    return None

            except (json.JSONDecodeError, IOError) as e:
                logger.warning(f"Failed to read image cache {cache_file}: {e}")
                cache_file.unlink(missing_ok=True)
                return None

    def set_image(self, key: str, image_data: bytes, ttl_seconds: int) -> Optional[Path]:
        """Set cached image and return path (thread-safe).

        Args:
            key: Cache key
            image_data: Image binary data
            ttl_seconds: Time-to-live in seconds

        Returns:
            Path to saved image or None if failed
        """
        with self._lock:
            # Determine subdirectory for image storage
            if key.startswith("poster_"):
                subdir = "posters"
                subkey = key[7:]
            else:
                subdir = "images"
                subkey = key

            # Create image directory
            image_dir = self.cache_dir / subdir
            image_dir.mkdir(parents=True, exist_ok=True)

            # Hash for filename
            key_hash = hashlib.md5(subkey.encode('utf-8')).hexdigest()
            image_path = image_dir / f"{key_hash}.jpg"

            try:
                # Write image data
                with open(image_path, 'wb') as f:
                    f.write(image_data)

                # Cache metadata
                data = {
                    'image_path': str(image_path),
                    'expires': time.time() + ttl_seconds
                }
                cache_file = self._get_cache_file(key)
                with open(cache_file, 'w') as f:
                    json.dump(data, f, indent=2)

                logger.debug(f"Cached image for key: {key} at {image_path} (TTL: {ttl_seconds}s)")
                return image_path

            except IOError as e:
                logger.error(f"Failed to cache image for key {key}: {e}")
                return None

    def get_object(self, key: str) -> Optional[Any]:
        """Get pickled object from cache if not expired (thread-safe).

        Note: This uses a separate .pkl file format for objects that can't be JSON-serialized.

        Args:
            key: Cache key

        Returns:
            Cached object or None if not found/expired
        """
        with self._lock:
            # Check memory cache first
            if key in self._memory_cache:
                data = self._memory_cache[key]
                if time.time() <= data.get('expires', 0):
                    return data.get('value')
                else:
                    del self._memory_cache[key]
                    logger.debug(f"Memory cache expired for pickled object: {key}")

            # Get cache file path but change extension to .pkl
            cache_file = self._get_cache_file(key).with_suffix('.pkl')
            if not cache_file.exists():
                return None

            try:
                with open(cache_file, 'rb') as f:
                    data = pickle.load(f)

                if time.time() > data.get('expires', 0):
                    # Expired, remove file
                    cache_file.unlink(missing_ok=True)
                    logger.debug(f"Pickled cache expired for key: {key}")
                    return None

                # Store in memory cache
                self._memory_cache[key] = data
                return data.get('value')

            except (pickle.PickleError, IOError) as e:
                # Corrupted or read error, remove
                logger.warning(f"Corrupted pickle cache {cache_file}: {e}")
                cache_file.unlink(missing_ok=True)
                return None

    def set_object(self, key: str, obj: Any, ttl_seconds: int) -> None:
        """Pickle and cache object with TTL (thread-safe).

        Note: This uses pickle format for objects that can't be JSON-serialized.

        Args:
            key: Cache key
            obj: Object to cache (must be picklable)
            ttl_seconds: Time-to-live in seconds
        """
        with self._lock:
            data = {
                'value': obj,
                'expires': time.time() + ttl_seconds
            }

            # Store in memory cache
            self._memory_cache[key] = data

            # Get cache file path but change extension to .pkl
            cache_file = self._get_cache_file(key).with_suffix('.pkl')
            try:
                with open(cache_file, 'wb') as f:
                    pickle.dump(data, f)
                logger.debug(f"Cached pickled object for key: {key} (TTL: {ttl_seconds}s)")
            except (IOError, pickle.PickleError) as e:
                logger.error(f"Failed to cache pickled object {cache_file}: {e}")

    def clear_expired(self) -> int:
        """Remove all expired cache entries.

        Returns:
            Number of entries removed
        """
        with self._lock:
            removed_count = 0
            current_time = time.time()

            # Clear expired from memory cache
            expired_keys = [k for k, v in self._memory_cache.items()
                          if current_time > v.get('expires', 0)]
            for key in expired_keys:
                del self._memory_cache[key]
                removed_count += 1

            # Clear expired from file cache
            for cache_file in self.cache_dir.rglob('*'):
                if cache_file.is_file() and cache_file.suffix in ['.json', '.pkl']:
                    try:
                        if cache_file.suffix == '.json':
                            with open(cache_file, 'r') as f:
                                data = json.load(f)
                        else:  # .pkl
                            with open(cache_file, 'rb') as f:
                                data = pickle.load(f)

                        if current_time > data.get('expires', 0):
                            cache_file.unlink(missing_ok=True)
                            removed_count += 1

                    except (json.JSONDecodeError, pickle.PickleError, IOError):
                        # Corrupted file, remove it
                        cache_file.unlink(missing_ok=True)
                        removed_count += 1

            logger.info(f"Cleared {removed_count} expired cache entries")
            return removed_count
