#!/usr/bin/env python3
"""
Private Gallery Encryption Module

Provides security features for the Private Gallery:
- Password hashing with bcrypt
- Key derivation with Argon2id
- File encryption/decryption with AES-256-GCM
- Field encryption with Fernet
- Session token management
"""

import os
import secrets
import hashlib
import base64
import time
from datetime import datetime, timedelta
from typing import Optional, Dict, Tuple
from pathlib import Path
from threading import Lock

try:
    import bcrypt
except ImportError:
    bcrypt = None

try:
    from argon2 import PasswordHasher
    from argon2.low_level import hash_secret_raw, Type
    ARGON2_AVAILABLE = True
except ImportError:
    ARGON2_AVAILABLE = False

try:
    from cryptography.fernet import Fernet
    from cryptography.hazmat.primitives.ciphers.aead import AESGCM
    from cryptography.hazmat.primitives import hashes
    from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
    CRYPTO_AVAILABLE = True
except ImportError:
    CRYPTO_AVAILABLE = False

from modules.universal_logger import get_logger

logger = get_logger('PrivateGalleryCrypto')


class PrivateGalleryCrypto:
    """
    Handles all encryption operations for the Private Gallery.

    Security features:
    - Passwords hashed with bcrypt (cost factor 12)
    - Encryption key derived from password using Argon2id
    - Files encrypted with AES-256-GCM
    - Database fields encrypted with Fernet (AES-128-CBC + HMAC)
    - Session tokens with configurable timeout
    """

    # Argon2id parameters (OWASP recommended)
    ARGON2_TIME_COST = 3
    ARGON2_MEMORY_COST = 65536  # 64 MiB
    ARGON2_PARALLELISM = 4
    ARGON2_HASH_LENGTH = 32  # 256 bits for AES-256

    # AES-GCM parameters
    AES_KEY_SIZE = 32  # 256 bits
    AES_NONCE_SIZE = 12  # 96 bits (GCM recommended)
    AES_TAG_SIZE = 16  # 128 bits

    # Encryption chunk size for streaming large files
    CHUNK_SIZE = 8 * 1024 * 1024  # 8 MB chunks
    CHUNKED_THRESHOLD = 50 * 1024 * 1024  # Use chunked encryption for files > 50 MB
    CHUNKED_MAGIC = b'\x01PGCE'  # Magic bytes: version 1, Private Gallery Chunked Encryption

    def __init__(self):
        self._sessions: Dict[str, Dict] = {}  # token -> {expiry, username}
        self._session_lock = Lock()
        self._derived_key: Optional[bytes] = None
        self._fernet: Optional[Fernet] = None
        self._aesgcm: Optional[AESGCM] = None

        # Check dependencies
        if not bcrypt:
            logger.warning("bcrypt not available - password hashing will use fallback")
        if not ARGON2_AVAILABLE:
            logger.warning("argon2-cffi not available - key derivation will use PBKDF2")
        if not CRYPTO_AVAILABLE:
            raise ImportError("cryptography library required for Private Gallery")

    # =========================================================================
    # PASSWORD HASHING (bcrypt)
    # =========================================================================

    def hash_password(self, password: str) -> str:
        """
        Hash a password using bcrypt with cost factor 12.

        Args:
            password: Plain text password

        Returns:
            bcrypt hash string (includes salt)
        """
        if bcrypt:
            salt = bcrypt.gensalt(rounds=12)
            hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
            return hashed.decode('utf-8')
        else:
            # Fallback to PBKDF2 if bcrypt not available
            salt = secrets.token_bytes(16)
            kdf = PBKDF2HMAC(
                algorithm=hashes.SHA256(),
                length=32,
                salt=salt,
                iterations=600000,
            )
            key = kdf.derive(password.encode('utf-8'))
            return f"pbkdf2${base64.b64encode(salt).decode()}${base64.b64encode(key).decode()}"

    def verify_password(self, password: str, password_hash: str) -> bool:
        """
        Verify a password against its hash.

        Args:
            password: Plain text password to check
            password_hash: Stored hash to verify against

        Returns:
            True if password matches
        """
        try:
            if password_hash.startswith('pbkdf2$'):
                # PBKDF2 fallback hash
                parts = password_hash.split('$')
                if len(parts) != 3:
                    return False
                salt = base64.b64decode(parts[1])
                stored_key = base64.b64decode(parts[2])
                kdf = PBKDF2HMAC(
                    algorithm=hashes.SHA256(),
                    length=32,
                    salt=salt,
                    iterations=600000,
                )
                try:
                    kdf.verify(password.encode('utf-8'), stored_key)
                    return True
                except Exception:
                    return False
            elif bcrypt:
                return bcrypt.checkpw(
                    password.encode('utf-8'),
                    password_hash.encode('utf-8')
                )
            else:
                return False
        except Exception as e:
            logger.error(f"Password verification failed: {e}")
            return False

    # =========================================================================
    # KEY DERIVATION (Argon2id or PBKDF2)
    # =========================================================================

    def derive_key(self, password: str, salt: bytes) -> bytes:
        """
        Derive an encryption key from password using Argon2id.

        Args:
            password: User's password
            salt: Random salt (should be stored)

        Returns:
            32-byte derived key for AES-256
        """
        if ARGON2_AVAILABLE:
            key = hash_secret_raw(
                secret=password.encode('utf-8'),
                salt=salt,
                time_cost=self.ARGON2_TIME_COST,
                memory_cost=self.ARGON2_MEMORY_COST,
                parallelism=self.ARGON2_PARALLELISM,
                hash_len=self.ARGON2_HASH_LENGTH,
                type=Type.ID  # Argon2id
            )
            return key
        else:
            # Fallback to PBKDF2 with high iterations
            kdf = PBKDF2HMAC(
                algorithm=hashes.SHA256(),
                length=self.AES_KEY_SIZE,
                salt=salt,
                iterations=600000,  # OWASP recommended minimum
            )
            return kdf.derive(password.encode('utf-8'))

    def generate_salt(self) -> bytes:
        """Generate a cryptographically secure random salt."""
        return secrets.token_bytes(16)

    def initialize_encryption(self, password: str, salt: bytes) -> None:
        """
        Initialize encryption with derived key.
        Must be called after successful unlock.

        Args:
            password: User's password
            salt: Stored salt for key derivation
        """
        self._derived_key = self.derive_key(password, salt)

        # Initialize Fernet for field encryption
        # Fernet requires a 32-byte key, base64-encoded
        fernet_key = base64.urlsafe_b64encode(self._derived_key)
        self._fernet = Fernet(fernet_key)

        # Initialize AES-GCM for file encryption
        self._aesgcm = AESGCM(self._derived_key)

        logger.info("Encryption initialized successfully")

    def clear_encryption(self) -> None:
        """Clear encryption keys from memory (on lock)."""
        self._derived_key = None
        self._fernet = None
        self._aesgcm = None
        logger.info("Encryption keys cleared")

    def is_initialized(self) -> bool:
        """Check if encryption is initialized (unlocked)."""
        return self._derived_key is not None

    # =========================================================================
    # FIELD ENCRYPTION (Fernet - for database fields)
    # =========================================================================

    def encrypt_field(self, plaintext: str) -> str:
        """
        Encrypt a database field value.

        Args:
            plaintext: Plain text to encrypt

        Returns:
            Base64-encoded encrypted string
        """
        if not self._fernet:
            raise RuntimeError("Encryption not initialized - call initialize_encryption first")

        if not plaintext:
            return ""

        encrypted = self._fernet.encrypt(plaintext.encode('utf-8'))
        return base64.urlsafe_b64encode(encrypted).decode('utf-8')

    def decrypt_field(self, ciphertext: str) -> str:
        """
        Decrypt a database field value.

        Args:
            ciphertext: Base64-encoded encrypted string

        Returns:
            Decrypted plain text
        """
        if not self._fernet:
            raise RuntimeError("Encryption not initialized - call initialize_encryption first")

        if not ciphertext:
            return ""

        try:
            encrypted = base64.urlsafe_b64decode(ciphertext.encode('utf-8'))
            decrypted = self._fernet.decrypt(encrypted)
            return decrypted.decode('utf-8')
        except Exception as e:
            logger.error(f"Field decryption failed: {e}")
            return "[Decryption Error]"

    # =========================================================================
    # FILE ENCRYPTION (AES-256-GCM)
    # =========================================================================

    def encrypt_file(self, input_path: Path, output_path: Path) -> bool:
        """
        Encrypt a file using AES-256-GCM.

        Small files (<=50MB): single-shot format
            [12-byte nonce][encrypted data + 16-byte tag]

        Large files (>50MB): chunked format for memory efficiency
            [5-byte magic 0x01PGCE][4-byte chunk_size BE]
            [12-byte nonce][encrypted chunk + 16-byte tag] (repeated)

        Args:
            input_path: Path to plaintext file
            output_path: Path for encrypted output

        Returns:
            True if successful
        """
        if not self._aesgcm:
            raise RuntimeError("Encryption not initialized")

        try:
            file_size = input_path.stat().st_size
            output_path.parent.mkdir(parents=True, exist_ok=True)

            if file_size <= self.CHUNKED_THRESHOLD:
                # Small file: single-shot encryption (backward compatible)
                nonce = secrets.token_bytes(self.AES_NONCE_SIZE)
                with open(input_path, 'rb') as f:
                    plaintext = f.read()
                ciphertext = self._aesgcm.encrypt(nonce, plaintext, None)
                with open(output_path, 'wb') as f:
                    f.write(nonce)
                    f.write(ciphertext)
            else:
                # Large file: chunked encryption
                import struct
                with open(input_path, 'rb') as fin, open(output_path, 'wb') as fout:
                    # Write header
                    fout.write(self.CHUNKED_MAGIC)
                    fout.write(struct.pack('>I', self.CHUNK_SIZE))

                    # Encrypt in chunks
                    while True:
                        chunk = fin.read(self.CHUNK_SIZE)
                        if not chunk:
                            break
                        nonce = secrets.token_bytes(self.AES_NONCE_SIZE)
                        encrypted_chunk = self._aesgcm.encrypt(nonce, chunk, None)
                        # Write chunk: nonce + encrypted data (includes GCM tag)
                        fout.write(nonce)
                        fout.write(struct.pack('>I', len(encrypted_chunk)))
                        fout.write(encrypted_chunk)

            return True

        except Exception as e:
            logger.error(f"File encryption failed: {e}")
            # Clean up partial output
            if output_path.exists():
                try:
                    output_path.unlink()
                except Exception:
                    pass
            return False

    def _is_chunked_format(self, input_path: Path) -> bool:
        """Check if an encrypted file uses the chunked format."""
        try:
            with open(input_path, 'rb') as f:
                magic = f.read(len(self.CHUNKED_MAGIC))
                return magic == self.CHUNKED_MAGIC
        except Exception:
            return False

    def decrypt_file(self, input_path: Path, output_path: Optional[Path] = None) -> Optional[bytes]:
        """
        Decrypt a file encrypted with AES-256-GCM.
        Handles both single-shot and chunked formats.

        Args:
            input_path: Path to encrypted file
            output_path: Optional path to write decrypted file

        Returns:
            Decrypted bytes if output_path is None, else None on success
        """
        if not self._aesgcm:
            raise RuntimeError("Encryption not initialized")

        try:
            if self._is_chunked_format(input_path):
                return self._decrypt_file_chunked(input_path, output_path)

            # Single-shot format: [nonce][ciphertext+tag]
            with open(input_path, 'rb') as f:
                nonce = f.read(self.AES_NONCE_SIZE)
                if len(nonce) != self.AES_NONCE_SIZE:
                    raise ValueError("Invalid encrypted file: missing nonce")
                ciphertext = f.read()

            plaintext = self._aesgcm.decrypt(nonce, ciphertext, None)

            if output_path:
                output_path.parent.mkdir(parents=True, exist_ok=True)
                with open(output_path, 'wb') as f:
                    f.write(plaintext)
                return None

            return plaintext

        except Exception as e:
            logger.error(f"File decryption failed: {e}")
            return None

    def _decrypt_file_chunked(self, input_path: Path, output_path: Optional[Path] = None) -> Optional[bytes]:
        """Decrypt a chunked-format encrypted file."""
        import struct

        try:
            parts = [] if output_path is None else None

            with open(input_path, 'rb') as fin:
                # Read header
                magic = fin.read(len(self.CHUNKED_MAGIC))
                if magic != self.CHUNKED_MAGIC:
                    raise ValueError("Invalid chunked file header")
                chunk_size_bytes = fin.read(4)
                # chunk_size from header (informational, actual sizes are per-chunk)
                struct.unpack('>I', chunk_size_bytes)

                fout = None
                if output_path:
                    output_path.parent.mkdir(parents=True, exist_ok=True)
                    fout = open(output_path, 'wb')

                try:
                    while True:
                        # Read chunk: [12-byte nonce][4-byte encrypted_len][encrypted data]
                        nonce = fin.read(self.AES_NONCE_SIZE)
                        if len(nonce) == 0:
                            break  # EOF
                        if len(nonce) != self.AES_NONCE_SIZE:
                            raise ValueError("Truncated chunk nonce")

                        enc_len_bytes = fin.read(4)
                        if len(enc_len_bytes) != 4:
                            raise ValueError("Truncated chunk length")
                        enc_len = struct.unpack('>I', enc_len_bytes)[0]

                        encrypted_chunk = fin.read(enc_len)
                        if len(encrypted_chunk) != enc_len:
                            raise ValueError("Truncated chunk data")

                        decrypted_chunk = self._aesgcm.decrypt(nonce, encrypted_chunk, None)

                        if fout:
                            fout.write(decrypted_chunk)
                        else:
                            parts.append(decrypted_chunk)
                finally:
                    if fout:
                        fout.close()

            if output_path:
                return None
            return b''.join(parts)

        except Exception as e:
            logger.error(f"Chunked file decryption failed for {input_path}: {type(e).__name__}: {e}")
            return None

    def re_encrypt_to_chunked(self, file_path: Path) -> bool:
        """
        Re-encrypt a single-shot encrypted file to chunked format in-place.
        Decrypts and re-encrypts in chunks to avoid loading the entire file into memory.

        Args:
            file_path: Path to the single-shot encrypted file

        Returns:
            True if successful, False if already chunked or on error
        """
        if not self._aesgcm:
            raise RuntimeError("Encryption not initialized")

        if self._is_chunked_format(file_path):
            return False  # Already chunked

        import struct
        temp_path = file_path.with_suffix(f'.enc.{secrets.token_hex(4)}.tmp')

        try:
            # Decrypt the single-shot file fully (required by AES-GCM)
            with open(file_path, 'rb') as f:
                nonce = f.read(self.AES_NONCE_SIZE)
                if len(nonce) != self.AES_NONCE_SIZE:
                    raise ValueError("Invalid encrypted file")
                ciphertext = f.read()

            plaintext = self._aesgcm.decrypt(nonce, ciphertext, None)
            del ciphertext  # Free memory

            # Write chunked format to temp file
            with open(temp_path, 'wb') as fout:
                fout.write(self.CHUNKED_MAGIC)
                fout.write(struct.pack('>I', self.CHUNK_SIZE))

                offset = 0
                while offset < len(plaintext):
                    chunk = plaintext[offset:offset + self.CHUNK_SIZE]
                    offset += len(chunk)
                    chunk_nonce = secrets.token_bytes(self.AES_NONCE_SIZE)
                    encrypted_chunk = self._aesgcm.encrypt(chunk_nonce, chunk, None)
                    fout.write(chunk_nonce)
                    fout.write(struct.pack('>I', len(encrypted_chunk)))
                    fout.write(encrypted_chunk)

            del plaintext  # Free memory

            # Atomic replace
            temp_path.replace(file_path)
            return True

        except Exception as e:
            logger.error(f"Re-encryption to chunked failed for {file_path}: {e}")
            if temp_path.exists():
                try:
                    temp_path.unlink()
                except Exception:
                    pass
            return False

    def decrypt_file_streaming(self, input_path: Path) -> Optional[bytes]:
        """
        Decrypt a file and return bytes for streaming.
        Only suitable for small files (single-shot format, ≤50MB).
        For large chunked files, use decrypt_file_generator() instead.

        Args:
            input_path: Path to encrypted file

        Returns:
            Decrypted bytes or None on error
        """
        return self.decrypt_file(input_path, output_path=None)

    def decrypt_file_generator(self, input_path: Path):
        """
        Generator that yields decrypted chunks for streaming large files.
        For chunked files, yields one decrypted chunk at a time (~8MB each).
        For single-shot files, yields the entire content at once.

        Args:
            input_path: Path to encrypted file

        Yields:
            bytes: Decrypted data chunks
        """
        import struct

        if not self._aesgcm:
            raise RuntimeError("Encryption not initialized")

        if self._is_chunked_format(input_path):
            with open(input_path, 'rb') as fin:
                # Skip header
                fin.read(len(self.CHUNKED_MAGIC))
                fin.read(4)

                while True:
                    nonce = fin.read(self.AES_NONCE_SIZE)
                    if len(nonce) == 0:
                        break
                    if len(nonce) != self.AES_NONCE_SIZE:
                        raise ValueError("Truncated chunk nonce")

                    enc_len_bytes = fin.read(4)
                    if len(enc_len_bytes) != 4:
                        raise ValueError("Truncated chunk length")
                    enc_len = struct.unpack('>I', enc_len_bytes)[0]

                    encrypted_chunk = fin.read(enc_len)
                    if len(encrypted_chunk) != enc_len:
                        raise ValueError("Truncated chunk data")

                    yield self._aesgcm.decrypt(nonce, encrypted_chunk, None)
        else:
            # Single-shot: yield everything at once (≤50MB)
            with open(input_path, 'rb') as f:
                nonce = f.read(self.AES_NONCE_SIZE)
                if len(nonce) != self.AES_NONCE_SIZE:
                    raise ValueError("Invalid encrypted file: missing nonce")
                ciphertext = f.read()
            yield self._aesgcm.decrypt(nonce, ciphertext, None)

    def decrypt_file_range_generator(self, input_path: Path, start: int, end: int):
        """
        Generator that yields only the decrypted bytes for a specific byte range.
        For chunked files, only decrypts the necessary chunks and slices them.
        For single-shot files, decrypts all and slices.

        Args:
            input_path: Path to encrypted file
            start: Start byte offset (inclusive)
            end: End byte offset (inclusive)

        Yields:
            bytes: Decrypted data for the requested range
        """
        import struct

        if not self._aesgcm:
            raise RuntimeError("Encryption not initialized")

        if not self._is_chunked_format(input_path):
            # Single-shot: decrypt all and slice (file is ≤50MB)
            with open(input_path, 'rb') as f:
                nonce = f.read(self.AES_NONCE_SIZE)
                ciphertext = f.read()
            plaintext = self._aesgcm.decrypt(nonce, ciphertext, None)
            yield plaintext[start:end + 1]
            return

        chunk_size = self.CHUNK_SIZE
        first_chunk = start // chunk_size
        last_chunk = end // chunk_size

        # Header: 5 magic + 4 chunk_size = 9 bytes
        header_size = len(self.CHUNKED_MAGIC) + 4
        # Each full encrypted chunk: 12 nonce + 4 length + (chunk_size + 16 tag)
        enc_chunk_stride = self.AES_NONCE_SIZE + 4 + chunk_size + self.AES_TAG_SIZE

        with open(input_path, 'rb') as fin:
            for chunk_idx in range(first_chunk, last_chunk + 1):
                # Seek to this chunk's position in the encrypted file
                fin.seek(header_size + chunk_idx * enc_chunk_stride)

                nonce = fin.read(self.AES_NONCE_SIZE)
                if len(nonce) == 0:
                    break
                if len(nonce) != self.AES_NONCE_SIZE:
                    raise ValueError("Truncated chunk nonce")

                enc_len_bytes = fin.read(4)
                if len(enc_len_bytes) != 4:
                    raise ValueError("Truncated chunk length")
                enc_len = struct.unpack('>I', enc_len_bytes)[0]

                encrypted_chunk = fin.read(enc_len)
                if len(encrypted_chunk) != enc_len:
                    raise ValueError("Truncated chunk data")

                decrypted_chunk = self._aesgcm.decrypt(nonce, encrypted_chunk, None)

                # Calculate which part of this chunk we need
                chunk_start_byte = chunk_idx * chunk_size
                slice_start = max(start - chunk_start_byte, 0)
                slice_end = min(end - chunk_start_byte + 1, len(decrypted_chunk))

                yield decrypted_chunk[slice_start:slice_end]

    # =========================================================================
    # SESSION MANAGEMENT
    # =========================================================================

    def create_session(self, username: str = "user", timeout_minutes: int = 30) -> str:
        """
        Create a new session token.

        Args:
            username: Username for the session
            timeout_minutes: Session timeout in minutes

        Returns:
            Session token string
        """
        token = secrets.token_urlsafe(32)
        expiry = datetime.now() + timedelta(minutes=timeout_minutes)

        with self._session_lock:
            self._sessions[token] = {
                'expiry': expiry,
                'username': username,
                'created_at': datetime.now()
            }

        logger.info(f"Created session for {username}, expires in {timeout_minutes} minutes")
        return token

    def verify_session(self, token: str) -> Optional[Dict]:
        """
        Verify a session token is valid and not expired.

        Args:
            token: Session token to verify

        Returns:
            Session info dict if valid, None otherwise
        """
        with self._session_lock:
            session = self._sessions.get(token)

            if not session:
                return None

            if datetime.now() > session['expiry']:
                # Expired - remove it
                del self._sessions[token]
                return None

            return session

    def refresh_session(self, token: str, timeout_minutes: int = 30) -> bool:
        """
        Refresh a session's expiry time.

        Args:
            token: Session token to refresh
            timeout_minutes: New timeout in minutes

        Returns:
            True if refreshed, False if token invalid
        """
        with self._session_lock:
            session = self._sessions.get(token)

            if not session:
                return False

            if datetime.now() > session['expiry']:
                del self._sessions[token]
                return False

            session['expiry'] = datetime.now() + timedelta(minutes=timeout_minutes)
            return True

    def invalidate_session(self, token: str) -> bool:
        """
        Invalidate a session token (logout/lock).

        Args:
            token: Session token to invalidate

        Returns:
            True if invalidated, False if not found
        """
        with self._session_lock:
            if token in self._sessions:
                del self._sessions[token]
                return True
            return False

    def invalidate_all_sessions(self) -> int:
        """
        Invalidate all sessions (master lock).

        Returns:
            Number of sessions invalidated
        """
        with self._session_lock:
            count = len(self._sessions)
            self._sessions.clear()
            return count

    def cleanup_expired_sessions(self) -> int:
        """
        Remove all expired sessions.

        Returns:
            Number of sessions removed
        """
        with self._session_lock:
            now = datetime.now()
            expired = [t for t, s in self._sessions.items() if now > s['expiry']]
            for token in expired:
                del self._sessions[token]
            return len(expired)

    def get_active_session_count(self) -> int:
        """Get count of active (non-expired) sessions."""
        self.cleanup_expired_sessions()
        return len(self._sessions)


# Global instance
_crypto_instance: Optional[PrivateGalleryCrypto] = None
_crypto_lock = Lock()


def get_private_gallery_crypto() -> PrivateGalleryCrypto:
    """Get or create the global crypto instance."""
    global _crypto_instance

    with _crypto_lock:
        if _crypto_instance is None:
            _crypto_instance = PrivateGalleryCrypto()
        return _crypto_instance


def export_key_to_file(path: str) -> bool:
    """
    Save the current derived key from the global crypto instance to a file.
    The file is written with mode 0600 for security.

    Args:
        path: File path to write the key material to

    Returns:
        True if successful
    """
    import json as _json

    crypto = get_private_gallery_crypto()
    if not crypto.is_initialized() or crypto._derived_key is None:
        logger.warning("Cannot export key: encryption not initialized")
        return False

    try:
        key_data = {
            'derived_key': base64.b64encode(crypto._derived_key).decode('utf-8')
        }
        key_path = Path(path)
        key_path.parent.mkdir(parents=True, exist_ok=True)

        # Write atomically via temp file
        tmp_path = key_path.with_suffix('.tmp')
        with open(tmp_path, 'w') as f:
            _json.dump(key_data, f)
        os.chmod(str(tmp_path), 0o600)
        tmp_path.replace(key_path)

        logger.info(f"Exported encryption key to {path}")
        return True
    except Exception as e:
        logger.error(f"Failed to export key to {path}: {e}")
        return False


def load_key_from_file(path: str) -> Optional[PrivateGalleryCrypto]:
    """
    Load a derived key from a file and return an initialized crypto instance.

    Args:
        path: File path containing the key material

    Returns:
        Initialized PrivateGalleryCrypto instance, or None if unavailable
    """
    import json as _json

    key_path = Path(path)
    if not key_path.exists():
        return None

    try:
        with open(key_path, 'r') as f:
            key_data = _json.load(f)

        derived_key = base64.b64decode(key_data['derived_key'])

        crypto = PrivateGalleryCrypto()
        crypto._derived_key = derived_key

        # Initialize Fernet for field encryption
        fernet_key = base64.urlsafe_b64encode(derived_key)
        crypto._fernet = Fernet(fernet_key)

        # Initialize AES-GCM for file encryption
        crypto._aesgcm = AESGCM(derived_key)

        return crypto
    except Exception as e:
        logger.error(f"Failed to load key from {path}: {e}")
        return None


def delete_key_file(path: str) -> bool:
    """Delete the key file if it exists."""
    try:
        key_path = Path(path)
        if key_path.exists():
            key_path.unlink()
            logger.info(f"Deleted key file {path}")
        return True
    except Exception as e:
        logger.error(f"Failed to delete key file {path}: {e}")
        return False
