# profile_picture_manager.py
"""
Intelligent profile picture manager for Miku.
Handles searching, face detection, cropping, and Discord avatar updates.

Supports both static images and animated GIFs:
- Static images (PNG, JPG, etc.): Full processing with face detection, smart cropping, resizing,
  and single-frame description generation
- Animated GIFs: Fast path that preserves animation, extracts frames for multi-frame description,
  and extracts dominant color from first frame
  Note: Animated avatars require Discord Nitro on the bot account
"""

import os
import io
import uuid
import shutil
import aiohttp
import asyncio
from PIL import Image, ImageDraw
import numpy as np
import cv2
from datetime import datetime
from typing import Optional, Dict, Tuple, List
import json
import discord
import globals

from .danbooru_client import danbooru_client
from .logger import get_logger
import globals

logger = get_logger('vision')


class ProfilePictureManager:
    """Manages Miku's profile picture with intelligent cropping and face detection"""
    
    PROFILE_PIC_DIR = "memory/profile_pictures"
    FALLBACK_PATH = "memory/profile_pictures/fallback.png"
    CURRENT_PATH = "memory/profile_pictures/current.png"
    ORIGINAL_PATH = "memory/profile_pictures/original.png"
    METADATA_PATH = "memory/profile_pictures/metadata.json"
    ALBUM_DIR = "memory/profile_pictures/album"
    
    # Face detection API endpoint
    FACE_DETECTOR_API = "http://anime-face-detector:6078/detect"
    
    # Fallback role color (Miku's iconic teal)
    FALLBACK_ROLE_COLOR = (134, 206, 203)  # #86cecb
    
    def __init__(self):
        self._ensure_directories()
        self.switching_locked = False  # Lock for voice session
    
    def lock_switching(self):
        """Lock profile picture changes during voice session"""
        self.switching_locked = True
        logger.info("Profile picture switching locked")
        
    def unlock_switching(self):
        """Unlock profile picture changes after voice session"""
        self.switching_locked = False
        logger.info("Profile picture switching unlocked")
    
    def _ensure_directories(self):
        """Ensure profile picture directory exists"""
        os.makedirs(self.PROFILE_PIC_DIR, exist_ok=True)
        os.makedirs(self.ALBUM_DIR, exist_ok=True)
    
    async def initialize(self):
        """Initialize the profile picture manager (check API availability)"""
        try:
            async with aiohttp.ClientSession() as session:
                async with session.get("http://anime-face-detector:6078/health", timeout=aiohttp.ClientTimeout(total=5)) as response:
                    if response.status == 200:
                        logger.info("Anime face detector API connected (pre-loaded)")
                        return True
        except Exception as e:
            logger.info(f"Face detector not pre-loaded (container not running)")
        return False
    
    async def _ensure_vram_available(self, debug: bool = False):
        """
        Ensure VRAM is available for face detection by swapping to text model.
        This unloads the vision model if it's loaded.
        """
        try:
            if debug:
                logger.info("Swapping to text model to free VRAM for face detection...")
            
            # Make a simple request to text model to trigger swap
            async with aiohttp.ClientSession() as session:
                payload = {
                    "model": "llama3.1",
                    "messages": [{"role": "user", "content": "hi"}],
                    "max_tokens": 1,
                    "stream": False
                }
                
                async with session.post(
                    "http://llama-swap:8080/v1/chat/completions",
                    json=payload,
                    timeout=aiohttp.ClientTimeout(total=60)
                ) as response:
                    if response.status == 200:
                        if debug:
                            logger.debug("Vision model unloaded, VRAM available")
                        # Give system time to fully release VRAM
                        await asyncio.sleep(3)
                        return True
        except Exception as e:
            if debug:
                logger.error(f"Could not swap models: {e}")
        
        return False
    
    async def _start_face_detector(self, debug: bool = False) -> bool:
        """Start the face detector container using Docker socket API"""
        try:
            if debug:
                logger.info("Starting face detector container...")
            # Prefer using the face_detector_manager helper (robust compose/docker logic)
            try:
                from .face_detector_manager import face_detector_manager
                started = await face_detector_manager.start_container(debug=debug)
                return started
            except Exception as e:
                if debug:
                    logger.debug(f"face_detector_manager not available or failed to create/start container: {e}; falling back to Docker socket API")

            # Fallback: Use Docker socket API to start container
            socket_path = "/var/run/docker.sock"
            if not os.path.exists(socket_path):
                if debug:
                    logger.error("Docker socket not available")
                return False

            from aiohttp import UnixConnector

            async with aiohttp.ClientSession(
                connector=UnixConnector(path=socket_path)
            ) as session:
                # Start the container
                url = "http://localhost/containers/anime-face-detector/start"
                async with session.post(url) as response:
                    if response.status not in [204, 304]:  # 204=started, 304=already running
                        if debug:
                            error_text = await response.text()
                            logger.error(f"Failed to start container: {response.status} - {error_text}")
                        return False

            # Wait for API to be ready (fallback timeout)
            for i in range(60):  # 60 second timeout (fallback)
                try:
                    async with aiohttp.ClientSession() as session:
                        async with session.get(
                            "http://anime-face-detector:6078/health",
                            timeout=aiohttp.ClientTimeout(total=2)
                        ) as response:
                            if response.status == 200:
                                if debug:
                                    logger.info(f"Face detector ready (took {i+1}s)")
                                return True
                except:
                    pass
                await asyncio.sleep(1)

            if debug:
                logger.warning("Face detector didn't become ready in time (fallback)")
            return False
            
        except Exception as e:
            if debug:
                logger.error(f"Error starting face detector: {e}")
            return False
    
    async def _stop_face_detector(self, debug: bool = False):
        """Stop the face detector container using Docker socket API"""
        try:
            if debug:
                logger.info("Stopping face detector to free VRAM...")
            # Prefer using face_detector_manager if available
            try:
                from .face_detector_manager import face_detector_manager
                await face_detector_manager.stop_container(debug=debug)
                return
            except Exception as e:
                if debug:
                    logger.debug(f"face_detector_manager not available or failed to stop container: {e}; falling back to Docker socket API")

            socket_path = "/var/run/docker.sock"

            if not os.path.exists(socket_path):
                if debug:
                    logger.error("Docker socket not available")
                return

            from aiohttp import UnixConnector

            async with aiohttp.ClientSession(
                connector=UnixConnector(path=socket_path)
            ) as session:
                # Stop the container
                url = "http://localhost/containers/anime-face-detector/stop"
                async with session.post(url, params={"t": 10}) as response:  # 10 second timeout
                    if response.status in [204, 304]:  # 204=stopped, 304=already stopped
                        if debug:
                            logger.info("Face detector stopped")
                    else:
                        if debug:
                            error_text = await response.text()
                            logger.warning(f"Failed to stop container: {response.status} - {error_text}")

        except Exception as e:
            if debug:
                logger.error(f"Error stopping face detector: {e}")
    
    async def save_current_avatar_as_fallback(self):
        """Save the bot's current avatar as fallback (only if fallback doesn't exist)"""
        try:
            # Only save if fallback doesn't already exist
            if os.path.exists(self.FALLBACK_PATH):
                logger.info("Fallback avatar already exists, skipping save")
                return True
            
            if not globals.client or not globals.client.user:
                logger.warning("Bot client not ready")
                return False
            
            avatar_asset = globals.client.user.avatar or globals.client.user.default_avatar
            
            # Download avatar
            avatar_bytes = await avatar_asset.read()
            
            # Save as fallback
            with open(self.FALLBACK_PATH, 'wb') as f:
                f.write(avatar_bytes)
            
            logger.info(f"Saved current avatar as fallback ({len(avatar_bytes)} bytes)")
            return True
            
        except Exception as e:
            logger.error(f"Error saving fallback avatar: {e}")
            return False
    
    async def change_profile_picture(
        self,
        mood: Optional[str] = None,
        custom_image_bytes: Optional[bytes] = None,
        debug: bool = False,
        max_retries: int = 5,
        skip_crop: bool = False
    ) -> Dict:
        """
        Main function to change Miku's profile picture.
        
        Args:
            mood: Current mood to influence Danbooru search
            custom_image_bytes: If provided, use this image instead of Danbooru
            debug: Enable debug output
            max_retries: Maximum number of attempts to find a valid Miku image (for Danbooru)
            skip_crop: If True, save original but skip cropping/description (for manual crop workflow)
        
        Returns:
            Dict with status and metadata
        """
        # Check if switching is locked (voice session active)
        if self.switching_locked:
            logger.info("Profile picture change blocked (voice session active)")
            return {
                "success": False,
                "source": None,
                "error": "Profile picture switching locked during voice session",
                "metadata": {}
            }
        
        result = {
            "success": False,
            "source": None,
            "error": None,
            "metadata": {}
        }
        
        try:
            # Step 1: Get and validate image (with retry for Danbooru)
            image_bytes = None
            image = None
            is_animated_gif = False
            
            if custom_image_bytes:
                # Custom upload - no retry needed
                if debug:
                    logger.info("Using provided custom image")
                image_bytes = custom_image_bytes
                result["source"] = "custom_upload"
                
                # Load image with PIL
                try:
                    image = Image.open(io.BytesIO(image_bytes))
                    if debug:
                        logger.debug(f"Original image size: {image.size}")
                    
                    # Check if it's an animated GIF
                    if image.format == 'GIF':
                        try:
                            # Check if GIF has multiple frames
                            image.seek(1)
                            is_animated_gif = True
                            image.seek(0)  # Reset to first frame
                            if debug:
                                logger.debug("Detected animated GIF - will preserve animation")
                        except EOFError:
                            # Only one frame, treat as static image
                            if debug:
                                logger.debug("Single-frame GIF - will process as static image")
                
                except Exception as e:
                    result["error"] = f"Failed to open image: {e}"
                    return result
                    
            else:
                # Danbooru - retry until we find a valid Miku image
                if debug:
                    logger.info(f"Searching Danbooru for Miku image (mood: {mood})")
                
                for attempt in range(max_retries):
                    if attempt > 0 and debug:
                        logger.info(f"Retry attempt {attempt + 1}/{max_retries}")
                    
                    post = await danbooru_client.get_random_miku_image(mood=mood)
                    if not post:
                        continue
                    
                    image_url = danbooru_client.extract_image_url(post)
                    if not image_url:
                        continue
                    
                    # Download image
                    temp_image_bytes = await self._download_image(image_url)
                    if not temp_image_bytes:
                        continue
                    
                    if debug:
                        logger.info(f"Downloaded image from Danbooru (post #{danbooru_client.get_post_metadata(post).get('id')})")
                    
                    # Load image with PIL
                    try:
                        temp_image = Image.open(io.BytesIO(temp_image_bytes))
                        if debug:
                            logger.debug(f"Original image size: {temp_image.size}")
                    except Exception as e:
                        if debug:
                            logger.warning(f"Failed to open image: {e}")
                        continue
                    
                    # Verify it's Miku
                    miku_verification = await self._verify_and_locate_miku(temp_image_bytes, debug=debug)
                    if not miku_verification["is_miku"]:
                        if debug:
                            logger.warning(f"Image verification failed: not Miku, trying another...")
                        continue
                    
                    # Success! This image is valid
                    image_bytes = temp_image_bytes
                    image = temp_image
                    result["source"] = "danbooru"
                    result["metadata"] = danbooru_client.get_post_metadata(post)
                    
                    # If multiple characters detected, use LLM's suggested crop region
                    if miku_verification.get("crop_region"):
                        if debug:
                            logger.debug(f"Using LLM-suggested crop region for Miku")
                        image = self._apply_crop_region(image, miku_verification["crop_region"])
                    
                    break
                
                # Check if we found a valid image
                if not image_bytes or not image:
                    result["error"] = f"Could not find valid Miku image after {max_retries} attempts"
                    return result
            
            # === ANIMATED GIF FAST PATH ===
            # If this is an animated GIF, skip most processing and use raw bytes
            if is_animated_gif:
                if debug:
                    logger.info("Using GIF fast path - skipping face detection and cropping")
                
                # Generate description of the animated GIF
                if debug:
                    logger.info("Generating GIF description using video analysis pipeline...")
                description = await self._generate_gif_description(image_bytes, debug=debug)
                if description:
                    # Save description to file
                    description_path = os.path.join(self.PROFILE_PIC_DIR, "current_description.txt")
                    try:
                        with open(description_path, 'w', encoding='utf-8') as f:
                            f.write(description)
                        result["metadata"]["description"] = description
                        if debug:
                            logger.info(f"Saved GIF description ({len(description)} chars)")
                    except Exception as e:
                        logger.error(f"Failed to save description file: {e}")
                else:
                    if debug:
                        logger.error("GIF description generation returned None")
                
                # Extract dominant color from first frame
                dominant_color = self._extract_dominant_color(image, debug=debug)
                if dominant_color:
                    result["metadata"]["dominant_color"] = {
                        "rgb": dominant_color,
                        "hex": "#{:02x}{:02x}{:02x}".format(*dominant_color)
                    }
                    if debug:
                        logger.debug(f"Dominant color from first frame: RGB{dominant_color} (#{result['metadata']['dominant_color']['hex'][1:]})")
                
                # Save the original GIF bytes
                with open(self.CURRENT_PATH, 'wb') as f:
                    f.write(image_bytes)
                
                if debug:
                    logger.info(f"Saved animated GIF ({len(image_bytes)} bytes)")
                
                # Update Discord avatar with original GIF
                if globals.client and globals.client.user:
                    try:
                        if globals.client.loop and globals.client.loop.is_running():
                            future = asyncio.run_coroutine_threadsafe(
                                globals.client.user.edit(avatar=image_bytes),
                                globals.client.loop
                            )
                            future.result(timeout=10)
                        else:
                            await globals.client.user.edit(avatar=image_bytes)
                        
                        result["success"] = True
                        result["metadata"]["changed_at"] = datetime.now().isoformat()
                        result["metadata"]["animated"] = True
                        
                        # Save metadata
                        self._save_metadata(result["metadata"])
                        
                        logger.info(f"Animated profile picture updated successfully!")
                        
                        # Update role colors if we have a dominant color
                        if dominant_color:
                            await self._update_role_colors(dominant_color, debug=debug)
                        
                        # Update bipolar webhook avatars if bipolar mode is active
                        if globals.BIPOLAR_MODE:
                            try:
                                from utils.bipolar_mode import update_webhook_avatars
                                updated = await update_webhook_avatars(globals.client)
                                if debug:
                                    logger.info(f"Updated bipolar webhook avatars: {updated}")
                            except Exception as e:
                                logger.warning(f"Failed to update bipolar webhook avatars: {e}")
                        
                        return result
                        
                    except discord.HTTPException as e:
                        result["error"] = f"Discord API error: {e}"
                        logger.warning(f"Failed to update Discord avatar with GIF: {e}")
                        if debug:
                            logger.debug("Note: Animated avatars require Discord Nitro")
                        return result
                    except Exception as e:
                        result["error"] = f"Unexpected error updating avatar: {e}"
                        logger.error(f"Unexpected error: {e}")
                        return result
                else:
                    result["error"] = "Bot client not ready"
                    return result
            
            # === NORMAL STATIC IMAGE PATH ===
            # Save full-resolution original for manual cropping later
            with open(self.ORIGINAL_PATH, 'wb') as f:
                f.write(image_bytes)
            if debug:
                logger.info(f"Saved full-resolution original ({len(image_bytes)} bytes, {image.size[0]}x{image.size[1]})")
            result["metadata"]["original_width"] = image.size[0]
            result["metadata"]["original_height"] = image.size[1]
            
            # If skip_crop requested (manual crop workflow), return early with original saved
            if skip_crop:
                result["success"] = True
                result["metadata"]["changed_at"] = datetime.now().isoformat()
                result["metadata"]["skip_crop"] = True
                self._save_metadata(result["metadata"])
                if debug:
                    logger.info("Skipping auto-crop (manual crop workflow) - original saved")
                return result
            
            # Step 2: Generate description of the validated image
            if debug:
                logger.info("Generating image description...")
            description = await self._generate_image_description(image_bytes, debug=debug)
            if description:
                # Save description to file
                description_path = os.path.join(self.PROFILE_PIC_DIR, "current_description.txt")
                try:
                    with open(description_path, 'w', encoding='utf-8') as f:
                        f.write(description)
                    result["metadata"]["description"] = description
                    if debug:
                        logger.info(f"Saved image description ({len(description)} chars)")
                except Exception as e:
                    logger.warning(f"Failed to save description file: {e}")
            else:
                if debug:
                    logger.warning("Description generation returned None")
            
            # Step 3: Detect face and crop intelligently (skip if already at/below Discord avatar size)
            target_size = 512
            width, height = image.size
            if width <= target_size and height <= target_size:
                if debug:
                    logger.info(f"Image already at/below target size ({width}x{height} <= {target_size}x{target_size}), skipping crop")
                cropped_image = image
            else:
                cropped_image = await self._intelligent_crop(image, image_bytes, target_size=target_size, debug=debug)
            
            if not cropped_image:
                result["error"] = "Failed to crop image"
                return result
            
            # Step 4: Save the cropped image first
            output_buffer = io.BytesIO()
            cropped_image.save(output_buffer, format='PNG')
            cropped_bytes = output_buffer.getvalue()
            
            # Save to disk as current
            with open(self.CURRENT_PATH, 'wb') as f:
                f.write(cropped_bytes)
            
            if debug:
                logger.info(f"Saved cropped image ({len(cropped_bytes)} bytes)")
            
            # Step 5: Extract dominant color from saved current.png
            saved_image = Image.open(self.CURRENT_PATH)
            dominant_color = self._extract_dominant_color(saved_image, debug=debug)
            if dominant_color:
                result["metadata"]["dominant_color"] = {
                    "rgb": dominant_color,
                    "hex": "#{:02x}{:02x}{:02x}".format(*dominant_color)
                }
                if debug:
                    logger.debug(f"Dominant color: RGB{dominant_color} (#{result['metadata']['dominant_color']['hex'][1:]})")
            
            # Step 6: Update Discord avatar
            if globals.client and globals.client.user:
                try:
                    # Run the edit operation in the bot's event loop
                    if globals.client.loop and globals.client.loop.is_running():
                        # Create a future to run in bot's loop
                        future = asyncio.run_coroutine_threadsafe(
                            globals.client.user.edit(avatar=cropped_bytes),
                            globals.client.loop
                        )
                        # Wait for the result
                        future.result(timeout=10)
                    else:
                        # Fallback if loop not available (shouldn't happen)
                        await globals.client.user.edit(avatar=cropped_bytes)
                    
                    result["success"] = True
                    result["metadata"]["changed_at"] = datetime.now().isoformat()
                    result["metadata"]["animated"] = False
                    
                    # Save metadata
                    self._save_metadata(result["metadata"])
                    
                    logger.info(f"Profile picture updated successfully!")
                    
                    # Step 7: Update role colors across all servers
                    if dominant_color:
                        await self._update_role_colors(dominant_color, debug=debug)
                    
                    # Step 8: Update bipolar webhook avatars if bipolar mode is active
                    if globals.BIPOLAR_MODE:
                        try:
                            from utils.bipolar_mode import update_webhook_avatars
                            await update_webhook_avatars(globals.client)
                            logger.debug("Updated bipolar webhook avatars after profile picture change")
                        except Exception as e:
                            logger.warning(f"Failed to update bipolar webhook avatars: {e}")
                    
                except discord.HTTPException as e:
                    result["error"] = f"Discord API error: {e}"
                    logger.warning(f"Failed to update Discord avatar: {e}")
                except Exception as e:
                    result["error"] = f"Unexpected error updating avatar: {e}"
                    logger.error(f"Unexpected error: {e}")
            else:
                result["error"] = "Bot client not ready"
            
        except Exception as e:
            result["error"] = f"Unexpected error: {e}"
            logger.error(f"Error in change_profile_picture: {e}")
        
        return result
    
    async def _download_image(self, url: str) -> Optional[bytes]:
        """Download image from URL"""
        try:
            async with aiohttp.ClientSession() as session:
                async with session.get(url, timeout=15) as response:
                    if response.status == 200:
                        return await response.read()
        except Exception as e:
            logger.error(f"Error downloading image: {e}")
        return None
    
    async def _generate_image_description(self, image_bytes: bytes, debug: bool = False) -> Optional[str]:
        """
        Generate a detailed description of the profile picture using vision model.
        This description will be used when users ask about the pfp.
        
        Args:
            image_bytes: Raw image bytes
            debug: Enable debug output
            
        Returns:
            Description string or None
        """
        try:
            import base64
            image_b64 = base64.b64encode(image_bytes).decode('utf-8')
            
            if debug:
                logger.debug(f"Encoded image: {len(image_b64)} chars, calling vision model...")
            
            prompt = """This is an image of Hatsune Miku that will be used as a profile picture.
Please describe this image in detail, including:
- What Miku is wearing (outfit, colors, accessories)
- Her pose and expression
- The art style and mood of the image
- Any notable background elements
- Overall atmosphere or theme

Keep the description conversational and in second-person (referring to Miku as "you"), as if Miku herself is describing her own appearance in this image."""

            payload = {
                "model": globals.VISION_MODEL,
                "messages": [
                    {
                        "role": "user",
                        "content": [
                            {
                                "type": "text",
                                "text": prompt
                            },
                            {
                                "type": "image_url",
                                "image_url": {
                                    "url": f"data:image/jpeg;base64,{image_b64}"
                                }
                            }
                        ]
                    }
                ],
                "stream": False,
                "max_tokens": 400,
                "temperature": 0.7
            }
            
            headers = {"Content-Type": "application/json"}
            
            if debug:
                logger.debug(f"Calling {globals.LLAMA_URL}/v1/chat/completions with model {globals.VISION_MODEL}")
            
            async with aiohttp.ClientSession() as session:
                async with session.post(f"{globals.LLAMA_URL}/v1/chat/completions", json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=60)) as resp:
                    if resp.status == 200:
                        data = await resp.json()
                        
                        if debug:
                            logger.debug(f"API Response keys: {data.keys()}")
                            logger.debug(f"Choices: {data.get('choices', [])}")
                        
                        # Try to get content from the response
                        choice = data.get("choices", [{}])[0]
                        message = choice.get("message", {})
                        
                        # Check both 'content' and 'reasoning_content' fields
                        description = message.get("content", "")
                        
                        # If content is empty, try reasoning_content (chain-of-thought models)
                        if not description or not description.strip():
                            description = message.get("reasoning_content", "")
                        
                        if description and description.strip():
                            if debug:
                                logger.info(f"Generated description: {description[:100]}...")
                            return description.strip()
                        else:
                            if debug:
                                logger.warning(f"Description is empty or None")
                                logger.warning(f"   Full response: {data}")
                            else:
                                logger.warning(f"Description is empty or None")
                            return None
                    else:
                        error_text = await resp.text()
                        logger.error(f"Vision API error generating description: {resp.status} - {error_text}")
                        
        except Exception as e:
            logger.error(f"Error generating image description: {e}")
            import traceback
            traceback.print_exc()
        
        return None
    
    async def _generate_gif_description(self, gif_bytes: bytes, debug: bool = False) -> Optional[str]:
        """
        Generate a detailed description of an animated GIF using the video analysis pipeline.
        
        Args:
            gif_bytes: Raw GIF bytes
            debug: Enable debug output
            
        Returns:
            Description string or None
        """
        try:
            from utils.image_handling import extract_video_frames, analyze_video_with_vision
            
            if debug:
                logger.info("Extracting frames from GIF...")
            
            # Extract frames from the GIF (6 frames for good analysis)
            frames = await extract_video_frames(gif_bytes, num_frames=6)
            
            if not frames:
                if debug:
                    logger.warning("Failed to extract frames from GIF")
                return None
            
            if debug:
                logger.info(f"Extracted {len(frames)} frames from GIF")
                logger.info(f"Analyzing GIF with vision model...")
            
            # Use the existing analyze_video_with_vision function (no timeout issues)
            # Note: This uses a generic prompt, but it works reliably
            description = await analyze_video_with_vision(frames, media_type="gif")
            
            if description and description.strip() and not description.startswith("Error"):
                if debug:
                    logger.info(f"Generated GIF description: {description[:100]}...")
                return description.strip()
            else:
                if debug:
                    logger.warning(f"GIF description failed or empty: {description}")
                return None
                        
        except Exception as e:
            logger.error(f"Error generating GIF description: {e}")
            import traceback
            traceback.print_exc()
        
        return None
    
    async def _verify_and_locate_miku(self, image_bytes: bytes, debug: bool = False) -> Dict:
        """
        Use vision model to verify image contains Miku and locate her if multiple characters.
        
        Returns:
            Dict with is_miku bool and optional crop_region
        """
        result = {
            "is_miku": False,
            "crop_region": None,
            "character_count": 0
        }
        
        try:
            # Encode image to base64
            import base64
            image_b64 = base64.b64encode(image_bytes).decode('utf-8')
            
            # Query vision model using OpenAI-compatible API
            prompt = """Analyze this image and answer:
1. Is Hatsune Miku present in this image? (yes/no)
2. How many characters are in the image? (number)
3. If there are multiple characters, describe where Miku is located (left/right/center, top/bottom/middle)

Respond in JSON format:
{
  "is_miku": true/false,
  "character_count": number,
  "miku_location": "description or null"
}"""
            
            payload = {
                "model": globals.VISION_MODEL,
                "messages": [
                    {
                        "role": "user",
                        "content": [
                            {
                                "type": "text",
                                "text": prompt
                            },
                            {
                                "type": "image_url",
                                "image_url": {
                                    "url": f"data:image/jpeg;base64,{image_b64}"
                                }
                            }
                        ]
                    }
                ],
                "stream": False,
                "max_tokens": 200,
                "temperature": 0.3
            }
            
            headers = {"Content-Type": "application/json"}
            
            async with aiohttp.ClientSession() as session:
                async with session.post(f"{globals.LLAMA_URL}/v1/chat/completions", json=payload, headers=headers) as resp:
                    if resp.status == 200:
                        data = await resp.json()
                        response = data.get("choices", [{}])[0].get("message", {}).get("content", "")
                    else:
                        error_text = await resp.text()
                        logger.error(f"Vision API error: {resp.status} - {error_text}")
                        return result
            
            if debug:
                logger.debug(f"Vision model response: {response}")
            
            # Parse JSON response
            import re
            json_match = re.search(r'\{[^}]+\}', response)
            if json_match:
                data = json.loads(json_match.group())
                result["is_miku"] = data.get("is_miku", False)
                result["character_count"] = data.get("character_count", 1)
                
                # If multiple characters, parse location
                if result["character_count"] > 1 and data.get("miku_location"):
                    result["crop_region"] = self._parse_location_to_region(
                        data["miku_location"],
                        debug=debug
                    )
            else:
                # Fallback: simple text analysis
                response_lower = response.lower()
                result["is_miku"] = "yes" in response_lower or "miku" in response_lower
                
        except Exception as e:
            logger.warning(f"Error in vision verification: {e}")
            # Assume it's Miku on error (trust Danbooru tags)
            result["is_miku"] = True
        
        return result
    
    def _parse_location_to_region(self, location: str, debug: bool = False) -> Optional[Dict]:
        """Parse location description to crop region coordinates"""
        location_lower = location.lower()
        
        # Simple region detection
        region = {
            "horizontal": "center",  # left, center, right
            "vertical": "middle"     # top, middle, bottom
        }
        
        if "left" in location_lower:
            region["horizontal"] = "left"
        elif "right" in location_lower:
            region["horizontal"] = "right"
        
        if "top" in location_lower:
            region["vertical"] = "top"
        elif "bottom" in location_lower:
            region["vertical"] = "bottom"
        
        if debug:
            logger.debug(f"Parsed location '{location}' -> {region}")
        
        return region
    
    def _apply_crop_region(self, image: Image.Image, region: Dict) -> Image.Image:
        """Apply crop region based on parsed location"""
        width, height = image.size
        
        # Determine crop box based on region
        # We want roughly 1/2 to 2/3 of the image
        crop_width = int(width * 0.6)
        crop_height = int(height * 0.6)
        
        # Horizontal position
        if region["horizontal"] == "left":
            left = 0
            right = crop_width
        elif region["horizontal"] == "right":
            left = width - crop_width
            right = width
        else:  # center
            left = (width - crop_width) // 2
            right = left + crop_width
        
        # Vertical position
        if region["vertical"] == "top":
            top = 0
            bottom = crop_height
        elif region["vertical"] == "bottom":
            top = height - crop_height
            bottom = height
        else:  # middle
            top = (height - crop_height) // 2
            bottom = top + crop_height
        
        return image.crop((left, top, right, bottom))
    
    async def _intelligent_crop(
        self,
        image: Image.Image,
        image_bytes: bytes,
        target_size: int = 512,
        debug: bool = False
    ) -> Optional[Image.Image]:
        """
        Intelligently crop image to square, centering on detected face.
        
        Args:
            image: PIL Image
            image_bytes: Image data as bytes (for API call)
            target_size: Target size for square output
            debug: Enable debug output
        
        Returns:
            Cropped PIL Image or None
        """
        width, height = image.size
        
        # Try face detection via API first
        face_detection = await self._detect_face(image_bytes, debug=debug)
        
        if face_detection and face_detection.get('center'):
            if debug:
                logger.debug(f"Face detected at {face_detection['center']}")
            crop_center = face_detection['center']
        else:
            if debug:
                logger.debug("No face detected, using saliency detection")
            # Fallback to saliency detection
            cv_image = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR)
            crop_center = self._detect_saliency(cv_image, debug=debug)
        
        # Determine crop box (square)
        # Use 60% of the smaller dimension to include face context
        base_size = min(width, height)
        crop_size = int(base_size * 0.6)
        
        # For very large images, cap the crop size at 1000px
        if crop_size > 1000:
            crop_size = 1000
        
        # Minimum crop size for quality
        if crop_size < 400:
            crop_size = 400
        
        # Center the crop on the detected point
        left = crop_center[0] - crop_size // 2
        top = crop_center[1] - crop_size // 2
        
        # Adjust if crop goes out of bounds
        # Instead of clamping, try to shift to keep face centered
        if left < 0:
            left = 0
        elif left + crop_size > width:
            left = width - crop_size
            
        if top < 0:
            # Face is too close to top edge
            # Shift down but keep face in upper portion (top 40%)
            top = 0
            # Adjust crop_center for logging
            if debug:
                logger.debug(f"Face too close to top edge, shifted crop to y=0")
        elif top + crop_size > height:
            # Face is too close to bottom edge
            top = height - crop_size
            if debug:
                logger.debug(f"Face too close to bottom edge, shifted crop to y={top}")
        
        # Crop
        cropped = image.crop((left, top, left + crop_size, top + crop_size))
        
        # Resize to target size
        cropped = cropped.resize((target_size, target_size), Image.Resampling.LANCZOS)
        
        if debug:
            logger.debug(f"Cropped to {target_size}x{target_size} centered at {crop_center}")
        
        return cropped
    
    async def _detect_face(self, image_bytes: bytes, debug: bool = False) -> Optional[Dict]:
        """
        Detect anime face in image using external API and return detection data.
        
        Args:
            image_bytes: Image data as bytes
            debug: Enable debug output
        
        Returns:
            Dict with detection data including bbox, confidence, keypoints, or None
        """
        face_detector_started = False
        
        try:
            # Step 1: Ensure VRAM is available by unloading vision model
            await self._ensure_vram_available(debug=debug)
            
            # Step 2: Start face detector container
            if not await self._start_face_detector(debug=debug):
                if debug:
                    logger.error("Could not start face detector")
                return None
            
            face_detector_started = True
            
            # Step 3: Call the face detection API
            async with aiohttp.ClientSession() as session:
                # Prepare multipart form data
                form = aiohttp.FormData()
                form.add_field('file', image_bytes, filename='image.jpg', content_type='image/jpeg')
                
                async with session.post(
                    self.FACE_DETECTOR_API,
                    data=form,
                    timeout=aiohttp.ClientTimeout(total=30)
                ) as response:
                    if response.status != 200:
                        if debug:
                            logger.error(f"Face detection API returned status {response.status}")
                        return None
                    
                    result = await response.json()
                    
                    if result.get('count', 0) == 0:
                        if debug:
                            logger.debug("No faces detected by API")
                        return None
                    
                    # Get detections and pick the one with highest confidence
                    detections = result.get('detections', [])
                    if not detections:
                        return None
                    
                    best_detection = max(detections, key=lambda d: d.get('confidence', 0))
                    
                    # Extract bbox coordinates
                    bbox = best_detection.get('bbox', [])
                    confidence = best_detection.get('confidence', 0)
                    keypoints = best_detection.get('keypoints', [])
                    
                    if len(bbox) >= 4:
                        x1, y1, x2, y2 = bbox[:4]
                        center_x = int((x1 + x2) / 2)
                        center_y = int((y1 + y2) / 2)
                        
                        if debug:
                            width = int(x2 - x1)
                            height = int(y2 - y1)
                            logger.debug(f"Detected {len(detections)} face(s) via API, using best at ({center_x}, {center_y}) [confidence: {confidence:.2%}]")
                            logger.debug(f"   Bounding box: x={int(x1)}, y={int(y1)}, w={width}, h={height}")
                            logger.debug(f"   Keypoints: {len(keypoints)} facial landmarks detected")
                        
                        return {
                            'center': (center_x, center_y),
                            'bbox': bbox,
                            'confidence': confidence,
                            'keypoints': keypoints,
                            'count': len(detections)
                        }
            
        except asyncio.TimeoutError:
            if debug:
                logger.warning("Face detection API timeout")
        except Exception as e:
            if debug:
                logger.error(f"Error calling face detection API: {e}")
        finally:
            # Always stop face detector to free VRAM
            if face_detector_started:
                await self._stop_face_detector(debug=debug)
        
        return None
    
    def _detect_saliency(self, cv_image: np.ndarray, debug: bool = False) -> Tuple[int, int]:
        """
        Detect most salient (interesting) region of image.
        Fallback when face detection fails.
        
        Returns:
            Tuple of (x, y) center coordinates
        """
        try:
            height, width = cv_image.shape[:2]
            
            # Use OpenCV's saliency detector
            saliency = cv2.saliency.StaticSaliencySpectralResidual_create()
            success, saliency_map = saliency.computeSaliency(cv_image)
            
            if success:
                # Find the point with highest saliency
                saliency_map = (saliency_map * 255).astype("uint8")
                _, max_val, _, max_loc = cv2.minMaxLoc(saliency_map)
                
                if debug:
                    logger.debug(f"Saliency peak at {max_loc}")
                
                return max_loc
        except Exception as e:
            if debug:
                logger.error(f"Saliency detection failed: {e}")
        
        # Ultimate fallback: center of image
        height, width = cv_image.shape[:2]
        return (width // 2, height // 2)
    
    def _extract_dominant_color(self, image: Image.Image, debug: bool = False) -> Optional[Tuple[int, int, int]]:
        """
        Extract the dominant color from an image using k-means clustering.
        
        Args:
            image: PIL Image
            debug: Enable debug output
            
        Returns:
            RGB tuple (r, g, b) or None
        """
        try:
            # Resize for faster processing
            small_image = image.resize((150, 150))
            
            # Convert to RGB if needed (handles grayscale, RGBA, etc.)
            if small_image.mode != 'RGB':
                small_image = small_image.convert('RGB')
            
            # Convert to numpy array
            pixels = np.array(small_image)
            
            # Reshape to list of RGB pixels
            pixels = pixels.reshape(-1, 3)
            
            # Remove very dark (near black) and very bright (near white) pixels
            # to avoid getting boring colors
            mask = ~((pixels.sum(axis=1) < 30) | (pixels.sum(axis=1) > 720))
            pixels = pixels[mask]
            
            if len(pixels) == 0:
                if debug:
                    logger.warning("No valid pixels after filtering, using fallback")
                return (200, 200, 200)  # Neutral gray fallback
            
            # Use k-means to find dominant colors
            from sklearn.cluster import KMeans
            n_colors = 5
            kmeans = KMeans(n_clusters=n_colors, random_state=42, n_init=10)
            kmeans.fit(pixels)
            
            # Get cluster centers (dominant colors) and their frequencies
            colors = kmeans.cluster_centers_
            labels = kmeans.labels_
            counts = np.bincount(labels)
            
            if debug:
                logger.debug(f"Found {n_colors} color clusters:")
                for i, (color, count) in enumerate(zip(colors, counts)):
                    pct = (count / len(labels)) * 100
                    r, g, b = color.astype(int)
                    logger.debug(f"   {i+1}. RGB({r}, {g}, {b}) = #{r:02x}{g:02x}{b:02x} ({pct:.1f}%)")
            
            # Sort by frequency
            sorted_indices = np.argsort(-counts)
            
            # Pick the most vibrant/saturated color from top 3
            best_color = None
            best_saturation = 0
            
            for idx in sorted_indices[:3]:
                color = colors[idx].astype(int)
                r, g, b = color
                
                # Calculate saturation (how vibrant the color is)
                max_c = max(r, g, b)
                min_c = min(r, g, b)
                saturation = (max_c - min_c) / max_c if max_c > 0 else 0
                
                if debug:
                    logger.debug(f"   Color RGB({r}, {g}, {b}) saturation: {saturation:.2f}")
                
                # Prefer more saturated colors
                if saturation > best_saturation:
                    best_saturation = saturation
                    # Convert to native Python ints for JSON serialization
                    best_color = (int(r), int(g), int(b))
            
            if best_color:
                if debug:
                    logger.debug(f"Selected color: RGB{best_color} (saturation: {best_saturation:.2f})")
                return best_color
            
            # Fallback to most common color
            dominant_color = colors[sorted_indices[0]].astype(int)
            # Convert to native Python ints
            result = (int(dominant_color[0]), int(dominant_color[1]), int(dominant_color[2]))
            if debug:
                logger.debug(f"Using most common color: RGB{result}")
            return result
            
        except Exception as e:
            if debug:
                logger.error(f"Error extracting dominant color: {e}")
            return None
    
    async def _update_role_colors(self, color: Tuple[int, int, int], debug: bool = False):
        """
        Update Miku's role color across all servers.
        
        Args:
            color: RGB tuple (r, g, b)
            debug: Enable debug output
        """
        if debug:
            logger.debug(f"Starting role color update with RGB{color}")
        
        if not globals.client:
            if debug:
                logger.error("No client available for role updates")
            return
        
        if debug:
            logger.debug(f"Found {len(globals.client.guilds)} guild(s)")
        
        # Convert RGB to Discord color (integer)
        discord_color = discord.Color.from_rgb(*color)
        
        updated_count = 0
        failed_count = 0
        
        for guild in globals.client.guilds:
            try:
                if debug:
                    logger.debug(f"Checking guild: {guild.name}")
                
                # Find the bot's top role (usually colored role)
                member = guild.get_member(globals.client.user.id)
                if not member:
                    if debug:
                        logger.warning(f"  Bot not found as member in {guild.name}")
                    continue
                
                # Get the highest role that the bot has (excluding @everyone)
                roles = [r for r in member.roles if r.name != "@everyone"]
                if not roles:
                    if debug:
                        logger.warning(f"  No roles found in {guild.name}")
                    continue
                
                # Look for a dedicated color role first (e.g., "Miku Color")
                color_role = None
                for role in guild.roles:
                    if role.name.lower() in ["miku color", "miku colour", "miku-color"]:
                        color_role = role
                        break
                
                # Get bot's top role
                bot_top_role = max(roles, key=lambda r: r.position)
                
                # Use dedicated color role if found, otherwise use top role
                if color_role:
                    if debug:
                        logger.debug(f"  Found dedicated color role: {color_role.name} (position {color_role.position})")
                    target_role = color_role
                else:
                    if debug:
                        logger.debug(f"  No 'Miku Color' role found, using top role: {bot_top_role.name} (position {bot_top_role.position})")
                    target_role = bot_top_role
                
                # Check permissions
                can_manage = guild.me.guild_permissions.manage_roles
                
                if debug:
                    logger.debug(f"  Manage roles permission: {can_manage}")
                    logger.debug(f"  Bot top role: {bot_top_role.name} (pos {bot_top_role.position}), Target: {target_role.name} (pos {target_role.position})")
                
                # Only update if we have permission and it's not a special role
                if can_manage:
                    # Run role edit in bot's event loop
                    if globals.client.loop and globals.client.loop.is_running():
                        future = asyncio.run_coroutine_threadsafe(
                            target_role.edit(color=discord_color, reason="Profile picture color sync"),
                            globals.client.loop
                        )
                        future.result(timeout=5)
                    else:
                        await target_role.edit(color=discord_color, reason="Profile picture color sync")
                    
                    updated_count += 1
                    if debug:
                        logger.info(f"  Updated role color in {guild.name}: {target_role.name}")
                else:
                    if debug:
                        logger.warning(f"  No manage_roles permission in {guild.name}")
                        
            except discord.Forbidden:
                failed_count += 1
                if debug:
                    logger.error(f"  Forbidden: No permission to update role in {guild.name}")
            except Exception as e:
                failed_count += 1
                if debug:
                    logger.error(f"  Error updating role in {guild.name}: {e}")
                import traceback
                traceback.print_exc()
        
        if updated_count > 0:
            logger.info(f"Updated role colors in {updated_count} server(s)")
        else:
            logger.warning(f"No roles were updated (failed: {failed_count})")
        if failed_count > 0 and debug:
            logger.error(f"Failed to update {failed_count} server(s)")
    
    async def set_custom_role_color(self, hex_color: str, debug: bool = False) -> Dict:
        """
        Set a custom role color across all servers.
        
        Args:
            hex_color: Hex color code (e.g., "#86cecb" or "86cecb")
            debug: Enable debug output
            
        Returns:
            Dict with success status and count
        """
        # Parse hex color
        hex_color = hex_color.strip().lstrip('#')
        try:
            r = int(hex_color[0:2], 16)
            g = int(hex_color[2:4], 16)
            b = int(hex_color[4:6], 16)
            color = (r, g, b)
        except (ValueError, IndexError):
            return {
                "success": False,
                "error": f"Invalid hex color: {hex_color}"
            }
        
        if debug:
            logger.debug(f"Setting custom role color: #{hex_color} RGB{color}")
        
        await self._update_role_colors(color, debug=debug)
        
        return {
            "success": True,
            "color": {
                "hex": f"#{hex_color}",
                "rgb": color
            }
        }
    
    async def reset_to_fallback_color(self, debug: bool = False) -> Dict:
        """
        Reset role color to the fallback color (#86cecb).
        
        Args:
            debug: Enable debug output
            
        Returns:
            Dict with success status
        """
        if debug:
            logger.debug(f"Resetting to fallback color: RGB{self.FALLBACK_ROLE_COLOR}")
        
        await self._update_role_colors(self.FALLBACK_ROLE_COLOR, debug=debug)
        
        return {
            "success": True,
            "color": {
                "hex": "#86cecb",
                "rgb": self.FALLBACK_ROLE_COLOR
            }
        }
    
    def _save_metadata(self, metadata: Dict):
        """Save metadata about current profile picture"""
        try:
            with open(self.METADATA_PATH, 'w') as f:
                json.dump(metadata, f, indent=2)
        except Exception as e:
            logger.error(f"Error saving metadata: {e}")
    
    def load_metadata(self) -> Optional[Dict]:
        """Load metadata about current profile picture"""
        try:
            if os.path.exists(self.METADATA_PATH):
                with open(self.METADATA_PATH, 'r') as f:
                    return json.load(f)
        except Exception as e:
            logger.error(f"Error loading metadata: {e}")
        return None
    
    async def restore_fallback(self) -> bool:
        """Restore the fallback profile picture"""
        try:
            if not os.path.exists(self.FALLBACK_PATH):
                logger.warning("No fallback avatar found")
                return False
            
            with open(self.FALLBACK_PATH, 'rb') as f:
                avatar_bytes = f.read()
            
            if globals.client and globals.client.user:
                # Run the edit operation in the bot's event loop
                if globals.client.loop and globals.client.loop.is_running():
                    future = asyncio.run_coroutine_threadsafe(
                        globals.client.user.edit(avatar=avatar_bytes),
                        globals.client.loop
                    )
                    future.result(timeout=10)
                else:
                    await globals.client.user.edit(avatar=avatar_bytes)
                
                logger.info("Restored fallback avatar")
                return True
            
        except Exception as e:
            logger.error(f"Error restoring fallback: {e}")
        
        return False
    
    def get_current_description(self) -> Optional[str]:
        """
        Get the description of the current profile picture.
        
        Returns:
            Description string or None
        """
        description_path = os.path.join(self.PROFILE_PIC_DIR, "current_description.txt")
        try:
            if os.path.exists(description_path):
                with open(description_path, 'r', encoding='utf-8') as f:
                    return f.read().strip()
        except Exception as e:
            logger.error(f"Error reading description: {e}")
        
        return None
    
    async def manual_crop(self, x: int, y: int, width: int, height: int, target_size: int = 512, debug: bool = False) -> Dict:
        """
        Manually crop the stored original image and apply it as the Discord avatar.
        
        Args:
            x: Left edge of crop region (pixels)
            y: Top edge of crop region (pixels)
            width: Width of crop region (pixels)
            height: Height of crop region (pixels)
            target_size: Final resize target (default 512)
            debug: Enable debug output
            
        Returns:
            Dict with success status and metadata
        """
        result = {"success": False, "error": None, "metadata": {}}
        
        try:
            if not os.path.exists(self.ORIGINAL_PATH):
                result["error"] = "No original image found. Upload or fetch an image first."
                return result
            
            image = Image.open(self.ORIGINAL_PATH)
            img_width, img_height = image.size
            
            # Validate crop region
            if x < 0 or y < 0:
                result["error"] = f"Crop coordinates must be non-negative (got x={x}, y={y})"
                return result
            if x + width > img_width or y + height > img_height:
                result["error"] = f"Crop region ({x},{y},{width},{height}) exceeds image bounds ({img_width}x{img_height})"
                return result
            if width < 64 or height < 64:
                result["error"] = f"Crop region too small (minimum 64x64, got {width}x{height})"
                return result
            
            # Perform crop
            cropped = image.crop((x, y, x + width, y + height))
            
            # Resize to target
            cropped = cropped.resize((target_size, target_size), Image.Resampling.LANCZOS)
            
            if debug:
                logger.info(f"Manual crop: ({x},{y},{width},{height}) -> {target_size}x{target_size}")
            
            # Save cropped image
            output_buffer = io.BytesIO()
            cropped.save(output_buffer, format='PNG')
            cropped_bytes = output_buffer.getvalue()
            
            with open(self.CURRENT_PATH, 'wb') as f:
                f.write(cropped_bytes)
            
            # Extract dominant color
            dominant_color = self._extract_dominant_color(cropped, debug=debug)
            if dominant_color:
                result["metadata"]["dominant_color"] = {
                    "rgb": dominant_color,
                    "hex": "#{:02x}{:02x}{:02x}".format(*dominant_color)
                }
            
            # Update Discord avatar
            if globals.client and globals.client.user:
                try:
                    if globals.client.loop and globals.client.loop.is_running():
                        future = asyncio.run_coroutine_threadsafe(
                            globals.client.user.edit(avatar=cropped_bytes),
                            globals.client.loop
                        )
                        future.result(timeout=10)
                    else:
                        await globals.client.user.edit(avatar=cropped_bytes)
                    
                    result["success"] = True
                    result["metadata"]["changed_at"] = datetime.now().isoformat()
                    result["metadata"]["crop_region"] = {"x": x, "y": y, "width": width, "height": height}
                    
                    # Update existing metadata
                    existing_meta = self.load_metadata() or {}
                    existing_meta.update(result["metadata"])
                    existing_meta.pop("skip_crop", None)
                    self._save_metadata(existing_meta)
                    
                    logger.info("Manual crop applied and Discord avatar updated")
                    
                    # Update role colors
                    if dominant_color:
                        await self._update_role_colors(dominant_color, debug=debug)
                    
                    # Update bipolar webhook avatars
                    if globals.BIPOLAR_MODE:
                        try:
                            from utils.bipolar_mode import update_webhook_avatars
                            await update_webhook_avatars(globals.client)
                        except Exception as e:
                            logger.warning(f"Failed to update bipolar webhook avatars: {e}")
                    
                except discord.HTTPException as e:
                    result["error"] = f"Discord API error: {e}"
                except Exception as e:
                    result["error"] = f"Unexpected error: {e}"
            else:
                result["error"] = "Bot client not ready"
        
        except Exception as e:
            result["error"] = f"Error in manual crop: {e}"
            logger.error(f"Error in manual_crop: {e}")
        
        return result
    
    async def auto_crop_only(self, debug: bool = False) -> Dict:
        """
        Run intelligent auto-crop on the stored original image and apply as Discord avatar.
        
        Returns:
            Dict with success status and metadata
        """
        result = {"success": False, "error": None, "metadata": {}}
        
        try:
            if not os.path.exists(self.ORIGINAL_PATH):
                result["error"] = "No original image found. Upload or fetch an image first."
                return result
            
            # Load original
            with open(self.ORIGINAL_PATH, 'rb') as f:
                image_bytes = f.read()
            image = Image.open(io.BytesIO(image_bytes))
            
            if debug:
                logger.info(f"Auto-cropping original image ({image.size[0]}x{image.size[1]})")
            
            # Run intelligent crop
            target_size = 512
            width, height = image.size
            if width <= target_size and height <= target_size:
                if debug:
                    logger.info(f"Image already at/below target size, skipping crop")
                cropped_image = image
            else:
                cropped_image = await self._intelligent_crop(image, image_bytes, target_size=target_size, debug=debug)
            
            if not cropped_image:
                result["error"] = "Intelligent crop failed"
                return result
            
            # Save cropped
            output_buffer = io.BytesIO()
            cropped_image.save(output_buffer, format='PNG')
            cropped_bytes = output_buffer.getvalue()
            
            with open(self.CURRENT_PATH, 'wb') as f:
                f.write(cropped_bytes)
            
            if debug:
                logger.info(f"Saved auto-cropped image ({len(cropped_bytes)} bytes)")
            
            # Extract dominant color
            dominant_color = self._extract_dominant_color(cropped_image, debug=debug)
            if dominant_color:
                result["metadata"]["dominant_color"] = {
                    "rgb": dominant_color,
                    "hex": "#{:02x}{:02x}{:02x}".format(*dominant_color)
                }
            
            # Update Discord avatar
            if globals.client and globals.client.user:
                try:
                    if globals.client.loop and globals.client.loop.is_running():
                        future = asyncio.run_coroutine_threadsafe(
                            globals.client.user.edit(avatar=cropped_bytes),
                            globals.client.loop
                        )
                        future.result(timeout=10)
                    else:
                        await globals.client.user.edit(avatar=cropped_bytes)
                    
                    result["success"] = True
                    result["metadata"]["changed_at"] = datetime.now().isoformat()
                    
                    # Update existing metadata
                    existing_meta = self.load_metadata() or {}
                    existing_meta.update(result["metadata"])
                    existing_meta.pop("skip_crop", None)
                    existing_meta.pop("crop_region", None)
                    self._save_metadata(existing_meta)
                    
                    logger.info("Auto-crop applied and Discord avatar updated")
                    
                    if dominant_color:
                        await self._update_role_colors(dominant_color, debug=debug)
                    
                    if globals.BIPOLAR_MODE:
                        try:
                            from utils.bipolar_mode import update_webhook_avatars
                            await update_webhook_avatars(globals.client)
                        except Exception as e:
                            logger.warning(f"Failed to update bipolar webhook avatars: {e}")
                    
                except discord.HTTPException as e:
                    result["error"] = f"Discord API error: {e}"
                except Exception as e:
                    result["error"] = f"Unexpected error: {e}"
            else:
                result["error"] = "Bot client not ready"
        
        except Exception as e:
            result["error"] = f"Error in auto_crop_only: {e}"
            logger.error(f"Error in auto_crop_only: {e}")
        
        return result
    
    async def update_description(self, description: str, reinject_cat: bool = True, debug: bool = False) -> Dict:
        """
        Update the profile picture description and optionally re-inject into Cheshire Cat memory.
        
        Args:
            description: New description text
            reinject_cat: Whether to store as a declarative fact in Cheshire Cat
            debug: Enable debug output
            
        Returns:
            Dict with success status
        """
        result = {"success": False, "error": None}
        
        try:
            # Save to description file
            description_path = os.path.join(self.PROFILE_PIC_DIR, "current_description.txt")
            with open(description_path, 'w', encoding='utf-8') as f:
                f.write(description)
            
            # Update metadata
            metadata = self.load_metadata() or {}
            metadata["description"] = description
            self._save_metadata(metadata)
            
            if debug:
                logger.info(f"Updated description ({len(description)} chars)")
            
            # Re-inject into Cheshire Cat as declarative memory
            if reinject_cat and globals.USE_CHESHIRE_CAT:
                try:
                    from utils.cat_client import cat_adapter
                    fact_content = f"Miku's current profile picture shows: {description}"
                    await cat_adapter.create_memory_point(
                        collection="declarative",
                        content=fact_content,
                        user_id="profile_picture_manager",
                        source="profile_picture_description",
                        metadata={"type": "profile_picture", "updated_at": datetime.now().isoformat()}
                    )
                    if debug:
                        logger.info("Re-injected description into Cheshire Cat declarative memory")
                except Exception as e:
                    logger.warning(f"Failed to re-inject description into Cat: {e}")
                    # Don't fail the whole operation
            
            result["success"] = True
        
        except Exception as e:
            result["error"] = f"Error updating description: {e}"
            logger.error(f"Error in update_description: {e}")
        
        return result
    
    async def regenerate_description(self, debug: bool = False) -> Dict:
        """
        Re-generate the description from the current original image using the vision model.
        
        Returns:
            Dict with success status and new description
        """
        result = {"success": False, "error": None, "description": None}
        
        try:
            # Try original first, fall back to current
            image_path = self.ORIGINAL_PATH if os.path.exists(self.ORIGINAL_PATH) else self.CURRENT_PATH
            if not os.path.exists(image_path):
                result["error"] = "No image found to describe"
                return result
            
            with open(image_path, 'rb') as f:
                image_bytes = f.read()
            
            if debug:
                logger.info("Regenerating image description via vision model...")
            
            description = await self._generate_image_description(image_bytes, debug=debug)
            
            if description:
                # Save it
                update_result = await self.update_description(description, reinject_cat=True, debug=debug)
                result["success"] = update_result["success"]
                result["description"] = description
                if update_result.get("error"):
                    result["error"] = update_result["error"]
            else:
                result["error"] = "Vision model returned no description"
        
        except Exception as e:
            result["error"] = f"Error regenerating description: {e}"
            logger.error(f"Error in regenerate_description: {e}")
        
        return result

    # =========================================================================
    # Album / Gallery System
    # =========================================================================

    def _album_entry_dir(self, entry_id: str) -> str:
        """Return the directory path for an album entry."""
        return os.path.join(self.ALBUM_DIR, entry_id)

    def _save_album_metadata(self, entry_id: str, metadata: Dict):
        """Save metadata for an album entry."""
        entry_dir = self._album_entry_dir(entry_id)
        os.makedirs(entry_dir, exist_ok=True)
        meta_path = os.path.join(entry_dir, "metadata.json")
        with open(meta_path, 'w') as f:
            json.dump(metadata, f, indent=2)

    def _load_album_metadata(self, entry_id: str) -> Optional[Dict]:
        """Load metadata for an album entry."""
        meta_path = os.path.join(self._album_entry_dir(entry_id), "metadata.json")
        try:
            if os.path.exists(meta_path):
                with open(meta_path, 'r') as f:
                    return json.load(f)
        except Exception as e:
            logger.error(f"Error loading album metadata for {entry_id}: {e}")
        return None

    async def _save_current_to_album(self, debug: bool = False) -> Optional[str]:
        """
        Archive the current PFP (original + cropped + metadata) into the album.

        Returns:
            The new album entry ID, or None if nothing to archive.
        """
        if not os.path.exists(self.ORIGINAL_PATH) and not os.path.exists(self.CURRENT_PATH):
            if debug:
                logger.info("No current PFP to archive")
            return None

        entry_id = str(uuid.uuid4())
        entry_dir = self._album_entry_dir(entry_id)
        os.makedirs(entry_dir, exist_ok=True)

        # Copy images
        if os.path.exists(self.ORIGINAL_PATH):
            shutil.copy2(self.ORIGINAL_PATH, os.path.join(entry_dir, "original.png"))
        if os.path.exists(self.CURRENT_PATH):
            shutil.copy2(self.CURRENT_PATH, os.path.join(entry_dir, "cropped.png"))

        # Build metadata from current
        current_meta = self.load_metadata() or {}
        description = self.get_current_description() or ""

        album_meta = {
            "id": entry_id,
            "added_at": datetime.now().isoformat(),
            "source": current_meta.get("source", "unknown"),
            "description": description,
            "dominant_color": current_meta.get("dominant_color"),
            "original_width": current_meta.get("original_width"),
            "original_height": current_meta.get("original_height"),
            "crop_region": current_meta.get("crop_region"),
            "was_current": True,
            "archived_at": datetime.now().isoformat(),
        }
        # Carry over Danbooru metadata if present
        for key in ("danbooru_post_id", "danbooru_tags", "danbooru_artist",
                     "danbooru_rating", "danbooru_score", "danbooru_file_url"):
            if key in current_meta:
                album_meta[key] = current_meta[key]

        self._save_album_metadata(entry_id, album_meta)

        if debug:
            logger.info(f"Archived current PFP to album entry {entry_id}")
        return entry_id

    async def add_to_album(
        self,
        image_bytes: bytes,
        source: str = "custom_upload",
        extra_metadata: Optional[Dict] = None,
        generate_description: bool = True,
        auto_crop: bool = True,
        debug: bool = False,
        _batch_mode: bool = False,
    ) -> Dict:
        """
        Add an image to the album gallery.

        Args:
            image_bytes: Raw image bytes
            source: Source label (e.g. "custom_upload", "danbooru")
            extra_metadata: Additional metadata to store (Danbooru info, etc.)
            generate_description: Whether to generate a vision-model description
            auto_crop: Whether to auto-crop (face detection → saliency)
            debug: Enable debug output
            _batch_mode: Internal flag — if True, skip starting/stopping face detector
                         (caller manages detector lifecycle)

        Returns:
            Dict with success, entry_id, metadata
        """
        result = {"success": False, "error": None, "entry_id": None, "metadata": {}}

        try:
            image = Image.open(io.BytesIO(image_bytes))
            if image.mode not in ("RGB", "RGBA"):
                image = image.convert("RGB")

            entry_id = str(uuid.uuid4())
            entry_dir = self._album_entry_dir(entry_id)
            os.makedirs(entry_dir, exist_ok=True)

            # Save original
            original_path = os.path.join(entry_dir, "original.png")
            with open(original_path, 'wb') as f:
                f.write(image_bytes)

            album_meta: Dict = {
                "id": entry_id,
                "added_at": datetime.now().isoformat(),
                "source": source,
                "original_width": image.size[0],
                "original_height": image.size[1],
            }
            if extra_metadata:
                album_meta.update(extra_metadata)

            # Generate description
            if generate_description:
                if debug:
                    logger.info(f"[Album {entry_id[:8]}] Generating description...")
                description = await self._generate_image_description(image_bytes, debug=debug)
                if description:
                    album_meta["description"] = description
                    if debug:
                        logger.info(f"[Album {entry_id[:8]}] Description: {description[:80]}...")

            # Auto-crop
            if auto_crop:
                target_size = 512
                w, h = image.size
                if w <= target_size and h <= target_size:
                    cropped_image = image
                else:
                    if _batch_mode:
                        # In batch mode the face detector is already running
                        cropped_image = await self._intelligent_crop(image, image_bytes, target_size=target_size, debug=debug)
                    else:
                        cropped_image = await self._intelligent_crop(image, image_bytes, target_size=target_size, debug=debug)

                if cropped_image:
                    buf = io.BytesIO()
                    cropped_image.save(buf, format='PNG')
                    cropped_bytes = buf.getvalue()
                    cropped_path = os.path.join(entry_dir, "cropped.png")
                    with open(cropped_path, 'wb') as f:
                        f.write(cropped_bytes)

                    # Extract dominant color from cropped
                    dominant_color = self._extract_dominant_color(cropped_image, debug=debug)
                    if dominant_color:
                        album_meta["dominant_color"] = {
                            "rgb": dominant_color,
                            "hex": "#{:02x}{:02x}{:02x}".format(*dominant_color)
                        }
                else:
                    if debug:
                        logger.warning(f"[Album {entry_id[:8]}] Auto-crop failed, using original resized")
                    # Fallback — resize original to square
                    cropped_image = image.copy()
                    cropped_image.thumbnail((target_size, target_size), Image.Resampling.LANCZOS)
                    buf = io.BytesIO()
                    cropped_image.save(buf, format='PNG')
                    with open(os.path.join(entry_dir, "cropped.png"), 'wb') as f:
                        f.write(buf.getvalue())

            self._save_album_metadata(entry_id, album_meta)

            result["success"] = True
            result["entry_id"] = entry_id
            result["metadata"] = album_meta
            if debug:
                logger.info(f"[Album] Added entry {entry_id}")

        except Exception as e:
            result["error"] = f"Error adding to album: {e}"
            logger.error(f"Error in add_to_album: {e}")
            import traceback
            traceback.print_exc()

        return result

    async def add_batch_to_album(
        self,
        images: List[Dict],
        debug: bool = False,
    ) -> Dict:
        """
        Batch-add multiple images to the album efficiently.
        Keeps the vision model loaded and the face detector running across all images.

        Args:
            images: List of dicts, each with 'bytes' (raw image bytes) and optional 'source', 'extra_metadata'
            debug: Enable debug output

        Returns:
            Dict with results list and summary counts
        """
        results = []
        success_count = 0
        fail_count = 0
        face_detector_started = False

        try:
            # Pre-start face detector for the whole batch
            await self._ensure_vram_available(debug=debug)
            if await self._start_face_detector(debug=debug):
                face_detector_started = True
                if debug:
                    logger.info(f"[Album Batch] Face detector started for {len(images)} images")

            for i, img_info in enumerate(images):
                if debug:
                    logger.info(f"[Album Batch] Processing image {i+1}/{len(images)}...")

                entry_result = await self.add_to_album(
                    image_bytes=img_info["bytes"],
                    source=img_info.get("source", "custom_upload"),
                    extra_metadata=img_info.get("extra_metadata"),
                    generate_description=True,
                    auto_crop=True,
                    debug=debug,
                    _batch_mode=True,
                )
                results.append(entry_result)
                if entry_result["success"]:
                    success_count += 1
                else:
                    fail_count += 1

        except Exception as e:
            logger.error(f"Error in batch album add: {e}")
        finally:
            # Clean up face detector after entire batch
            if face_detector_started:
                await self._stop_face_detector(debug=debug)

        return {
            "success": fail_count == 0,
            "total": len(images),
            "succeeded": success_count,
            "failed": fail_count,
            "results": results,
        }

    def get_album_entries(self) -> List[Dict]:
        """
        List all album entries with their metadata, sorted newest-first.

        Returns:
            List of metadata dicts
        """
        entries = []
        if not os.path.exists(self.ALBUM_DIR):
            return entries

        for name in os.listdir(self.ALBUM_DIR):
            entry_dir = os.path.join(self.ALBUM_DIR, name)
            if not os.path.isdir(entry_dir):
                continue
            meta = self._load_album_metadata(name)
            if meta:
                # Add convenience flags
                meta["has_original"] = os.path.exists(os.path.join(entry_dir, "original.png"))
                meta["has_cropped"] = os.path.exists(os.path.join(entry_dir, "cropped.png"))
                entries.append(meta)

        # Sort newest first
        entries.sort(key=lambda e: e.get("added_at", ""), reverse=True)
        return entries

    def get_album_entry(self, entry_id: str) -> Optional[Dict]:
        """Get metadata for a single album entry."""
        entry_dir = self._album_entry_dir(entry_id)
        if not os.path.isdir(entry_dir):
            return None
        meta = self._load_album_metadata(entry_id)
        if meta:
            meta["has_original"] = os.path.exists(os.path.join(entry_dir, "original.png"))
            meta["has_cropped"] = os.path.exists(os.path.join(entry_dir, "cropped.png"))
        return meta

    def get_album_image_path(self, entry_id: str, image_type: str = "cropped") -> Optional[str]:
        """
        Get the file path for an album entry's image.

        Args:
            entry_id: Album entry UUID
            image_type: 'original' or 'cropped'

        Returns:
            Absolute path string or None if not found
        """
        filename = "original.png" if image_type == "original" else "cropped.png"
        path = os.path.join(self._album_entry_dir(entry_id), filename)
        return path if os.path.exists(path) else None

    async def set_album_entry_as_current(self, entry_id: str, archive_current: bool = True, debug: bool = False) -> Dict:
        """
        Set an album entry as the current Discord profile picture.
        Optionally archives the current PFP into the album first.

        Args:
            entry_id: Album entry UUID
            archive_current: Whether to archive current PFP before replacing
            debug: Enable debug output

        Returns:
            Dict with success status
        """
        result = {"success": False, "error": None, "archived_entry_id": None}

        try:
            entry_dir = self._album_entry_dir(entry_id)
            meta = self._load_album_metadata(entry_id)
            if not meta:
                result["error"] = f"Album entry {entry_id} not found"
                return result

            # Find the best image to use — prefer cropped, fall back to original
            cropped_path = os.path.join(entry_dir, "cropped.png")
            original_path = os.path.join(entry_dir, "original.png")

            if not os.path.exists(cropped_path) and not os.path.exists(original_path):
                result["error"] = "Album entry has no images"
                return result

            # Archive current PFP first
            if archive_current and (os.path.exists(self.ORIGINAL_PATH) or os.path.exists(self.CURRENT_PATH)):
                archived_id = await self._save_current_to_album(debug=debug)
                result["archived_entry_id"] = archived_id

            # Copy album entry images to current slots
            if os.path.exists(original_path):
                shutil.copy2(original_path, self.ORIGINAL_PATH)
            if os.path.exists(cropped_path):
                shutil.copy2(cropped_path, self.CURRENT_PATH)
            elif os.path.exists(original_path):
                # No cropped version — copy original as current too
                shutil.copy2(original_path, self.CURRENT_PATH)

            # Read the avatar bytes for Discord
            avatar_path = self.CURRENT_PATH if os.path.exists(self.CURRENT_PATH) else self.ORIGINAL_PATH
            with open(avatar_path, 'rb') as f:
                avatar_bytes = f.read()

            # Update Discord avatar
            if globals.client and globals.client.user:
                try:
                    if globals.client.loop and globals.client.loop.is_running():
                        future = asyncio.run_coroutine_threadsafe(
                            globals.client.user.edit(avatar=avatar_bytes),
                            globals.client.loop
                        )
                        future.result(timeout=10)
                    else:
                        await globals.client.user.edit(avatar=avatar_bytes)

                    result["success"] = True
                    logger.info(f"Set album entry {entry_id} as current PFP")
                except discord.HTTPException as e:
                    result["error"] = f"Discord API error: {e}"
                    return result
                except Exception as e:
                    result["error"] = f"Unexpected error updating avatar: {e}"
                    return result
            else:
                result["error"] = "Bot client not ready"
                return result

            # Update current metadata
            new_meta = {
                "source": meta.get("source", "album"),
                "changed_at": datetime.now().isoformat(),
                "album_entry_id": entry_id,
                "original_width": meta.get("original_width"),
                "original_height": meta.get("original_height"),
                "dominant_color": meta.get("dominant_color"),
                "crop_region": meta.get("crop_region"),
            }
            self._save_metadata(new_meta)

            # Update description
            description = meta.get("description", "")
            if description:
                desc_path = os.path.join(self.PROFILE_PIC_DIR, "current_description.txt")
                with open(desc_path, 'w', encoding='utf-8') as f:
                    f.write(description)

            # Update role colors
            dom_color = meta.get("dominant_color")
            if dom_color and "rgb" in dom_color:
                color_tuple = tuple(dom_color["rgb"])
                await self._update_role_colors(color_tuple, debug=debug)

            # Update bipolar webhooks
            if globals.BIPOLAR_MODE:
                try:
                    from utils.bipolar_mode import update_webhook_avatars
                    await update_webhook_avatars(globals.client)
                except Exception as e:
                    logger.warning(f"Failed to update bipolar webhook avatars: {e}")

        except Exception as e:
            result["error"] = f"Error setting album entry as current: {e}"
            logger.error(f"Error in set_album_entry_as_current: {e}")

        return result

    def delete_album_entry(self, entry_id: str) -> bool:
        """Delete a single album entry and its files."""
        entry_dir = self._album_entry_dir(entry_id)
        if os.path.isdir(entry_dir):
            shutil.rmtree(entry_dir)
            logger.info(f"Deleted album entry {entry_id}")
            return True
        return False

    def delete_album_entries(self, entry_ids: List[str]) -> Dict:
        """Bulk delete multiple album entries."""
        deleted = 0
        failed = 0
        for eid in entry_ids:
            if self.delete_album_entry(eid):
                deleted += 1
            else:
                failed += 1
        return {"deleted": deleted, "failed": failed, "total": len(entry_ids)}

    def get_album_disk_usage(self) -> Dict:
        """Calculate total disk usage of the album directory."""
        total_bytes = 0
        entry_count = 0
        if os.path.exists(self.ALBUM_DIR):
            for name in os.listdir(self.ALBUM_DIR):
                entry_dir = os.path.join(self.ALBUM_DIR, name)
                if not os.path.isdir(entry_dir):
                    continue
                entry_count += 1
                for fname in os.listdir(entry_dir):
                    fpath = os.path.join(entry_dir, fname)
                    if os.path.isfile(fpath):
                        total_bytes += os.path.getsize(fpath)

        # Human-readable
        if total_bytes < 1024:
            human = f"{total_bytes} B"
        elif total_bytes < 1024 * 1024:
            human = f"{total_bytes / 1024:.1f} KB"
        elif total_bytes < 1024 * 1024 * 1024:
            human = f"{total_bytes / (1024*1024):.1f} MB"
        else:
            human = f"{total_bytes / (1024*1024*1024):.2f} GB"

        return {
            "total_bytes": total_bytes,
            "human_readable": human,
            "entry_count": entry_count,
        }

    async def update_album_entry_description(self, entry_id: str, description: str, debug: bool = False) -> Dict:
        """Update the description of an album entry."""
        result = {"success": False, "error": None}
        meta = self._load_album_metadata(entry_id)
        if not meta:
            result["error"] = f"Album entry {entry_id} not found"
            return result
        meta["description"] = description
        self._save_album_metadata(entry_id, meta)
        result["success"] = True
        if debug:
            logger.info(f"Updated description for album entry {entry_id}")
        return result

    async def manual_crop_album_entry(
        self, entry_id: str, x: int, y: int, width: int, height: int,
        target_size: int = 512, debug: bool = False
    ) -> Dict:
        """
        Manually crop an album entry's original image.
        Does NOT apply to Discord — only updates the album entry's cropped.png.
        """
        result = {"success": False, "error": None, "metadata": {}}

        entry_dir = self._album_entry_dir(entry_id)
        original_path = os.path.join(entry_dir, "original.png")

        if not os.path.exists(original_path):
            result["error"] = f"No original image for album entry {entry_id}"
            return result

        try:
            image = Image.open(original_path)
            img_w, img_h = image.size

            if x < 0 or y < 0:
                result["error"] = f"Crop coordinates must be non-negative"
                return result
            if x + width > img_w or y + height > img_h:
                result["error"] = f"Crop region exceeds image bounds ({img_w}x{img_h})"
                return result
            if width < 64 or height < 64:
                result["error"] = f"Crop region too small (min 64x64)"
                return result

            cropped = image.crop((x, y, x + width, y + height))
            cropped = cropped.resize((target_size, target_size), Image.Resampling.LANCZOS)

            buf = io.BytesIO()
            cropped.save(buf, format='PNG')
            cropped_bytes = buf.getvalue()

            with open(os.path.join(entry_dir, "cropped.png"), 'wb') as f:
                f.write(cropped_bytes)

            # Update metadata
            meta = self._load_album_metadata(entry_id) or {}
            meta["crop_region"] = {"x": x, "y": y, "width": width, "height": height}

            # Re-extract dominant color
            dominant_color = self._extract_dominant_color(cropped, debug=debug)
            if dominant_color:
                meta["dominant_color"] = {
                    "rgb": dominant_color,
                    "hex": "#{:02x}{:02x}{:02x}".format(*dominant_color)
                }
            self._save_album_metadata(entry_id, meta)

            result["success"] = True
            result["metadata"] = meta
            if debug:
                logger.info(f"Manual crop applied to album entry {entry_id}")

        except Exception as e:
            result["error"] = f"Error cropping album entry: {e}"
            logger.error(f"Error in manual_crop_album_entry: {e}")

        return result

    async def auto_crop_album_entry(self, entry_id: str, debug: bool = False) -> Dict:
        """
        Auto-crop an album entry's original image (face detection → saliency).
        Does NOT apply to Discord — only updates the album entry's cropped.png.
        """
        result = {"success": False, "error": None, "metadata": {}}

        entry_dir = self._album_entry_dir(entry_id)
        original_path = os.path.join(entry_dir, "original.png")

        if not os.path.exists(original_path):
            result["error"] = f"No original image for album entry {entry_id}"
            return result

        try:
            with open(original_path, 'rb') as f:
                image_bytes = f.read()
            image = Image.open(io.BytesIO(image_bytes))

            target_size = 512
            w, h = image.size
            if w <= target_size and h <= target_size:
                cropped_image = image
            else:
                cropped_image = await self._intelligent_crop(image, image_bytes, target_size=target_size, debug=debug)

            if not cropped_image:
                result["error"] = "Auto-crop failed"
                return result

            buf = io.BytesIO()
            cropped_image.save(buf, format='PNG')
            with open(os.path.join(entry_dir, "cropped.png"), 'wb') as f:
                f.write(buf.getvalue())

            # Update metadata
            meta = self._load_album_metadata(entry_id) or {}
            meta.pop("crop_region", None)  # auto-crop doesn't have user-defined region

            dominant_color = self._extract_dominant_color(cropped_image, debug=debug)
            if dominant_color:
                meta["dominant_color"] = {
                    "rgb": dominant_color,
                    "hex": "#{:02x}{:02x}{:02x}".format(*dominant_color)
                }
            self._save_album_metadata(entry_id, meta)

            result["success"] = True
            result["metadata"] = meta
            if debug:
                logger.info(f"Auto-crop applied to album entry {entry_id}")

        except Exception as e:
            result["error"] = f"Error auto-cropping album entry: {e}"
            logger.error(f"Error in auto_crop_album_entry: {e}")

        return result


# Global instance
profile_picture_manager = ProfilePictureManager()
