"""Error handling and resilience utilities for the geo-targeting system.

This module provides:
- Custom exception classes for different error scenarios
- Error recovery strategies
- Graceful fallback mechanisms
- Error context tracking for debugging
"""

import logging
from typing import Optional, Callable, Any
from functools import wraps
from datetime import datetime, timedelta
from django.core.cache import cache

logger = logging.getLogger(__name__)


class GeoTargetingError(Exception):
    """Base exception for all geo-targeting system errors."""
    
    def __init__(self, message: str, error_code: str = None, context: dict = None):
        """Initialize the exception.
        
        Args:
            message: Human-readable error message
            error_code: Machine-readable error code for categorization
            context: Additional context information for debugging
        """
        super().__init__(message)
        self.message = message
        self.error_code = error_code or 'UNKNOWN_ERROR'
        self.context = context or {}
        self.timestamp = datetime.now()
    
    def to_dict(self) -> dict:
        """Convert exception to dictionary for logging/serialization.
        
        Returns:
            Dictionary representation of the exception
        """
        return {
            'error_type': self.__class__.__name__,
            'message': self.message,
            'error_code': self.error_code,
            'context': self.context,
            'timestamp': self.timestamp.isoformat()
        }


class GeolocationProviderError(GeoTargetingError):
    """Error from geolocation provider (network, API, etc.)."""
    
    def __init__(self, message: str, provider: str = None, context: dict = None):
        """Initialize the exception.
        
        Args:
            message: Error message
            provider: Name of the provider that failed
            context: Additional context
        """
        context = context or {}
        if provider:
            context['provider'] = provider
        
        super().__init__(
            message,
            error_code='PROVIDER_ERROR',
            context=context
        )


class CacheError(GeoTargetingError):
    """Error from cache system (Redis, Memcached, etc.)."""
    
    def __init__(self, message: str, operation: str = None, context: dict = None):
        """Initialize the exception.
        
        Args:
            message: Error message
            operation: Cache operation that failed (get, set, delete)
            context: Additional context
        """
        context = context or {}
        if operation:
            context['operation'] = operation
        
        super().__init__(
            message,
            error_code='CACHE_ERROR',
            context=context
        )


class InvalidIPError(GeoTargetingError):
    """Error for invalid IP address."""
    
    def __init__(self, ip_address: str, reason: str = None):
        """Initialize the exception.
        
        Args:
            ip_address: The invalid IP address
            reason: Reason why the IP is invalid
        """
        context = {'ip_address': ip_address}
        if reason:
            context['reason'] = reason
        
        super().__init__(
            f"Invalid IP address: {ip_address}",
            error_code='INVALID_IP',
            context=context
        )


class NetworkTimeoutError(GeoTargetingError):
    """Error for network timeout during provider lookup."""
    
    def __init__(self, provider: str = None, timeout_seconds: float = None):
        """Initialize the exception.
        
        Args:
            provider: Name of the provider that timed out
            timeout_seconds: Timeout duration in seconds
        """
        context = {}
        if provider:
            context['provider'] = provider
        if timeout_seconds:
            context['timeout_seconds'] = timeout_seconds
        
        super().__init__(
            f"Network timeout during geolocation lookup",
            error_code='NETWORK_TIMEOUT',
            context=context
        )


class ErrorRecoveryStrategy:
    """Strategy for recovering from errors with fallback mechanisms."""
    
    # Error recovery configuration
    MAX_RETRIES = 3
    RETRY_DELAY_SECONDS = 1
    CIRCUIT_BREAKER_THRESHOLD = 5  # Errors before circuit opens
    CIRCUIT_BREAKER_TIMEOUT_SECONDS = 60  # How long circuit stays open
    
    def __init__(self):
        """Initialize the error recovery strategy."""
        self.error_counts = {}  # Track errors per provider
        self.circuit_breakers = {}  # Track circuit breaker state
    
    def should_retry(self, error: GeoTargetingError, attempt: int) -> bool:
        """Determine if an operation should be retried.
        
        Args:
            error: The error that occurred
            attempt: Current attempt number (1-based)
            
        Returns:
            True if should retry, False otherwise
        """
        # Don't retry invalid IP errors
        if isinstance(error, InvalidIPError):
            return False
        
        # Don't retry if max retries exceeded
        if attempt >= self.MAX_RETRIES:
            return False
        
        # Retry on provider and network errors
        if isinstance(error, (GeolocationProviderError, NetworkTimeoutError, CacheError)):
            return True
        
        return False
    
    def is_circuit_open(self, provider: str) -> bool:
        """Check if circuit breaker is open for a provider.
        
        Args:
            provider: Provider name
            
        Returns:
            True if circuit is open, False otherwise
        """
        if provider not in self.circuit_breakers:
            return False
        
        breaker_state = self.circuit_breakers[provider]
        
        # Check if circuit breaker has expired
        if datetime.now() > breaker_state['opened_at'] + timedelta(
            seconds=self.CIRCUIT_BREAKER_TIMEOUT_SECONDS
        ):
            # Circuit breaker timeout expired, reset it
            del self.circuit_breakers[provider]
            return False
        
        return True
    
    def record_error(self, provider: str):
        """Record an error for a provider.
        
        Args:
            provider: Provider name
        """
        if provider not in self.error_counts:
            self.error_counts[provider] = 0
        
        self.error_counts[provider] += 1
        
        # Open circuit breaker if threshold exceeded
        if self.error_counts[provider] >= self.CIRCUIT_BREAKER_THRESHOLD:
            self.circuit_breakers[provider] = {
                'opened_at': datetime.now(),
                'error_count': self.error_counts[provider]
            }
            logger.warning(
                f"Circuit breaker opened for provider {provider} "
                f"after {self.error_counts[provider]} errors"
            )
    
    def record_success(self, provider: str):
        """Record a successful operation for a provider.
        
        Args:
            provider: Provider name
        """
        # Reset error count on success
        if provider in self.error_counts:
            self.error_counts[provider] = 0
        
        # Close circuit breaker on success
        if provider in self.circuit_breakers:
            del self.circuit_breakers[provider]
            logger.info(f"Circuit breaker closed for provider {provider}")


class ErrorLogger:
    """Centralized error logging with context tracking."""
    
    # Error log cache key
    ERROR_LOG_CACHE_KEY = 'geo:error_log'
    ERROR_LOG_RETENTION_HOURS = 24
    
    def __init__(self):
        """Initialize the error logger."""
        self.logger = logging.getLogger('cms.geo.errors')
    
    def log_error(self, error: GeoTargetingError, context: dict = None):
        """Log an error with full context.
        
        Args:
            error: The error to log
            context: Additional context information
        """
        full_context = error.context.copy()
        if context:
            full_context.update(context)
        
        # Log to Django logger
        self.logger.error(
            f"{error.error_code}: {error.message}",
            extra={
                'error_code': error.error_code,
                'context': full_context,
                'timestamp': error.timestamp.isoformat()
            }
        )
        
        # Store in cache for error summary reports
        self._store_error_in_cache(error, full_context)
    
    def _store_error_in_cache(self, error: GeoTargetingError, context: dict):
        """Store error in cache for later retrieval.
        
        Args:
            error: The error to store
            context: Error context
        """
        try:
            # Get existing error log from cache
            error_log = cache.get(self.ERROR_LOG_CACHE_KEY, [])
            
            # Add new error
            error_entry = {
                'error_code': error.error_code,
                'message': error.message,
                'context': context,
                'timestamp': error.timestamp.isoformat()
            }
            error_log.append(error_entry)
            
            # Keep only recent errors
            max_entries = 1000
            if len(error_log) > max_entries:
                error_log = error_log[-max_entries:]
            
            # Store back in cache
            cache.set(
                self.ERROR_LOG_CACHE_KEY,
                error_log,
                self.ERROR_LOG_RETENTION_HOURS * 3600
            )
            
        except Exception as e:
            self.logger.error(f"Failed to store error in cache: {str(e)}")
    
    def get_error_summary(self) -> dict:
        """Get a summary of recent errors.
        
        Returns:
            Dictionary with error statistics
        """
        try:
            error_log = cache.get(self.ERROR_LOG_CACHE_KEY, [])
            
            if not error_log:
                return {
                    'total_errors': 0,
                    'errors_by_code': {},
                    'recent_errors': []
                }
            
            # Count errors by code
            errors_by_code = {}
            for entry in error_log:
                code = entry['error_code']
                errors_by_code[code] = errors_by_code.get(code, 0) + 1
            
            # Get recent errors (last 10)
            recent_errors = error_log[-10:]
            
            return {
                'total_errors': len(error_log),
                'errors_by_code': errors_by_code,
                'recent_errors': recent_errors
            }
            
        except Exception as e:
            self.logger.error(f"Failed to get error summary: {str(e)}")
            return {
                'total_errors': 0,
                'errors_by_code': {},
                'recent_errors': [],
                'error': str(e)
            }
    
    def clear_error_log(self):
        """Clear the error log cache."""
        try:
            cache.delete(self.ERROR_LOG_CACHE_KEY)
            self.logger.info("Error log cleared")
        except Exception as e:
            self.logger.error(f"Failed to clear error log: {str(e)}")


def with_error_handling(
    fallback_value: Any = None,
    error_logger: Optional[ErrorLogger] = None,
    recovery_strategy: Optional[ErrorRecoveryStrategy] = None
):
    """Decorator for adding error handling to functions.
    
    This decorator:
    - Catches exceptions and converts them to GeoTargetingError
    - Logs errors with context
    - Implements retry logic
    - Returns fallback value on failure
    
    Args:
        fallback_value: Value to return if function fails
        error_logger: ErrorLogger instance for logging
        recovery_strategy: ErrorRecoveryStrategy instance for retries
        
    Returns:
        Decorated function with error handling
    """
    error_logger = error_logger or ErrorLogger()
    recovery_strategy = recovery_strategy or ErrorRecoveryStrategy()
    
    def decorator(func: Callable) -> Callable:
        @wraps(func)
        def wrapper(*args, **kwargs):
            attempt = 0
            last_error = None
            
            while attempt < recovery_strategy.MAX_RETRIES:
                attempt += 1
                
                try:
                    result = func(*args, **kwargs)
                    
                    # Record success if this is a provider function
                    provider = kwargs.get('provider') or args[0] if args else None
                    if provider:
                        recovery_strategy.record_success(provider)
                    
                    return result
                    
                except GeoTargetingError as e:
                    last_error = e
                    
                    # Log the error
                    error_logger.log_error(e, {'attempt': attempt, 'function': func.__name__})
                    
                    # Record error for circuit breaker
                    provider = e.context.get('provider')
                    if provider:
                        recovery_strategy.record_error(provider)
                    
                    # Check if should retry
                    if not recovery_strategy.should_retry(e, attempt):
                        break
                    
                    # Wait before retry
                    import time
                    time.sleep(recovery_strategy.RETRY_DELAY_SECONDS)
                    
                except Exception as e:
                    # Convert unexpected exceptions to GeoTargetingError
                    geo_error = GeoTargetingError(
                        f"Unexpected error in {func.__name__}: {str(e)}",
                        error_code='UNEXPECTED_ERROR',
                        context={'function': func.__name__, 'attempt': attempt}
                    )
                    last_error = geo_error
                    
                    # Log the error
                    error_logger.log_error(geo_error)
                    
                    # Don't retry unexpected errors
                    break
            
            # All retries exhausted, return fallback value
            if last_error:
                error_logger.log_error(
                    last_error,
                    {'status': 'max_retries_exceeded', 'function': func.__name__}
                )
            
            return fallback_value
        
        return wrapper
    
    return decorator


# Global instances
_error_logger = None
_recovery_strategy = None


def get_error_logger() -> ErrorLogger:
    """Get the global error logger instance.
    
    Returns:
        ErrorLogger instance
    """
    global _error_logger
    if _error_logger is None:
        _error_logger = ErrorLogger()
    return _error_logger


def get_recovery_strategy() -> ErrorRecoveryStrategy:
    """Get the global error recovery strategy instance.
    
    Returns:
        ErrorRecoveryStrategy instance
    """
    global _recovery_strategy
    if _recovery_strategy is None:
        _recovery_strategy = ErrorRecoveryStrategy()
    return _recovery_strategy
