"""Dead letter queue for failed telemetry exports.

This module provides a bounded queue for storing telemetry that failed
all retry attempts, enabling post-mortem analysis and preventing infinite
retry loops.
"""

import errno
import json
import logging
import os
import tempfile
import threading
from collections import deque
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional

from ..security.encryption import EncryptionManager, get_encryption_key_from_env
from ..utils.exceptions import BufferingError

logger = logging.getLogger(__name__)

DEFAULT_DLQ_PERSIST_PATH = "~/.mca_sdk/dlq.json"
MAX_ITEM_SIZE_BYTES = 64 * 1024  # 64KB per item to prevent memory exhaustion


class DeadLetterQueue:
    """Dead letter queue for failed telemetry exports.

    Stores telemetry that failed all retry attempts for later investigation.
    Provides bounded storage with automatic eviction of oldest items and
    persistence to disk for post-mortem analysis.

    Thread Safety:
        All operations are protected by a threading.Lock to ensure
        safe concurrent access from multiple threads.

    Examples:
        >>> dlq = DeadLetterQueue(max_size=1000)
        >>> dlq.enqueue(
        ...     item={"metric": "model.predictions_total", "value": 1},
        ...     error=Exception("Connection timeout"),
        ...     retry_count=3
        ... )
        >>> items = dlq.inspect(limit=10)
        >>> dlq.size()
        1

        With custom persistence path:
        >>> dlq = DeadLetterQueue(
        ...     max_size=500,
        ...     persist_path="~/.mca_sdk/custom_dlq.json"
        ... )
    """

    def __init__(
        self,
        max_size: int = 1000,
        persist_path: str = DEFAULT_DLQ_PERSIST_PATH,
        encryption_key: Optional[bytes] = None,
        require_encryption: bool = False,
    ):
        """Initialize dead letter queue.

        Args:
            max_size: Maximum number of items in DLQ (default: 1000)
            persist_path: Path for DLQ persistence (default: ~/.mca_sdk/dlq.json)
            encryption_key: Optional encryption key for data at rest. If not provided,
                          will attempt to load from MCA_SDK_ENCRYPTION_KEY environment variable.
                          If no key is available, data will be stored unencrypted (not HIPAA compliant).
            require_encryption: If True, fail-fast when encryption cannot be initialized (default: False).
                              Set to True in production to enforce fail-secure behavior.

        Raises:
            BufferingError: If max_size is invalid or if require_encryption=True and encryption fails

        Example:
            >>> # Production mode with mandatory encryption
            >>> from mca_sdk.security import EncryptionManager
            >>> key = EncryptionManager.generate_key()
            >>> dlq = DeadLetterQueue(encryption_key=key, require_encryption=True)

            >>> # Dev mode (encryption optional)
            >>> dlq = DeadLetterQueue()  # Will warn about unencrypted storage

        Note:
            Items are serialized for inspection only. Not replay-safe due to
            non-serializable object conversion (default=str fallback).
        """
        if max_size <= 0:
            raise BufferingError(f"max_size must be positive, got {max_size}")

        self._max_size = max_size
        self._queue = deque(maxlen=max_size)
        self._persist_path = os.path.expanduser(persist_path)
        self._lock = threading.Lock()

        # Encryption setup with fail-secure option
        if encryption_key is None:
            encryption_key = get_encryption_key_from_env()

        if encryption_key:
            try:
                self._encryption_manager: Optional[EncryptionManager] = EncryptionManager(
                    encryption_key
                )
                logger.info("DLQ encryption at rest enabled")
            except Exception as e:
                if require_encryption:
                    raise BufferingError(
                        f"Encryption required but failed to initialize: {e}. "
                        "Cannot start DLQ without encryption in production mode."
                    )
                logger.error(f"Failed to initialize encryption: {e}. DLQ will be unencrypted.")
                self._encryption_manager = None
        else:
            if require_encryption:
                raise BufferingError(
                    "Encryption required but no key provided. "
                    "Set encryption_key parameter or MCA_SDK_ENCRYPTION_KEY environment variable."
                )
            self._encryption_manager = None

        # Metrics
        self._items_enqueued = 0
        self._items_evicted = 0
        self._persist_success_count = 0
        self._persist_failure_count = 0
        self._items_recovered = 0
        self._consecutive_persist_failures = 0
        self._max_consecutive_failures = 5  # Fail-fast after 5 consecutive failures

        # Load existing DLQ from disk if available
        self._load_from_disk()

        # CRITICAL: Warn about unencrypted storage (only if not required)
        if self._encryption_manager is None and not require_encryption:
            logger.warning(
                "⚠️  HIPAA COMPLIANCE WARNING ⚠️\n"
                "Dead Letter Queue persistence is enabled WITHOUT encryption at rest.\n"
                "DLQ may contain PHI and is stored UNENCRYPTED.\n"
                "To enable encryption, provide encryption_key parameter or set MCA_SDK_ENCRYPTION_KEY environment variable.\n"
                f"Unencrypted DLQ file: {self._persist_path}"
            )

    def enqueue(
        self,
        item: Any,
        error: Exception,
        retry_count: int,
    ) -> None:
        """Add failed item to DLQ with metadata.

        Thread-safe operation that adds a failed telemetry item to the DLQ
        with enriched metadata for investigation. If the DLQ is full, the
        oldest item is automatically evicted (FIFO).

        Args:
            item: Telemetry data that failed export
            error: Exception that caused the failure
            retry_count: Number of retry attempts made

        Example:
            >>> dlq.enqueue(
            ...     item={"metric": "model.latency_seconds", "value": 0.15},
            ...     error=ConnectionError("Collector unreachable"),
            ...     retry_count=3
            ... )
        """
        with self._lock:
            was_full = len(self._queue) >= self._max_size

            # BEST EFFORT MEMORY SAFETY: Check basic object size before serialization
            # This prevents massive objects (e.g. 1GB strings/bytes) from choking json.dumps
            # Note: This is not perfect for nested objects but catches the most common DoS vectors
            estimated_size = 0
            if isinstance(item, (str, bytes)):
                estimated_size = len(item)
            elif isinstance(item, dict):
                # Rough estimation for flat dicts or dicts with large string values
                estimated_size = sum(len(str(k)) + len(str(v)) for k, v in item.items())

            # If estimation alone exceeds limit (with 2x safety margin), truncate early
            if estimated_size > MAX_ITEM_SIZE_BYTES * 2:
                # Truncate early to avoid serialization cost
                logger.warning(
                    f"DLQ item rejected early: estimated size {estimated_size} bytes exceeds limit",
                    extra={"estimated_size": estimated_size, "max_size": MAX_ITEM_SIZE_BYTES},
                )
                item = {
                    "__truncated__": True,
                    "__reason__": "Early size check failed",
                    "__estimated_size__": estimated_size,
                }

            # Truncate large items to prevent memory exhaustion
            # Use default=str to handle non-serializable objects
            item_json = json.dumps(item, default=str)
            item_size_bytes = len(item_json.encode("utf-8"))

            if item_size_bytes > MAX_ITEM_SIZE_BYTES:
                # Truncate item and add warning
                truncated_item = {
                    "__truncated__": True,
                    "__original_size_bytes__": item_size_bytes,
                    # Keep half for debugging
                    "__truncated_data__": item_json[: MAX_ITEM_SIZE_BYTES // 2],
                }
                logger.warning(
                    f"DLQ item truncated: {item_size_bytes} bytes exceeds limit of {MAX_ITEM_SIZE_BYTES} bytes",
                    extra={
                        "item_size_bytes": item_size_bytes,
                        "max_size_bytes": MAX_ITEM_SIZE_BYTES,
                    },
                )
                item = truncated_item

            # Create DLQ item with metadata
            dlq_item = {
                "timestamp": datetime.now(timezone.utc).isoformat(),
                "item": item,
                "error": str(error),
                "error_type": type(error).__name__,
                "retry_count": retry_count,
            }

            # deque with maxlen automatically evicts oldest when full
            self._queue.append(dlq_item)

            # Track metrics
            self._items_enqueued += 1
            if was_full:
                self._items_evicted += 1

            # Persist to disk
            self._save_to_disk()

            # HIPAA compliance: Log error type only, not error message (may contain PHI)
            logger.error(
                f"Item moved to DLQ after {retry_count} retries",
                extra={
                    "dlq_size": len(self._queue),
                    "error_type": type(error).__name__,
                    "retry_count": retry_count,
                },
            )

    def inspect(self, limit: int = 10) -> List[Dict]:
        """Get recent DLQ items for inspection.

        Thread-safe operation that returns the most recent DLQ items
        for operational visibility and debugging.

        Args:
            limit: Maximum number of items to return (default: 10)

        Returns:
            List of DLQ items (most recent first), each containing:
                - timestamp: ISO 8601 timestamp of failure
                - item: Original telemetry data
                - error: Error message
                - error_type: Exception class name
                - retry_count: Number of retry attempts

        Example:
            >>> items = dlq.inspect(limit=5)
            >>> for item in items:
            ...     print(f"{item['timestamp']}: {item['error_type']}")
        """
        with self._lock:
            # Return most recent items (from end of deque)
            return list(self._queue)[-limit:]

    def size(self) -> int:
        """Get current DLQ size.

        Thread-safe operation that returns the number of items in DLQ.

        Returns:
            Current number of items in DLQ
        """
        with self._lock:
            return len(self._queue)

    def is_empty(self) -> bool:
        """Check if DLQ is empty.

        Thread-safe operation.

        Returns:
            True if DLQ is empty, False otherwise
        """
        with self._lock:
            return len(self._queue) == 0

    def is_full(self) -> bool:
        """Check if DLQ is at maximum capacity.

        Thread-safe operation.

        Returns:
            True if DLQ is full, False otherwise
        """
        with self._lock:
            return len(self._queue) >= self._max_size

    def clear(self) -> int:
        """Remove all items from DLQ.

        Thread-safe operation that empties the DLQ and persists
        the cleared state to disk.

        Returns:
            Number of items that were removed

        Example:
            >>> removed = dlq.clear()
            >>> print(f"Removed {removed} items from DLQ")
        """
        with self._lock:
            count = len(self._queue)
            self._queue.clear()

            # Persist cleared state to disk
            self._save_to_disk()

            if count > 0:
                logger.info(f"DLQ cleared: {count} items removed")

            return count

    def stats(self) -> Dict[str, Any]:
        """Get DLQ statistics for monitoring.

        Thread-safe operation that returns current DLQ metrics.

        Returns:
            Dictionary with DLQ statistics:
                - size: Current number of items in DLQ
                - max_size: Maximum DLQ capacity
                - is_full: Whether DLQ is at maximum capacity
                - is_empty: Whether DLQ is empty
                - utilization: DLQ utilization percentage (0-100)
                - items_enqueued: Total items added to DLQ
                - items_evicted: Total items evicted due to size limit
                - persist_success_count: Successful disk writes
                - persist_failure_count: Failed disk writes
                - items_recovered_on_startup: Items loaded from disk on startup
                - encryption_enabled: Whether encryption at rest is enabled

        Example:
            >>> stats = dlq.stats()
            >>> print(f"DLQ {stats['utilization']:.1f}% full")
            DLQ 42.5% full
        """
        with self._lock:
            current_size = len(self._queue)
            utilization = (current_size / self._max_size * 100) if self._max_size > 0 else 0

            return {
                "size": current_size,
                "max_size": self._max_size,
                "is_full": current_size >= self._max_size,
                "is_empty": current_size == 0,
                "utilization": round(utilization, 2),
                "items_enqueued": self._items_enqueued,
                "items_evicted": self._items_evicted,
                "persist_success_count": self._persist_success_count,
                "persist_failure_count": self._persist_failure_count,
                "items_recovered_on_startup": self._items_recovered,
                "encryption_enabled": self._encryption_manager is not None,
            }

    def _load_from_disk(self) -> None:
        """Load DLQ from disk on startup.

        Attempts to restore DLQ state from persisted file. If the file
        doesn't exist or is corrupted, starts with an empty DLQ.
        Supports both encrypted and unencrypted files.

        Thread Safety:
            Called only during __init__, before any concurrent access.
        """
        if not os.path.exists(self._persist_path):
            logger.debug(f"No existing DLQ file found at {self._persist_path}")
            return

        try:
            with open(
                self._persist_path,
                "rb" if self._encryption_manager else "r",
                encoding=None if self._encryption_manager else "utf-8",
            ) as f:
                file_data = f.read()

            # Decrypt if encryption is enabled
            if self._encryption_manager:
                try:
                    decrypted_data = self._encryption_manager.decrypt(file_data)
                    json_str = decrypted_data.decode("utf-8")
                    data = json.loads(json_str)
                except Exception as e:
                    # Data preservation: move unreadable file to .failed backup
                    # Add timestamp to prevent overwrites of previous backups
                    timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
                    failed_path = f"{self._persist_path}.failed.{timestamp}"
                    try:
                        import shutil

                        shutil.copy2(self._persist_path, failed_path)
                        logger.warning(
                            f"Failed to decrypt DLQ file: {type(e).__name__}. "
                            f"Original file preserved at {failed_path}. "
                            "This may occur after key rotation or corruption. "
                            "Starting with empty DLQ.",
                            extra={
                                "persist_path": self._persist_path,
                                "failed_path": failed_path,
                                "error_type": type(e).__name__,
                            },
                        )
                    except Exception as backup_error:
                        logger.error(
                            f"Failed to decrypt DLQ file AND failed to create backup: {type(e).__name__}. "
                            "Starting with empty DLQ.",
                            extra={
                                "persist_path": self._persist_path,
                                "decrypt_error": type(e).__name__,
                                "backup_error": type(backup_error).__name__,
                            },
                        )
                    return
            else:
                data = json.loads(file_data)

            # Validate loaded data
            if not isinstance(data, list):
                logger.warning(
                    f"DLQ file contains invalid data type: {type(data).__name__}. "
                    "Starting with empty DLQ."
                )
                return

            # Restore items (respecting max_size limit)
            for item in data[-self._max_size :]:
                self._queue.append(item)

            self._items_recovered = len(self._queue)

            logger.info(
                f"DLQ loaded from disk: {self._items_recovered} items recovered",
                extra={"dlq_size": self._items_recovered, "persist_path": self._persist_path},
            )

        except (json.JSONDecodeError, EOFError) as e:
            logger.error(
                f"Failed to load DLQ from disk (corrupted file): {type(e).__name__}. "
                "Starting with empty DLQ.",
                extra={"persist_path": self._persist_path, "error_type": type(e).__name__},
            )
        except Exception as e:
            logger.error(
                f"Failed to load DLQ from disk: {type(e).__name__}. Starting with empty DLQ.",
                extra={"persist_path": self._persist_path, "error_type": type(e).__name__},
            )

    def _save_to_disk(self) -> None:
        """Persist DLQ to disk using atomic write.

        Uses atomic write pattern (write to temp file, then rename) to
        prevent corruption from partial writes.

        Thread Safety:
            Must be called with self._lock held.
        """
        # Create parent directory with secure permissions (0700)
        parent_dir = os.path.dirname(self._persist_path)
        if parent_dir:
            try:
                os.makedirs(parent_dir, mode=0o700, exist_ok=True)
            except OSError as e:
                logger.error(
                    f"Failed to create DLQ directory: {type(e).__name__}",
                    extra={"persist_path": self._persist_path, "error_type": type(e).__name__},
                )
                self._persist_failure_count += 1
                return

        # Take snapshot of current queue
        snapshot = list(self._queue)

        # Atomic write: write to temp file, then rename
        temp_fd = None
        temp_path = None

        try:
            # Create temp file in same directory as target (ensures same filesystem)
            temp_fd, temp_path = tempfile.mkstemp(
                dir=parent_dir or ".", prefix=".dlq_", suffix=".tmp"
            )

            # Write to temp file (encrypted or plaintext)
            if self._encryption_manager:
                # Encrypt the JSON data
                # Use default=str to handle non-serializable objects safely
                json_str = json.dumps(snapshot, indent=2, ensure_ascii=False, default=str)
                encrypted_data = self._encryption_manager.encrypt(json_str.encode("utf-8"))
                with os.fdopen(temp_fd, "wb") as f:
                    temp_fd = None  # fdopen takes ownership
                    f.write(encrypted_data)
            else:
                # Write plaintext JSON
                # Use default=str to handle non-serializable objects safely
                with os.fdopen(temp_fd, "w", encoding="utf-8") as f:
                    temp_fd = None  # fdopen takes ownership
                    json.dump(snapshot, f, indent=2, ensure_ascii=False, default=str)

            # Atomic rename (overwrites existing file)
            # Use os.replace() for truly atomic operation on all platforms (Python ≥3.3)
            os.replace(temp_path, self._persist_path)
            temp_path = None  # Replace succeeded, no cleanup needed

            self._persist_success_count += 1
            self._consecutive_persist_failures = 0  # Reset on success

            logger.debug(
                f"DLQ persisted to disk: {len(snapshot)} items",
                extra={"dlq_size": len(snapshot), "persist_path": self._persist_path},
            )

        except OSError as e:
            self._persist_failure_count += 1
            self._consecutive_persist_failures += 1

            # Disk full is recoverable - log as warning
            if e.errno == errno.ENOSPC:
                logger.warning(
                    "Failed to persist DLQ: No space left on device (recoverable)",
                    extra={"persist_path": self._persist_path, "error_type": "ENOSPC"},
                )
            else:
                logger.error(
                    f"Failed to persist DLQ: {type(e).__name__}",
                    extra={"persist_path": self._persist_path, "error_type": type(e).__name__},
                )

            # Fail-fast after consecutive failures (indicates persistent issue)
            if self._consecutive_persist_failures >= self._max_consecutive_failures:
                raise BufferingError(
                    f"DLQ persistence failed {self._consecutive_persist_failures} consecutive times. "
                    f"Last error: {type(e).__name__}. This indicates a persistent storage issue."
                )

        except Exception as e:
            self._persist_failure_count += 1
            self._consecutive_persist_failures += 1

            logger.error(
                f"Failed to persist DLQ: {type(e).__name__}",
                extra={"persist_path": self._persist_path, "error_type": type(e).__name__},
            )

            # Fail-fast after consecutive failures
            if self._consecutive_persist_failures >= self._max_consecutive_failures:
                raise BufferingError(
                    f"DLQ persistence failed {self._consecutive_persist_failures} consecutive times. "
                    f"Last error: {type(e).__name__}. This indicates a persistent storage issue."
                )

        finally:
            # Cleanup temp file if write failed
            if temp_fd is not None:
                try:
                    os.close(temp_fd)
                except OSError:
                    pass

            if temp_path and os.path.exists(temp_path):
                try:
                    os.unlink(temp_path)
                except OSError:
                    pass
