"""
Private Gallery Router

API endpoints for the Private Gallery feature:
- Authentication (setup, unlock, lock, change-password)
- Configuration management
- Relationship types CRUD
- Persons CRUD
- Media CRUD with encryption
- File upload/copy with encryption
- File serving with on-the-fly decryption
- Batch operations
- Export (decrypted downloads)
- Albums (auto-generated from persons)
- Statistics
"""

import asyncio
import hashlib
import json
import mimetypes
import os
import re
import secrets
import shutil
import tempfile
import time
import uuid
from datetime import datetime, date, timedelta
from io import BytesIO
from pathlib import Path
import threading
from threading import Lock
from typing import Dict, List, Optional, Any
from zipfile import ZipFile

from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response, UploadFile, File, Form, Header
from fastapi.responses import StreamingResponse, FileResponse
from pydantic import BaseModel, Field
from slowapi import Limiter
from slowapi.util import get_remote_address

from ..core.dependencies import get_current_user, get_app_state
from ..core.exceptions import handle_exceptions, NotFoundError, ValidationError, AuthError
from ..core.responses import message_response, now_iso8601
from modules.universal_logger import get_logger
from modules.private_gallery_crypto import get_private_gallery_crypto

logger = get_logger('API')

router = APIRouter(prefix="/api/private-gallery", tags=["Private Gallery"])
limiter = Limiter(key_func=get_remote_address)

# Custom header for private gallery auth
PRIVATE_GALLERY_TOKEN_HEADER = "X-Private-Gallery-Token"

# In-memory cache for decrypted post lists (avoids re-decrypting all posts on every page)
_posts_cache: Dict[str, Any] = {}
_posts_cache_time: Dict[str, float] = {}  # cache_key -> timestamp
_posts_cache_version: int = 0
_posts_cache_lock = Lock()
_POSTS_CACHE_TTL = 30  # seconds — ensures external DB changes (e.g. reddit monitor) are picked up

def _invalidate_posts_cache():
    """Bump version to invalidate cached decrypted post lists."""
    global _posts_cache_version
    with _posts_cache_lock:
        _posts_cache_version += 1
        _posts_cache.clear()
        _posts_cache_time.clear()


# In-memory config cache (avoids DB query on every thumbnail/file request)
_config_cache: Dict[str, Any] = {}
_config_cache_time: float = 0
_config_cache_lock = Lock()
_CONFIG_CACHE_TTL = 30  # seconds


def _invalidate_config_cache():
    """Invalidate the config cache (call after config changes)."""
    global _config_cache_time
    with _config_cache_lock:
        _config_cache_time = 0
        _config_cache.clear()


# In-memory LRU cache for decrypted thumbnails
# Thumbnails are ~20KB each, 2000 items ≈ 40MB max memory
from collections import OrderedDict

_thumb_cache: OrderedDict = OrderedDict()  # storage_id -> (etag, bytes)
_thumb_cache_lock = Lock()
_THUMB_CACHE_MAX = 2000


def _thumb_cache_get(storage_id: str):
    """Get a thumbnail from cache. Returns (etag, bytes) or None."""
    with _thumb_cache_lock:
        item = _thumb_cache.get(storage_id)
        if item:
            _thumb_cache.move_to_end(storage_id)
        return item


def _thumb_cache_put(storage_id: str, etag: str, data: bytes):
    """Put a thumbnail into cache, evicting oldest if full."""
    with _thumb_cache_lock:
        if storage_id in _thumb_cache:
            _thumb_cache.move_to_end(storage_id)
        else:
            if len(_thumb_cache) >= _THUMB_CACHE_MAX:
                _thumb_cache.popitem(last=False)
        _thumb_cache[storage_id] = (etag, data)


def _thumb_cache_invalidate(storage_id: str):
    """Remove a specific thumbnail from cache."""
    with _thumb_cache_lock:
        _thumb_cache.pop(storage_id, None)


# ============================================================================
# JOB TRACKING FOR BACKGROUND PROCESSING
# ============================================================================

_pg_jobs: Dict[str, Dict] = {}
_pg_jobs_lock = Lock()


def _create_pg_job(job_id: str, total_files: int, operation: str):
    """Create a new private gallery job."""
    with _pg_jobs_lock:
        _pg_jobs[job_id] = {
            'id': job_id,
            'status': 'processing',
            'operation': operation,
            'total_files': total_files,
            'processed_files': 0,
            'success_count': 0,
            'failed_count': 0,
            'duplicate_count': 0,
            'results': [],
            'current_file': None,
            'current_phase': None,
            'bytes_downloaded': 0,
            'bytes_total': 0,
            'started_at': datetime.now().isoformat(),
            'completed_at': None
        }


def _update_pg_job(job_id: str, updates: Dict):
    """Update a private gallery job's status."""
    with _pg_jobs_lock:
        if job_id in _pg_jobs:
            _pg_jobs[job_id].update(updates)


def _get_pg_job(job_id: str) -> Optional[Dict]:
    """Get the current status of a private gallery job."""
    with _pg_jobs_lock:
        job = _pg_jobs.get(job_id)
        return dict(job) if job else None


def _cleanup_old_pg_jobs():
    """Remove jobs older than 1 hour."""
    with _pg_jobs_lock:
        now = datetime.now()
        to_remove = []
        for job_id, job in _pg_jobs.items():
            if job.get('completed_at'):
                try:
                    completed = datetime.fromisoformat(job['completed_at'])
                    if (now - completed).total_seconds() > 3600:
                        to_remove.append(job_id)
                except (ValueError, TypeError):
                    pass
        for job_id in to_remove:
            del _pg_jobs[job_id]


# ============================================================================
# PYDANTIC MODELS
# ============================================================================

class SetupRequest(BaseModel):
    password: str = Field(..., min_length=8)


class UnlockRequest(BaseModel):
    password: str


class ChangePasswordRequest(BaseModel):
    current_password: str
    new_password: str = Field(..., min_length=8)


class ConfigUpdateRequest(BaseModel):
    storage_path: Optional[str] = None
    thumbnail_path: Optional[str] = None
    organize_by_person: Optional[bool] = None
    organize_by_date: Optional[bool] = None
    auto_lock_minutes: Optional[int] = None
    duplicate_auto_select_distance: Optional[int] = Field(None, ge=0, le=12)
    min_import_resolution: Optional[int] = Field(None, ge=0, le=10000)


class RelationshipRequest(BaseModel):
    name: str = Field(..., min_length=1, max_length=50)
    color: Optional[str] = "#6366f1"


class PersonRequest(BaseModel):
    name: str = Field(..., min_length=1, max_length=100)
    sort_name: Optional[str] = None
    relationship_id: int
    default_tag_ids: Optional[List[int]] = None


class PersonUpdateRequest(BaseModel):
    name: Optional[str] = None
    sort_name: Optional[str] = None
    relationship_id: Optional[int] = None
    default_tag_ids: Optional[List[int]] = None


class MediaUpdateRequest(BaseModel):
    description: Optional[str] = None
    person_id: Optional[int] = None
    media_date: Optional[str] = None
    tag_ids: Optional[List[int]] = None


class PostUpdateRequest(BaseModel):
    description: Optional[str] = None
    person_id: Optional[int] = None
    media_date: Optional[str] = None
    tag_ids: Optional[List[int]] = None


class AttachMediaRequest(BaseModel):
    media_ids: List[int]


class CopyRequest(BaseModel):
    source_paths: List[str]
    person_id: int
    tag_ids: List[int] = []
    media_date: Optional[str] = None
    description: Optional[str] = None
    original_filenames: Optional[Dict[str, str]] = None  # source_path -> original filename


class ImportUrlRequest(BaseModel):
    urls: List[str]
    person_id: int
    tag_ids: List[int] = []
    media_date: Optional[str] = None
    description: Optional[str] = None


class ImportDirectoryRequest(BaseModel):
    directory_path: str
    person_id: int
    tag_ids: List[int] = []
    media_date: Optional[str] = None
    description: Optional[str] = None
    recursive: bool = False


class FeaturesUpdateRequest(BaseModel):
    """Request body for updating enabled features."""
    enabled_features: List[str] = Field(..., description="List of enabled feature paths")
    feature_order: Optional[Dict[str, List[str]]] = Field(None, description="Order of features by group")
    feature_labels: Optional[Dict[str, str]] = Field(None, description="Custom labels for features: {'/path': 'Custom Label'}")
    group_order: Optional[List[str]] = Field(None, description="Order of groups: ['media', 'video', ...]")


class BatchDeleteRequest(BaseModel):
    media_ids: List[int]


class BatchTagsRequest(BaseModel):
    media_ids: List[int]
    add_tag_ids: Optional[List[int]] = None
    remove_tag_ids: Optional[List[int]] = None


class BatchReadStatusRequest(BaseModel):
    post_ids: List[int]
    is_read: bool


class BatchDateRequest(BaseModel):
    media_ids: List[int]
    new_date: str


class BatchPersonRequest(BaseModel):
    media_ids: List[int]
    person_id: int


class ExportBatchRequest(BaseModel):
    media_ids: List[int]
    organize_by_date: Optional[bool] = False
    organize_by_person: Optional[bool] = False


class ExportAllRequest(BaseModel):
    organize_by_date: Optional[bool] = True
    organize_by_person: Optional[bool] = True
    include_metadata: Optional[bool] = True


class PostCreateRequest(BaseModel):
    person_id: int
    tag_ids: Optional[List[int]] = None
    media_date: Optional[str] = None
    description: Optional[str] = None
    media_ids: Optional[List[int]] = None


class TagCreateRequest(BaseModel):
    name: str = Field(..., min_length=1, max_length=100)
    color: Optional[str] = '#6b7280'
    description: Optional[str] = None


class TagUpdateRequest(BaseModel):
    name: Optional[str] = None
    color: Optional[str] = None
    description: Optional[str] = None


class ImportAuthCreateRequest(BaseModel):
    domain: str = Field(..., min_length=1, max_length=253)
    auth_type: str = Field(default='basic', pattern='^(basic|cookies|both)$')
    username: Optional[str] = None
    password: Optional[str] = None
    cookies: Optional[List[dict]] = None
    user_agent: Optional[str] = None
    notes: Optional[str] = None


class ImportAuthUpdateRequest(BaseModel):
    domain: Optional[str] = None
    auth_type: Optional[str] = Field(default=None, pattern='^(basic|cookies|both)$')
    username: Optional[str] = None
    password: Optional[str] = None
    cookies: Optional[List[dict]] = None
    user_agent: Optional[str] = None
    notes: Optional[str] = None


class PersonGroupCreate(BaseModel):
    name: str
    description: Optional[str] = None
    min_resolution: Optional[int] = Field(0, ge=0, le=10000)

class PersonGroupUpdate(BaseModel):
    name: Optional[str] = None
    description: Optional[str] = None
    min_resolution: Optional[int] = Field(None, ge=0, le=10000)

class PersonGroupMemberAdd(BaseModel):
    person_id: int

class PersonGroupTagMemberAdd(BaseModel):
    tag_id: int

class PersonGroupRelationshipMemberAdd(BaseModel):
    relationship_id: int

class PersonGroupExcludedTagAdd(BaseModel):
    tag_id: int


class RedditCommunityCreate(BaseModel):
    subreddit_name: str
    person_id: int

class RedditCommunityUpdate(BaseModel):
    subreddit_name: Optional[str] = None
    person_id: Optional[int] = None
    enabled: Optional[bool] = None

class RedditMonitorSettingsUpdate(BaseModel):
    enabled: Optional[bool] = None
    check_interval_hours: Optional[int] = None
    lookback_days: Optional[int] = None

class RedditCookiesUpload(BaseModel):
    cookies_json: str  # JSON string of cookies array


class ScraperAccountCreate(BaseModel):
    username: str
    person_id: int

class ScraperAccountUpdate(BaseModel):
    person_id: Optional[int] = None
    enabled: Optional[bool] = None


# ============================================================================
# HELPER FUNCTIONS
# ============================================================================

def _get_crypto():
    """Get the crypto instance."""
    return get_private_gallery_crypto()


def _get_db():
    """Get the unified database instance."""
    app_state = get_app_state()
    return app_state.db


def _get_config(db) -> Dict[str, Any]:
    """Get private gallery configuration (cached for 30s)."""
    global _config_cache_time
    now = time.monotonic()
    with _config_cache_lock:
        if _config_cache and (now - _config_cache_time) < _CONFIG_CACHE_TTL:
            return dict(_config_cache)
    config = {}
    with db.get_connection() as conn:
        cursor = conn.cursor()
        cursor.execute('SELECT key, value FROM private_media_config')
        for row in cursor.fetchall():
            value = row['value']
            # Convert string booleans and numbers
            if value is None:
                pass
            elif value == 'true':
                value = True
            elif value == 'false':
                value = False
            elif value.isdigit():
                value = int(value)
            config[row['key']] = value
    with _config_cache_lock:
        _config_cache.clear()
        _config_cache.update(config)
        _config_cache_time = now
    return config


def _get_import_auth_for_url(db, crypto, url: str) -> Optional[Dict]:
    """Find matching import auth for a URL. Suffix matching, most-specific first."""
    from urllib.parse import urlparse
    try:
        parsed = urlparse(url)
        hostname = (parsed.hostname or '').lower().strip('.')
    except Exception:
        return None

    if not hostname:
        return None

    with db.get_connection() as conn:
        cursor = conn.cursor()
        cursor.execute('SELECT * FROM private_gallery_import_auth ORDER BY LENGTH(domain) DESC')
        rows = cursor.fetchall()

    for row in rows:
        domain = row['domain']
        if hostname == domain or hostname.endswith('.' + domain):
            result = {
                'auth_type': row['auth_type'],
            }
            if row['encrypted_username']:
                result['username'] = crypto.decrypt_field(row['encrypted_username'])
            if row['encrypted_password']:
                result['password'] = crypto.decrypt_field(row['encrypted_password'])
            if row['encrypted_cookies_json']:
                try:
                    result['cookies'] = json.loads(crypto.decrypt_field(row['encrypted_cookies_json']))
                except (json.JSONDecodeError, Exception) as e:
                    logger.debug(f"Error decrypting cookies: {e}", module="PrivateGallery")
            if row['encrypted_user_agent']:
                result['user_agent'] = crypto.decrypt_field(row['encrypted_user_agent'])
            return result

    return None


def _set_config(db, key: str, value: Any) -> None:
    """Set a configuration value."""
    if isinstance(value, bool):
        value = 'true' if value else 'false'
    else:
        value = str(value)

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        cursor.execute('''
            INSERT OR REPLACE INTO private_media_config (key, value, updated_at)
            VALUES (?, ?, CURRENT_TIMESTAMP)
        ''', (key, value))
        conn.commit()
    _invalidate_config_cache()


def _verify_gallery_token(
    token: str = Header(None, alias=PRIVATE_GALLERY_TOKEN_HEADER),
    _token: str = Query(None, description="Token via query param for img/video tags")
) -> Dict:
    """Verify the private gallery session token.

    Accepts token from either header (for API calls) or query param (for img/video tags).
    Refreshes session expiry on each valid request (activity-based timeout).
    """
    # Use header token first, fall back to query param
    actual_token = token or _token

    if not actual_token:
        raise AuthError("Private gallery authentication required")

    crypto = _get_crypto()
    session = crypto.verify_session(actual_token)

    if not session:
        raise AuthError("Invalid or expired session token")

    if not crypto.is_initialized():
        raise AuthError("Gallery is locked")

    # Refresh session expiry on activity
    db = _get_db()
    config = _get_config(db)
    auto_lock = config.get('auto_lock_minutes', 30)
    crypto.refresh_session(actual_token, auto_lock)

    return session


def _get_file_hash(file_path: Path) -> str:
    """Calculate SHA256 hash of a file."""
    sha256 = hashlib.sha256()
    with open(file_path, 'rb') as f:
        for chunk in iter(lambda: f.read(65536), b''):
            sha256.update(chunk)
    return sha256.hexdigest()


def _compute_perceptual_hash(file_path: Path) -> Optional[str]:
    """Calculate perceptual hash (dHash) for an image or video file."""
    try:
        import imagehash
        from PIL import Image
    except ImportError:
        return None

    ext = file_path.suffix.lower().lstrip('.')
    image_exts = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'tiff', 'heic', 'heif', 'avif'}
    video_exts = {'mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v', 'wmv', 'flv'}

    pil_image = None
    frame = None
    frame_rgb = None

    try:
        if ext in video_exts:
            try:
                import cv2
            except ImportError:
                return None
            cap = cv2.VideoCapture(str(file_path))
            if not cap.isOpened():
                return None
            total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
            cap.set(cv2.CAP_PROP_POS_FRAMES, int(total_frames * 0.5))
            ret, frame = cap.read()
            cap.release()
            if not ret or frame is None:
                return None
            frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
            pil_image = Image.fromarray(frame_rgb)
        elif ext in image_exts:
            pil_image = Image.open(file_path)
        else:
            return None

        phash = str(imagehash.dhash(pil_image, hash_size=16))
        return phash
    except Exception:
        return None
    finally:
        if pil_image is not None:
            pil_image.close()
            del pil_image
        if frame_rgb is not None:
            del frame_rgb
        if frame is not None:
            del frame


def _get_file_info(file_path: Path) -> Dict[str, Any]:
    """Get file type, mime type, and dimensions."""
    ext = file_path.suffix.lower().lstrip('.')
    mime_type, _ = mimetypes.guess_type(str(file_path))
    if not mime_type:
        mime_type = 'application/octet-stream'

    image_exts = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'tiff', 'heic', 'heif', 'avif'}
    video_exts = {'mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v', 'wmv', 'flv'}

    if ext in image_exts:
        file_type = 'image'
    elif ext in video_exts:
        file_type = 'video'
    else:
        file_type = 'other'

    info = {
        'file_type': file_type,
        'mime_type': mime_type,
        'width': None,
        'height': None,
        'duration': None
    }

    # Get dimensions for images
    if file_type == 'image':
        try:
            from PIL import Image
            with Image.open(file_path) as img:
                info['width'], info['height'] = img.size
        except Exception:
            pass

    # Get duration/dimensions for videos
    if file_type == 'video':
        try:
            import subprocess
            result = subprocess.run([
                'ffprobe', '-v', 'quiet', '-print_format', 'json',
                '-show_streams', '-show_format', str(file_path)
            ], capture_output=True, text=True, timeout=30)
            if result.returncode == 0:
                import json
                data = json.loads(result.stdout)
                for stream in data.get('streams', []):
                    if stream.get('codec_type') == 'video':
                        info['width'] = stream.get('width')
                        info['height'] = stream.get('height')
                        break
                if 'format' in data:
                    duration = data['format'].get('duration')
                    if duration:
                        info['duration'] = float(duration)
        except Exception:
            pass

    return info


def _generate_thumbnail(file_path: Path, output_path: Path, file_type: str) -> bool:
    """Generate a thumbnail for an image or video."""
    try:
        output_path.parent.mkdir(parents=True, exist_ok=True)

        if file_type == 'image':
            from PIL import Image, ImageOps
            with Image.open(file_path) as img:
                # Apply EXIF orientation before resizing
                img = ImageOps.exif_transpose(img)
                img.thumbnail((400, 400))
                # Convert to RGB if necessary (for JPEG)
                if img.mode in ('RGBA', 'P'):
                    img = img.convert('RGB')
                img.save(output_path, 'JPEG', quality=85)
            return True

        elif file_type == 'video':
            import subprocess
            result = subprocess.run([
                'ffmpeg', '-y', '-i', str(file_path),
                '-ss', '00:00:01', '-vframes', '1',
                '-vf', 'scale=400:-1:force_original_aspect_ratio=decrease',
                str(output_path)
            ], capture_output=True, timeout=30)
            return result.returncode == 0 and output_path.exists()

    except Exception as e:
        logger.error(f"Thumbnail generation failed: {e}")
    return False


def _extract_date_from_filename(filename: str) -> Optional[str]:
    """Extract date and optionally time from filename patterns."""
    # Patterns with date and time (6 groups: year, month, day, hour, minute, second)
    patterns_with_time = [
        # IMG_20260115_143022.jpg or video_20260115_143022.mp4
        r'(?:IMG|VID|video|photo)?[-_]?(\d{4})(\d{2})(\d{2})[-_](\d{2})(\d{2})(\d{2})',
        # 2026-01-15_14-30-22.jpg or 2026_01_15_14_30_22.jpg
        r'(\d{4})[-_](\d{2})[-_](\d{2})[-_](\d{2})[-_](\d{2})[-_](\d{2})',
        # 20260115143022.jpg (all digits)
        r'(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})',
    ]

    # Patterns with date only (3 groups: year, month, day)
    patterns_date_only = [
        r'(\d{4})[-_]?(\d{2})[-_]?(\d{2})',  # 2026-01-15 or 20260115
        r'IMG[-_]?(\d{4})(\d{2})(\d{2})',  # IMG_20260115
        r'(\d{2})[-_](\d{2})[-_](\d{4})',  # 15-01-2026
    ]

    # First try patterns with time
    for pattern in patterns_with_time:
        match = re.search(pattern, filename)
        if match:
            groups = match.groups()
            try:
                year, month, day = int(groups[0]), int(groups[1]), int(groups[2])
                hour, minute, second = int(groups[3]), int(groups[4]), int(groups[5])

                if (1 <= month <= 12 and 1 <= day <= 31 and 2000 <= year <= 2100 and
                    0 <= hour <= 23 and 0 <= minute <= 59 and 0 <= second <= 59):
                    return f"{year:04d}-{month:02d}-{day:02d}T{hour:02d}:{minute:02d}:{second:02d}"
            except (ValueError, IndexError):
                continue

    # Fall back to date-only patterns
    for pattern in patterns_date_only:
        match = re.search(pattern, filename)
        if match:
            groups = match.groups()
            try:
                if len(groups[0]) == 4:
                    year, month, day = int(groups[0]), int(groups[1]), int(groups[2])
                else:
                    day, month, year = int(groups[0]), int(groups[1]), int(groups[2])

                if 1 <= month <= 12 and 1 <= day <= 31 and 2000 <= year <= 2100:
                    return f"{year:04d}-{month:02d}-{day:02d}"
            except (ValueError, IndexError):
                continue

    return None


def _extract_date_from_exif(file_path: Path) -> Optional[str]:
    """Extract date and time from EXIF metadata."""
    try:
        from PIL import Image
        from PIL.ExifTags import TAGS

        with Image.open(file_path) as img:
            exif = img._getexif()
            if exif:
                for tag_id, value in exif.items():
                    tag = TAGS.get(tag_id, tag_id)
                    if tag in ('DateTimeOriginal', 'DateTime', 'DateTimeDigitized'):
                        if value:
                            # Format: "2026:01:15 14:30:22"
                            parts = value.split(' ')
                            date_part = parts[0].replace(':', '-')
                            if len(parts) > 1 and parts[1]:
                                return f"{date_part}T{parts[1]}"
                            return date_part
    except Exception:
        pass
    return None


# ============================================================================
# JOB STATUS ENDPOINT
# ============================================================================

@router.get("/job-status/{job_id}")
@limiter.limit("120/minute")
@handle_exceptions
async def get_gallery_job_status(
    request: Request,
    job_id: str,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Get the status of a private gallery background job."""
    _cleanup_old_pg_jobs()
    job = _get_pg_job(job_id)

    if not job:
        raise NotFoundError(f"Job '{job_id}' not found")

    return job


# ============================================================================
# AUTHENTICATION ENDPOINTS (No gallery auth required)
# ============================================================================

@router.get("/status")
@limiter.limit("60/minute")
@handle_exceptions
async def get_status(
    request: Request,
    current_user: Dict = Depends(get_current_user)
):
    """Check if setup is complete and current lock status."""
    db = _get_db()
    crypto = _get_crypto()
    config = _get_config(db)

    # Check if the user has a valid session token (not just if crypto is initialized)
    is_unlocked = False
    token = request.headers.get('X-Private-Gallery-Token')
    if token and crypto.is_initialized():
        session = crypto.verify_session(token)
        is_unlocked = session is not None
        # Refresh session expiry on status check (keeps session alive during active use)
        if is_unlocked:
            auto_lock = config.get('auto_lock_minutes', 30)
            crypto.refresh_session(token, auto_lock)

    return {
        "is_setup_complete": config.get('is_setup_complete', False),
        "is_unlocked": is_unlocked,
        "active_sessions": crypto.get_active_session_count()
    }


@router.post("/setup")
@limiter.limit("5/minute")
@handle_exceptions
async def setup_gallery(
    request: Request,
    body: SetupRequest,
    current_user: Dict = Depends(get_current_user)
):
    """First-time setup - create password and encryption salt."""
    db = _get_db()
    crypto = _get_crypto()
    config = _get_config(db)

    if config.get('is_setup_complete', False):
        raise ValidationError("Gallery is already set up")

    # Hash password
    password_hash = crypto.hash_password(body.password)
    _set_config(db, 'password_hash', password_hash)

    # Generate and store encryption salt
    salt = crypto.generate_salt()
    import base64
    _set_config(db, 'encryption_salt', base64.b64encode(salt).decode('utf-8'))

    # Create default relationship types
    relationships = [
        ('Friend', '#10b981'),
        ('Family', '#3b82f6'),
        ('Coworker', '#f59e0b'),
        ('Acquaintance', '#8b5cf6'),
        ('Other', '#6b7280')
    ]

    # Initialize encryption to encrypt relationship names
    crypto.initialize_encryption(body.password, salt)

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        for name, color in relationships:
            encrypted_name = crypto.encrypt_field(name)
            cursor.execute('''
                INSERT INTO private_media_relationships (encrypted_name, color)
                VALUES (?, ?)
            ''', (encrypted_name, color))
        conn.commit()

    # Create storage directories
    storage_path = Path(config.get('storage_path', '/opt/immich/private'))
    (storage_path / 'data').mkdir(parents=True, exist_ok=True)
    (storage_path / 'thumbs').mkdir(parents=True, exist_ok=True)

    # Mark setup complete
    _set_config(db, 'is_setup_complete', True)

    # Create session token
    auto_lock = config.get('auto_lock_minutes', 30)
    token = crypto.create_session(current_user.get('sub', 'user'), auto_lock)

    logger.info("Private gallery setup completed")
    return {
        "message": "Gallery setup complete",
        "token": token
    }


@router.post("/unlock")
@limiter.limit("10/minute")
@handle_exceptions
async def unlock_gallery(
    request: Request,
    body: UnlockRequest,
    current_user: Dict = Depends(get_current_user)
):
    """Unlock the gallery with password."""
    db = _get_db()
    crypto = _get_crypto()
    config = _get_config(db)

    if not config.get('is_setup_complete', False):
        raise ValidationError("Gallery is not set up yet")

    # Verify password (use 403 not 401 so frontend doesn't clear main auth session)
    password_hash = config.get('password_hash', '')
    if not crypto.verify_password(body.password, password_hash):
        raise HTTPException(status_code=403, detail="Invalid password")

    # Get salt and initialize encryption
    import base64
    salt = base64.b64decode(config.get('encryption_salt', ''))
    crypto.initialize_encryption(body.password, salt)

    # Create session token
    auto_lock = config.get('auto_lock_minutes', 30)
    token = crypto.create_session(current_user.get('sub', 'user'), auto_lock)

    logger.info("Private gallery unlocked")

    # Export key files for background services (Reddit monitor + scraper bridge)
    try:
        from modules.private_gallery_crypto import export_key_to_file
        from modules.scraper_gallery_bridge import SCRAPER_BRIDGE_KEY_FILE
        export_key_to_file(SCRAPER_BRIDGE_KEY_FILE)
    except Exception:
        pass

    # One-time migration: convert single-shot encrypted files >50MB to chunked format
    # for streaming playback. Runs once per API process on first unlock.
    if getattr(unlock_gallery, '_migration_done', False):
        return {"message": "Gallery unlocked", "token": token}
    unlock_gallery._migration_done = True

    def _auto_migrate_chunked():
        try:
            storage_path = Path(config.get('storage_path', '/opt/immich/private'))
            data_path = storage_path / 'data'
            min_size = 50 * 1024 * 1024

            with db.get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute('SELECT id, storage_id, file_size FROM private_media WHERE file_size > ? ORDER BY file_size ASC', (min_size,))
                rows = cursor.fetchall()

            to_migrate = []
            for row in rows:
                enc_file = data_path / f"{row['storage_id']}.enc"
                if enc_file.exists() and not crypto._is_chunked_format(enc_file):
                    to_migrate.append(row)

            if not to_migrate:
                return

            logger.info(f"Auto-migrating {len(to_migrate)} files to chunked encryption format")
            for row in to_migrate:
                enc_file = data_path / f"{row['storage_id']}.enc"
                try:
                    if crypto.re_encrypt_to_chunked(enc_file):
                        logger.info(f"Migrated ID {row['id']} ({row['file_size']/1e6:.0f}MB) to chunked format")
                except Exception as e:
                    logger.error(f"Migration failed for ID {row['id']}: {e}")
            logger.info("Chunked encryption migration complete")
        except Exception as e:
            logger.error(f"Auto-migration failed: {e}")

    import threading
    threading.Thread(target=_auto_migrate_chunked, daemon=True).start()

    return {
        "message": "Gallery unlocked",
        "token": token
    }


@router.post("/lock")
@limiter.limit("30/minute")
@handle_exceptions
async def lock_gallery(
    request: Request,
    token: str = Header(None, alias=PRIVATE_GALLERY_TOKEN_HEADER),
    current_user: Dict = Depends(get_current_user)
):
    """Lock the gallery and invalidate session."""
    crypto = _get_crypto()

    if token:
        crypto.invalidate_session(token)

    crypto.invalidate_all_sessions()
    crypto.clear_encryption()

    logger.info("Private gallery locked")
    return message_response("Gallery locked")


@router.post("/change-password")
@limiter.limit("5/minute")
@handle_exceptions
async def change_password(
    request: Request,
    body: ChangePasswordRequest,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Change the gallery password."""
    db = _get_db()
    crypto = _get_crypto()
    config = _get_config(db)

    # Verify current password
    password_hash = config.get('password_hash', '')
    if not crypto.verify_password(body.current_password, password_hash):
        raise AuthError("Current password is incorrect")

    # Hash new password
    new_hash = crypto.hash_password(body.new_password)
    _set_config(db, 'password_hash', new_hash)

    # Note: We keep the same salt to avoid re-encrypting all data
    # The encryption key will be different when unlocked with new password
    # This means existing encrypted data needs to be re-encrypted

    # For simplicity in this implementation, we'll keep the salt the same
    # In a production system, you might want to re-encrypt all data

    logger.info("Private gallery password changed")
    return message_response("Password changed successfully")


# ============================================================================
# CONFIGURATION ENDPOINTS
# ============================================================================

@router.get("/config")
@limiter.limit("60/minute")
@handle_exceptions
async def get_config(
    request: Request,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Get gallery configuration."""
    db = _get_db()
    config = _get_config(db)

    # Remove sensitive fields
    safe_config = {k: v for k, v in config.items()
                   if k not in ('password_hash', 'encryption_salt')}

    return {"config": safe_config}


@router.put("/config")
@limiter.limit("30/minute")
@handle_exceptions
async def update_config(
    request: Request,
    body: ConfigUpdateRequest,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Update gallery configuration."""
    db = _get_db()

    if body.storage_path is not None:
        _set_config(db, 'storage_path', body.storage_path)
        Path(body.storage_path).mkdir(parents=True, exist_ok=True)

    if body.thumbnail_path is not None:
        _set_config(db, 'thumbnail_path', body.thumbnail_path)
        Path(body.thumbnail_path).mkdir(parents=True, exist_ok=True)

    if body.organize_by_person is not None:
        _set_config(db, 'organize_by_person', body.organize_by_person)

    if body.organize_by_date is not None:
        _set_config(db, 'organize_by_date', body.organize_by_date)

    if body.auto_lock_minutes is not None:
        _set_config(db, 'auto_lock_minutes', body.auto_lock_minutes)

    if body.duplicate_auto_select_distance is not None:
        _set_config(db, 'duplicate_auto_select_distance', body.duplicate_auto_select_distance)

    if body.min_import_resolution is not None:
        _set_config(db, 'min_import_resolution', body.min_import_resolution)

    return message_response("Configuration updated")


# ============================================================================
# FEATURES ENDPOINTS
# ============================================================================

# Default list of all available features (all enabled by default)
# These paths must match App.tsx menu items
ALL_FEATURES = [
    # Primary nav (Media section)
    '/downloads',
    '/gallery',
    '/review',
    # Video dropdown
    '/videos',
    '/celebrities',
    '/queue',
    '/video/channel-monitors',
    # Tools dropdown
    '/import',
    '/faces',
    '/scheduler',
    '/appearances',
    '/press',
    '/discovery',
    '/recycle-bin',
    # Analytics dropdown
    '/analytics',
    '/health',
    '/monitoring',
    '/notifications',
    # Paid Content dropdown
    '/paid-content',
    '/paid-content/feed',
    '/paid-content/gallery',
    '/paid-content/messages',
    '/paid-content/creators',
    '/paid-content/add',
    '/paid-content/queue',
    '/paid-content/notifications',
    '/paid-content/settings',
    '/paid-content/watch-later',
    '/paid-content/analytics',
    '/paid-content/recycle',
    # Private Gallery
    '/private-gallery',
    '/private-gallery/config',
    # System dropdown (always enabled - can't disable config)
    '/config',
    '/platforms',
    '/scrapers',
    '/logs',
    '/changelog',
]


@router.get("/features")
@limiter.limit("60/minute")
@handle_exceptions
async def get_features(
    request: Request,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Get list of enabled features.

    Returns both the list of all available features and which ones are enabled.
    """
    import json

    db = _get_db()
    config = _get_config(db)

    # Get enabled features from config, default to all features enabled
    enabled_features_json = config.get('enabled_features')
    if enabled_features_json and isinstance(enabled_features_json, str):
        try:
            enabled_features = json.loads(enabled_features_json)
            # Auto-enable only truly new features (added to code after last save)
            known_features_json = config.get('known_features')
            known_features = []
            if known_features_json and isinstance(known_features_json, str):
                try:
                    known_features = json.loads(known_features_json)
                except json.JSONDecodeError:
                    pass
            for feature in ALL_FEATURES:
                if feature not in enabled_features and feature not in known_features:
                    enabled_features.append(feature)
            # Filter out stale/removed features
            enabled_features = [f for f in enabled_features if f in ALL_FEATURES]
        except json.JSONDecodeError:
            enabled_features = ALL_FEATURES.copy()
    else:
        enabled_features = ALL_FEATURES.copy()

    # Get feature order from config
    feature_order_json = config.get('feature_order')
    if feature_order_json and isinstance(feature_order_json, str):
        try:
            feature_order = json.loads(feature_order_json)
        except json.JSONDecodeError:
            feature_order = {}
    else:
        feature_order = {}

    # Get custom labels from config
    feature_labels_json = config.get('feature_labels')
    if feature_labels_json and isinstance(feature_labels_json, str):
        try:
            feature_labels = json.loads(feature_labels_json)
        except json.JSONDecodeError:
            feature_labels = {}
    else:
        feature_labels = {}

    # Get group order from config
    group_order_json = config.get('group_order')
    if group_order_json and isinstance(group_order_json, str):
        try:
            group_order = json.loads(group_order_json)
        except json.JSONDecodeError:
            group_order = []
    else:
        group_order = []

    return {
        "all_features": ALL_FEATURES,
        "enabled_features": enabled_features,
        "feature_order": feature_order,
        "feature_labels": feature_labels,
        "group_order": group_order
    }


@router.put("/features")
@limiter.limit("30/minute")
@handle_exceptions
async def update_features(
    request: Request,
    body: FeaturesUpdateRequest,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Update the list of enabled features.

    Only features in the ALL_FEATURES list can be enabled/disabled.
    System features like /config cannot be disabled.
    """
    import json

    db = _get_db()

    # Filter out any stale/removed features silently
    valid_features = [f for f in body.enabled_features if f in ALL_FEATURES]

    # Always ensure /config is enabled (can't lock yourself out)
    # Use dict.fromkeys() to deduplicate while preserving order
    enabled = list(dict.fromkeys(valid_features))
    if '/config' not in enabled:
        enabled.append('/config')

    # Store enabled features as JSON string
    _set_config(db, 'enabled_features', json.dumps(enabled))

    # Store the set of known features at save time so we can detect truly new features later
    _set_config(db, 'known_features', json.dumps(ALL_FEATURES.copy()))

    # Store feature order if provided
    feature_order = {}
    if body.feature_order:
        feature_order = body.feature_order
        _set_config(db, 'feature_order', json.dumps(feature_order))

    # Store custom labels if provided
    feature_labels = {}
    if body.feature_labels:
        feature_labels = body.feature_labels
        _set_config(db, 'feature_labels', json.dumps(feature_labels))

    # Store group order if provided
    group_order = []
    if body.group_order:
        group_order = body.group_order
        _set_config(db, 'group_order', json.dumps(group_order))

    return {
        "message": "Features updated",
        "enabled_features": enabled,
        "feature_order": feature_order,
        "feature_labels": feature_labels,
        "group_order": group_order
    }


@router.get("/features/public")
@limiter.limit("120/minute")
@handle_exceptions
async def get_features_public(
    request: Request,
    current_user: Dict = Depends(get_current_user)
):
    """Get enabled features without requiring private gallery auth.

    This endpoint is used by the frontend to determine which menu items to show.
    It doesn't expose the full feature list, just which ones are enabled.
    """
    import json

    db = _get_db()
    config = _get_config(db)

    # Get enabled features from config, default to all features enabled
    enabled_features_json = config.get('enabled_features')
    if enabled_features_json and isinstance(enabled_features_json, str):
        try:
            enabled_features = json.loads(enabled_features_json)
            # Auto-enable only truly new features (added to code after last save)
            known_features_json = config.get('known_features')
            known_features = []
            if known_features_json and isinstance(known_features_json, str):
                try:
                    known_features = json.loads(known_features_json)
                except json.JSONDecodeError:
                    pass
            for feature in ALL_FEATURES:
                if feature not in enabled_features and feature not in known_features:
                    enabled_features.append(feature)
            # Filter out stale/removed features
            enabled_features = [f for f in enabled_features if f in ALL_FEATURES]
        except json.JSONDecodeError:
            enabled_features = ALL_FEATURES.copy()
    else:
        enabled_features = ALL_FEATURES.copy()

    # Get feature order from config
    feature_order_json = config.get('feature_order')
    if feature_order_json and isinstance(feature_order_json, str):
        try:
            feature_order = json.loads(feature_order_json)
        except json.JSONDecodeError:
            feature_order = {}
    else:
        feature_order = {}

    # Get custom labels from config
    feature_labels_json = config.get('feature_labels')
    if feature_labels_json and isinstance(feature_labels_json, str):
        try:
            feature_labels = json.loads(feature_labels_json)
        except json.JSONDecodeError:
            feature_labels = {}
    else:
        feature_labels = {}

    # Get group order from config
    group_order_json = config.get('group_order')
    if group_order_json and isinstance(group_order_json, str):
        try:
            group_order = json.loads(group_order_json)
        except json.JSONDecodeError:
            group_order = []
    else:
        group_order = []

    return {
        "enabled_features": enabled_features,
        "feature_order": feature_order,
        "feature_labels": feature_labels,
        "group_order": group_order
    }


@router.post("/features/reset")
@limiter.limit("10/minute")
@handle_exceptions
async def reset_features(
    request: Request,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Reset all feature settings to defaults.

    This enables all features, clears custom labels, and resets ordering.
    """
    import json

    db = _get_db()

    # Reset to all features enabled
    _set_config(db, 'enabled_features', json.dumps(ALL_FEATURES))
    # Clear custom order
    _set_config(db, 'feature_order', json.dumps({}))
    # Clear custom labels
    _set_config(db, 'feature_labels', json.dumps({}))
    # Clear group order
    _set_config(db, 'group_order', json.dumps([]))

    return {
        "message": "Features reset to defaults",
        "enabled_features": ALL_FEATURES,
        "feature_order": {},
        "feature_labels": {},
        "group_order": []
    }


# ============================================================================
# RELATIONSHIP ENDPOINTS
# ============================================================================

@router.get("/relationships")
@limiter.limit("60/minute")
@handle_exceptions
async def get_relationships(
    request: Request,
    assigned_only: Optional[bool] = None,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Get all relationship types. If assigned_only=true, only return relationships that have persons assigned."""
    db = _get_db()
    crypto = _get_crypto()

    with db.get_connection() as conn:
        cursor = conn.cursor()
        if assigned_only:
            cursor.execute('''
                SELECT DISTINCT r.id, r.encrypted_name, r.color, r.created_at
                FROM private_media_relationships r
                WHERE r.id IN (
                    SELECT relationship_id FROM private_media_persons
                )
                ORDER BY r.id
            ''')
        else:
            cursor.execute('''
                SELECT id, encrypted_name, color, created_at
                FROM private_media_relationships
                ORDER BY id
            ''')
        rows = cursor.fetchall()

    relationships = []
    for row in rows:
        relationships.append({
            'id': row['id'],
            'name': crypto.decrypt_field(row['encrypted_name']),
            'color': row['color'],
            'created_at': row['created_at']
        })

    relationships.sort(key=lambda r: r['name'].lower())

    return {"relationships": relationships}


@router.post("/relationships")
@limiter.limit("30/minute")
@handle_exceptions
async def create_relationship(
    request: Request,
    body: RelationshipRequest,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Create a new relationship type."""
    db = _get_db()
    crypto = _get_crypto()

    encrypted_name = crypto.encrypt_field(body.name)

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        cursor.execute('''
            INSERT INTO private_media_relationships (encrypted_name, color)
            VALUES (?, ?)
        ''', (encrypted_name, body.color))
        rel_id = cursor.lastrowid
        conn.commit()

    return {
        "id": rel_id,
        "name": body.name,
        "color": body.color
    }


@router.put("/relationships/{relationship_id}")
@limiter.limit("30/minute")
@handle_exceptions
async def update_relationship(
    request: Request,
    relationship_id: int,
    body: RelationshipRequest,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Update a relationship type."""
    db = _get_db()
    crypto = _get_crypto()

    encrypted_name = crypto.encrypt_field(body.name)

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        cursor.execute('''
            UPDATE private_media_relationships
            SET encrypted_name = ?, color = ?
            WHERE id = ?
        ''', (encrypted_name, body.color, relationship_id))
        if cursor.rowcount == 0:
            raise NotFoundError(f"Relationship {relationship_id} not found")
        conn.commit()

    return message_response("Relationship updated")


@router.delete("/relationships/{relationship_id}")
@limiter.limit("30/minute")
@handle_exceptions
async def delete_relationship(
    request: Request,
    relationship_id: int,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Delete a relationship type (fails if persons exist)."""
    db = _get_db()

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()

        # Check if any persons use this relationship
        cursor.execute('''
            SELECT COUNT(*) as count FROM private_media_persons
            WHERE relationship_id = ?
        ''', (relationship_id,))
        if cursor.fetchone()['count'] > 0:
            raise ValidationError("Cannot delete relationship type that has persons assigned")

        cursor.execute('DELETE FROM private_media_relationships WHERE id = ?', (relationship_id,))
        if cursor.rowcount == 0:
            raise NotFoundError(f"Relationship {relationship_id} not found")
        conn.commit()

    return message_response("Relationship deleted")


# ============================================================================
# PERSON ENDPOINTS
# ============================================================================

@router.get("/persons")
@limiter.limit("60/minute")
@handle_exceptions
async def get_persons(
    request: Request,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Get all persons with relationship info."""
    db = _get_db()
    crypto = _get_crypto()

    # Ensure default tags table exists (migration safety)
    with db.get_connection(for_write=True) as conn:
        conn.cursor().execute('''
            CREATE TABLE IF NOT EXISTS private_media_person_default_tags (
                person_id INTEGER NOT NULL, tag_id INTEGER NOT NULL,
                PRIMARY KEY (person_id, tag_id),
                FOREIGN KEY (person_id) REFERENCES private_media_persons(id) ON DELETE CASCADE,
                FOREIGN KEY (tag_id) REFERENCES private_gallery_tags(id) ON DELETE CASCADE
            )
        ''')
        conn.commit()

    with db.get_connection() as conn:
        cursor = conn.cursor()
        cursor.execute('''
            SELECT p.id, p.encrypted_name, p.encrypted_sort_name,
                   p.relationship_id, p.created_at, p.updated_at,
                   r.encrypted_name as rel_encrypted_name, r.color as rel_color
            FROM private_media_persons p
            JOIN private_media_relationships r ON p.relationship_id = r.id
            ORDER BY p.encrypted_sort_name, p.encrypted_name
        ''')
        rows = cursor.fetchall()

        # Batch-query default tags for all persons
        cursor.execute('SELECT person_id, tag_id FROM private_media_person_default_tags')
        default_tags_rows = cursor.fetchall()

    # Build person_id -> [tag_ids] map
    default_tags_map: Dict[int, List[int]] = {}
    for dt_row in default_tags_rows:
        pid = dt_row['person_id']
        if pid not in default_tags_map:
            default_tags_map[pid] = []
        default_tags_map[pid].append(dt_row['tag_id'])

    persons = []
    for row in rows:
        persons.append({
            'id': row['id'],
            'name': crypto.decrypt_field(row['encrypted_name']),
            'sort_name': crypto.decrypt_field(row['encrypted_sort_name']) if row['encrypted_sort_name'] else None,
            'relationship_id': row['relationship_id'],
            'relationship': {
                'id': row['relationship_id'],
                'name': crypto.decrypt_field(row['rel_encrypted_name']),
                'color': row['rel_color']
            },
            'default_tag_ids': default_tags_map.get(row['id'], []),
            'created_at': row['created_at'],
            'updated_at': row['updated_at']
        })

    # Sort by decrypted name
    persons.sort(key=lambda p: (p.get('sort_name') or p['name']).lower())

    return {"persons": persons}


@router.post("/persons")
@limiter.limit("30/minute")
@handle_exceptions
async def create_person(
    request: Request,
    body: PersonRequest,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Create a new person."""
    db = _get_db()
    crypto = _get_crypto()

    encrypted_name = crypto.encrypt_field(body.name)
    encrypted_sort_name = crypto.encrypt_field(body.sort_name) if body.sort_name else None

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()

        # Verify relationship exists
        cursor.execute('SELECT id FROM private_media_relationships WHERE id = ?', (body.relationship_id,))
        if not cursor.fetchone():
            raise ValidationError(f"Relationship {body.relationship_id} not found")

        cursor.execute('''
            INSERT INTO private_media_persons (encrypted_name, encrypted_sort_name, relationship_id)
            VALUES (?, ?, ?)
        ''', (encrypted_name, encrypted_sort_name, body.relationship_id))
        person_id = cursor.lastrowid

        # Insert default tags
        if body.default_tag_ids:
            cursor.execute('''
                CREATE TABLE IF NOT EXISTS private_media_person_default_tags (
                    person_id INTEGER NOT NULL, tag_id INTEGER NOT NULL,
                    PRIMARY KEY (person_id, tag_id),
                    FOREIGN KEY (person_id) REFERENCES private_media_persons(id) ON DELETE CASCADE,
                    FOREIGN KEY (tag_id) REFERENCES private_gallery_tags(id) ON DELETE CASCADE
                )
            ''')
            for tag_id in body.default_tag_ids:
                cursor.execute('''
                    INSERT OR IGNORE INTO private_media_person_default_tags (person_id, tag_id)
                    VALUES (?, ?)
                ''', (person_id, tag_id))

        conn.commit()

    return {
        "id": person_id,
        "name": body.name,
        "sort_name": body.sort_name,
        "relationship_id": body.relationship_id,
        "default_tag_ids": body.default_tag_ids or []
    }


@router.put("/persons/{person_id}")
@limiter.limit("30/minute")
@handle_exceptions
async def update_person(
    request: Request,
    person_id: int,
    body: PersonUpdateRequest,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Update a person."""
    db = _get_db()
    crypto = _get_crypto()

    updates = []
    params = []

    if body.name is not None:
        updates.append("encrypted_name = ?")
        params.append(crypto.encrypt_field(body.name))

    if body.sort_name is not None:
        updates.append("encrypted_sort_name = ?")
        params.append(crypto.encrypt_field(body.sort_name) if body.sort_name else None)

    if body.relationship_id is not None:
        updates.append("relationship_id = ?")
        params.append(body.relationship_id)

    if not updates and body.default_tag_ids is None:
        raise ValidationError("No fields to update")

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()

        if updates:
            updates.append("updated_at = CURRENT_TIMESTAMP")
            params.append(person_id)
            cursor.execute(f'''
                UPDATE private_media_persons
                SET {", ".join(updates)}
                WHERE id = ?
            ''', params)
            if cursor.rowcount == 0:
                raise NotFoundError(f"Person {person_id} not found")

        # Sync default tags (delete-and-reinsert)
        if body.default_tag_ids is not None:
            # Ensure table exists (migration safety)
            cursor.execute('''
                CREATE TABLE IF NOT EXISTS private_media_person_default_tags (
                    person_id INTEGER NOT NULL, tag_id INTEGER NOT NULL,
                    PRIMARY KEY (person_id, tag_id),
                    FOREIGN KEY (person_id) REFERENCES private_media_persons(id) ON DELETE CASCADE,
                    FOREIGN KEY (tag_id) REFERENCES private_gallery_tags(id) ON DELETE CASCADE
                )
            ''')
            cursor.execute('DELETE FROM private_media_person_default_tags WHERE person_id = ?', (person_id,))
            for tag_id in body.default_tag_ids:
                cursor.execute('''
                    INSERT OR IGNORE INTO private_media_person_default_tags (person_id, tag_id)
                    VALUES (?, ?)
                ''', (person_id, tag_id))

            # Apply default tags to all existing posts for this person
            cursor.execute('SELECT id FROM private_media_posts WHERE person_id = ?', (person_id,))
            post_ids = [row['id'] for row in cursor.fetchall()]
            for post_id in post_ids:
                for tag_id in body.default_tag_ids:
                    cursor.execute('''
                        INSERT OR IGNORE INTO private_media_post_tags (post_id, tag_id)
                        VALUES (?, ?)
                    ''', (post_id, tag_id))

        conn.commit()

    return message_response("Person updated")


@router.delete("/persons/{person_id}")
@limiter.limit("30/minute")
@handle_exceptions
async def delete_person(
    request: Request,
    person_id: int,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Delete a person (media will have person_id set to NULL)."""
    db = _get_db()

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        cursor.execute('DELETE FROM private_media_persons WHERE id = ?', (person_id,))
        if cursor.rowcount == 0:
            raise NotFoundError(f"Person {person_id} not found")
        conn.commit()

    return message_response("Person deleted")


# ============================================================================
# PERSON GROUP ENDPOINTS (Encrypted)
# ============================================================================

@router.get("/person-groups")
@limiter.limit("60/minute")
@handle_exceptions
async def list_person_groups(
    request: Request,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """List all person groups with member counts."""
    db = _get_db()
    crypto = _get_crypto()

    with db.get_connection() as conn:
        cursor = conn.cursor()
        cursor.execute('''
            SELECT g.id, g.encrypted_name, g.encrypted_description, g.created_at, g.updated_at,
                   g.min_resolution,
                   (SELECT COUNT(*) FROM private_media_person_group_members WHERE group_id = g.id) as member_count,
                   (SELECT COUNT(*) FROM private_media_person_group_tag_members WHERE group_id = g.id) as tag_member_count,
                   (SELECT COUNT(*) FROM private_media_person_group_relationship_members WHERE group_id = g.id) as relationship_member_count,
                   (SELECT COUNT(*) FROM private_media_person_group_excluded_tags WHERE group_id = g.id) as excluded_tag_count
            FROM private_media_person_groups g
            ORDER BY g.created_at DESC
        ''')
        rows = cursor.fetchall()

    groups = []
    for row in rows:
        groups.append({
            'id': row['id'],
            'name': crypto.decrypt_field(row['encrypted_name']),
            'description': crypto.decrypt_field(row['encrypted_description']) if row['encrypted_description'] else None,
            'member_count': row['member_count'],
            'tag_member_count': row['tag_member_count'],
            'relationship_member_count': row['relationship_member_count'],
            'excluded_tag_count': row['excluded_tag_count'],
            'min_resolution': row['min_resolution'] or 0,
            'created_at': row['created_at'],
            'updated_at': row['updated_at'],
        })

    groups.sort(key=lambda g: g['name'].lower())

    return {"groups": groups}


@router.get("/person-groups/{group_id}")
@limiter.limit("60/minute")
@handle_exceptions
async def get_person_group(
    request: Request,
    group_id: int,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Get a single person group with its members."""
    db = _get_db()
    crypto = _get_crypto()

    with db.get_connection() as conn:
        cursor = conn.cursor()
        cursor.execute('''
            SELECT id, encrypted_name, encrypted_description, min_resolution, created_at, updated_at
            FROM private_media_person_groups WHERE id = ?
        ''', (group_id,))
        row = cursor.fetchone()
        if not row:
            raise NotFoundError(f"Person group {group_id} not found")

        group = {
            'id': row['id'],
            'name': crypto.decrypt_field(row['encrypted_name']),
            'description': crypto.decrypt_field(row['encrypted_description']) if row['encrypted_description'] else None,
            'min_resolution': row['min_resolution'] or 0,
            'created_at': row['created_at'],
            'updated_at': row['updated_at'],
        }

        # Fetch person members
        cursor.execute('''
            SELECT p.id, p.encrypted_name, p.encrypted_sort_name, p.relationship_id,
                   r.encrypted_name as rel_encrypted_name, r.color as rel_color,
                   m.added_at
            FROM private_media_person_group_members m
            JOIN private_media_persons p ON m.person_id = p.id
            JOIN private_media_relationships r ON p.relationship_id = r.id
            WHERE m.group_id = ?
            ORDER BY p.encrypted_name
        ''', (group_id,))
        member_rows = cursor.fetchall()

        # Fetch tag members
        cursor.execute('''
            SELECT t.id, t.encrypted_name, t.color, tm.added_at
            FROM private_media_person_group_tag_members tm
            JOIN private_gallery_tags t ON tm.tag_id = t.id
            WHERE tm.group_id = ?
        ''', (group_id,))
        tag_member_rows = cursor.fetchall()

        # Fetch relationship members
        cursor.execute('''
            SELECT r.id, r.encrypted_name, r.color, rm.added_at
            FROM private_media_person_group_relationship_members rm
            JOIN private_media_relationships r ON rm.relationship_id = r.id
            WHERE rm.group_id = ?
        ''', (group_id,))
        rel_member_rows = cursor.fetchall()

        # Fetch excluded tags
        cursor.execute('''
            SELECT t.id, t.encrypted_name, t.color, et.added_at
            FROM private_media_person_group_excluded_tags et
            JOIN private_gallery_tags t ON et.tag_id = t.id
            WHERE et.group_id = ?
        ''', (group_id,))
        excluded_tag_rows = cursor.fetchall()

    members = []
    for mr in member_rows:
        members.append({
            'id': mr['id'],
            'name': crypto.decrypt_field(mr['encrypted_name']),
            'sort_name': crypto.decrypt_field(mr['encrypted_sort_name']) if mr['encrypted_sort_name'] else None,
            'relationship_id': mr['relationship_id'],
            'relationship': {
                'id': mr['relationship_id'],
                'name': crypto.decrypt_field(mr['rel_encrypted_name']),
                'color': mr['rel_color'],
            },
            'added_at': mr['added_at'],
        })
    members.sort(key=lambda m: (m.get('sort_name') or m['name']).lower())

    tag_members = []
    for tr in tag_member_rows:
        tag_members.append({
            'id': tr['id'],
            'name': crypto.decrypt_field(tr['encrypted_name']),
            'color': tr['color'],
            'added_at': tr['added_at'],
        })
    tag_members.sort(key=lambda t: t['name'].lower())

    relationship_members = []
    for rr in rel_member_rows:
        relationship_members.append({
            'id': rr['id'],
            'name': crypto.decrypt_field(rr['encrypted_name']),
            'color': rr['color'],
            'added_at': rr['added_at'],
        })
    relationship_members.sort(key=lambda r: r['name'].lower())

    excluded_tags = []
    for etr in excluded_tag_rows:
        excluded_tags.append({
            'id': etr['id'],
            'name': crypto.decrypt_field(etr['encrypted_name']),
            'color': etr['color'],
            'added_at': etr['added_at'],
        })
    excluded_tags.sort(key=lambda t: t['name'].lower())

    group['members'] = members
    group['member_count'] = len(members)
    group['tag_members'] = tag_members
    group['tag_member_count'] = len(tag_members)
    group['relationship_members'] = relationship_members
    group['relationship_member_count'] = len(relationship_members)
    group['excluded_tags'] = excluded_tags
    group['excluded_tag_count'] = len(excluded_tags)

    return group


@router.post("/person-groups")
@limiter.limit("30/minute")
@handle_exceptions
async def create_person_group(
    request: Request,
    body: PersonGroupCreate,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Create a new person group."""
    db = _get_db()
    crypto = _get_crypto()

    encrypted_name = crypto.encrypt_field(body.name)
    encrypted_description = crypto.encrypt_field(body.description) if body.description else None

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        cursor.execute('''
            INSERT INTO private_media_person_groups (encrypted_name, encrypted_description, min_resolution)
            VALUES (?, ?, ?)
        ''', (encrypted_name, encrypted_description, body.min_resolution or 0))
        group_id = cursor.lastrowid
        conn.commit()

    return {
        "group": {
            "id": group_id,
            "name": body.name,
            "description": body.description,
            "min_resolution": body.min_resolution or 0,
            "member_count": 0,
        },
        "message": "Group created"
    }


@router.put("/person-groups/{group_id}")
@limiter.limit("30/minute")
@handle_exceptions
async def update_person_group(
    request: Request,
    group_id: int,
    body: PersonGroupUpdate,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Update a person group."""
    db = _get_db()
    crypto = _get_crypto()

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        # Check existence
        cursor.execute('SELECT id FROM private_media_person_groups WHERE id = ?', (group_id,))
        if not cursor.fetchone():
            raise NotFoundError(f"Person group {group_id} not found")

        updates = []
        params = []
        if body.name is not None:
            updates.append("encrypted_name = ?")
            params.append(crypto.encrypt_field(body.name))
        if body.description is not None:
            updates.append("encrypted_description = ?")
            params.append(crypto.encrypt_field(body.description))
        if body.min_resolution is not None:
            updates.append("min_resolution = ?")
            params.append(body.min_resolution)

        if updates:
            updates.append("updated_at = CURRENT_TIMESTAMP")
            params.append(group_id)
            cursor.execute(f'UPDATE private_media_person_groups SET {", ".join(updates)} WHERE id = ?', params)
            conn.commit()

    return {"success": True, "message": "Group updated"}


@router.delete("/person-groups/{group_id}")
@limiter.limit("30/minute")
@handle_exceptions
async def delete_person_group(
    request: Request,
    group_id: int,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Delete a person group."""
    db = _get_db()

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        cursor.execute('DELETE FROM private_media_person_groups WHERE id = ?', (group_id,))
        if cursor.rowcount == 0:
            raise NotFoundError(f"Person group {group_id} not found")
        conn.commit()

    return {"success": True, "message": "Group deleted"}


@router.post("/person-groups/{group_id}/members")
@limiter.limit("60/minute")
@handle_exceptions
async def add_person_to_group(
    request: Request,
    group_id: int,
    body: PersonGroupMemberAdd,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Add a person to a group."""
    db = _get_db()

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        # Verify group exists
        cursor.execute('SELECT id FROM private_media_person_groups WHERE id = ?', (group_id,))
        if not cursor.fetchone():
            raise NotFoundError(f"Person group {group_id} not found")
        cursor.execute('''
            INSERT OR IGNORE INTO private_media_person_group_members (group_id, person_id)
            VALUES (?, ?)
        ''', (group_id, body.person_id))
        conn.commit()

    return {"success": True, "message": "Person added to group"}


@router.delete("/person-groups/{group_id}/members/{person_id}")
@limiter.limit("60/minute")
@handle_exceptions
async def remove_person_from_group(
    request: Request,
    group_id: int,
    person_id: int,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Remove a person from a group."""
    db = _get_db()

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        cursor.execute('DELETE FROM private_media_person_group_members WHERE group_id = ? AND person_id = ?', (group_id, person_id))
        conn.commit()

    return {"success": True, "message": "Person removed from group"}


@router.post("/person-groups/{group_id}/tags")
@limiter.limit("60/minute")
@handle_exceptions
async def add_tag_to_person_group(
    request: Request,
    group_id: int,
    body: PersonGroupTagMemberAdd,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Add a tag to a person group."""
    db = _get_db()

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        cursor.execute('SELECT id FROM private_media_person_groups WHERE id = ?', (group_id,))
        if not cursor.fetchone():
            raise NotFoundError(f"Person group {group_id} not found")
        cursor.execute('''
            INSERT OR IGNORE INTO private_media_person_group_tag_members (group_id, tag_id)
            VALUES (?, ?)
        ''', (group_id, body.tag_id))
        conn.commit()

    return {"success": True, "message": "Tag added to group"}


@router.delete("/person-groups/{group_id}/tags/{tag_id}")
@limiter.limit("60/minute")
@handle_exceptions
async def remove_tag_from_person_group(
    request: Request,
    group_id: int,
    tag_id: int,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Remove a tag from a person group."""
    db = _get_db()

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        cursor.execute('DELETE FROM private_media_person_group_tag_members WHERE group_id = ? AND tag_id = ?', (group_id, tag_id))
        conn.commit()

    return {"success": True, "message": "Tag removed from group"}


@router.post("/person-groups/{group_id}/relationships")
@limiter.limit("60/minute")
@handle_exceptions
async def add_relationship_to_person_group(
    request: Request,
    group_id: int,
    body: PersonGroupRelationshipMemberAdd,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Add a relationship to a person group."""
    db = _get_db()

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        cursor.execute('SELECT id FROM private_media_person_groups WHERE id = ?', (group_id,))
        if not cursor.fetchone():
            raise NotFoundError(f"Person group {group_id} not found")
        cursor.execute('''
            INSERT OR IGNORE INTO private_media_person_group_relationship_members (group_id, relationship_id)
            VALUES (?, ?)
        ''', (group_id, body.relationship_id))
        conn.commit()

    return {"success": True, "message": "Relationship added to group"}


@router.delete("/person-groups/{group_id}/relationships/{relationship_id}")
@limiter.limit("60/minute")
@handle_exceptions
async def remove_relationship_from_person_group(
    request: Request,
    group_id: int,
    relationship_id: int,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Remove a relationship from a person group."""
    db = _get_db()

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        cursor.execute('DELETE FROM private_media_person_group_relationship_members WHERE group_id = ? AND relationship_id = ?', (group_id, relationship_id))
        conn.commit()

    return {"success": True, "message": "Relationship removed from group"}


@router.post("/person-groups/{group_id}/excluded-tags")
@limiter.limit("60/minute")
@handle_exceptions
async def add_excluded_tag_to_person_group(
    request: Request,
    group_id: int,
    body: PersonGroupExcludedTagAdd,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Add an excluded tag to a person group."""
    db = _get_db()

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        cursor.execute('SELECT id FROM private_media_person_groups WHERE id = ?', (group_id,))
        if not cursor.fetchone():
            raise NotFoundError(f"Person group {group_id} not found")
        cursor.execute('''
            INSERT OR IGNORE INTO private_media_person_group_excluded_tags (group_id, tag_id)
            VALUES (?, ?)
        ''', (group_id, body.tag_id))
        conn.commit()

    return {"success": True, "message": "Excluded tag added to group"}


@router.delete("/person-groups/{group_id}/excluded-tags/{tag_id}")
@limiter.limit("60/minute")
@handle_exceptions
async def remove_excluded_tag_from_person_group(
    request: Request,
    group_id: int,
    tag_id: int,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Remove an excluded tag from a person group."""
    db = _get_db()

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        cursor.execute('DELETE FROM private_media_person_group_excluded_tags WHERE group_id = ? AND tag_id = ?', (group_id, tag_id))
        conn.commit()

    return {"success": True, "message": "Excluded tag removed from group"}


# ============================================================================
# TAG ENDPOINTS (Encrypted private_gallery_tags)
# ============================================================================

_tags_migrated = False

def _ensure_private_gallery_tags_migrated(db, crypto):
    """Sync all tags from paid_content_tags to private_gallery_tags, preserving IDs."""
    global _tags_migrated
    if _tags_migrated:
        return
    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()

        # Create the table if it doesn't exist
        cursor.execute('''
            CREATE TABLE IF NOT EXISTS private_gallery_tags (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                encrypted_name TEXT NOT NULL,
                color TEXT DEFAULT '#6b7280',
                encrypted_description TEXT,
                created_at TEXT DEFAULT CURRENT_TIMESTAMP
            )
        ''')

        # Get existing private gallery tag IDs
        cursor.execute('SELECT id FROM private_gallery_tags')
        existing_ids = {row['id'] for row in cursor.fetchall()}

        # Copy ALL paid content tags that don't already exist in private gallery
        cursor.execute('SELECT id, name, color, description FROM paid_content_tags')
        new_count = 0
        for row in cursor.fetchall():
            if row['id'] not in existing_ids:
                encrypted_name = crypto.encrypt_field(row['name'])
                encrypted_description = crypto.encrypt_field(row['description']) if row['description'] else None
                cursor.execute('''
                    INSERT INTO private_gallery_tags (id, encrypted_name, color, encrypted_description)
                    VALUES (?, ?, ?, ?)
                ''', (row['id'], encrypted_name, row['color'], encrypted_description))
                new_count += 1

        if new_count > 0:
            conn.commit()
            logger.info(f"Synced {new_count} new tags from paid_content_tags to private_gallery_tags")

    _tags_migrated = True


@router.get("/tags")
@limiter.limit("60/minute")
@handle_exceptions
async def get_tags(
    request: Request,
    assigned_only: Optional[bool] = None,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Get all encrypted tags. If assigned_only=true, only return tags used in private gallery."""
    db = _get_db()
    crypto = _get_crypto()

    # Ensure migration has run
    _ensure_private_gallery_tags_migrated(db, crypto)

    with db.get_connection() as conn:
        cursor = conn.cursor()
        if assigned_only:
            cursor.execute('''
                SELECT t.id, t.encrypted_name, t.color, t.encrypted_description, t.created_at,
                       (SELECT COUNT(*) FROM private_media_tags mt WHERE mt.tag_id = t.id) +
                       (SELECT COUNT(*) FROM private_media_post_tags pt WHERE pt.tag_id = t.id) as usage_count
                FROM private_gallery_tags t
                WHERE t.id IN (
                    SELECT tag_id FROM private_media_tags
                    UNION
                    SELECT tag_id FROM private_media_post_tags
                )
            ''')
        else:
            cursor.execute('''
                SELECT id, encrypted_name, color, encrypted_description, created_at,
                       (SELECT COUNT(*) FROM private_media_tags mt WHERE mt.tag_id = private_gallery_tags.id) +
                       (SELECT COUNT(*) FROM private_media_post_tags pt WHERE pt.tag_id = private_gallery_tags.id) as usage_count
                FROM private_gallery_tags
            ''')
        rows = cursor.fetchall()

    tags = []
    for row in rows:
        tags.append({
            'id': row['id'],
            'name': crypto.decrypt_field(row['encrypted_name']),
            'color': row['color'],
            'description': crypto.decrypt_field(row['encrypted_description']) if row['encrypted_description'] else None,
            'created_at': row['created_at'],
            'usage_count': row['usage_count'],
        })

    # Sort alphabetically by name
    tags.sort(key=lambda t: t['name'].lower())

    return {"tags": tags}


@router.post("/tags")
@limiter.limit("30/minute")
@handle_exceptions
async def create_tag(
    request: Request,
    body: TagCreateRequest,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Create a new encrypted tag."""
    db = _get_db()
    crypto = _get_crypto()

    encrypted_name = crypto.encrypt_field(body.name)
    encrypted_description = crypto.encrypt_field(body.description) if body.description else None

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        cursor.execute('''
            INSERT INTO private_gallery_tags (encrypted_name, color, encrypted_description)
            VALUES (?, ?, ?)
        ''', (encrypted_name, body.color, encrypted_description))
        tag_id = cursor.lastrowid
        conn.commit()

    return {
        "id": tag_id,
        "name": body.name,
        "color": body.color,
        "description": body.description,
    }


@router.put("/tags/{tag_id}")
@limiter.limit("30/minute")
@handle_exceptions
async def update_tag(
    request: Request,
    tag_id: int,
    body: TagUpdateRequest,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Update an encrypted tag."""
    db = _get_db()
    crypto = _get_crypto()

    updates = []
    params = []

    if body.name is not None:
        updates.append("encrypted_name = ?")
        params.append(crypto.encrypt_field(body.name))

    if body.color is not None:
        updates.append("color = ?")
        params.append(body.color)

    if body.description is not None:
        updates.append("encrypted_description = ?")
        params.append(crypto.encrypt_field(body.description) if body.description else None)

    if not updates:
        raise ValidationError("No fields to update")

    params.append(tag_id)

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        cursor.execute(f'''
            UPDATE private_gallery_tags
            SET {", ".join(updates)}
            WHERE id = ?
        ''', params)
        if cursor.rowcount == 0:
            raise NotFoundError(f"Tag {tag_id} not found")
        conn.commit()

    return message_response("Tag updated")


@router.delete("/tags/{tag_id}")
@limiter.limit("30/minute")
@handle_exceptions
async def delete_tag(
    request: Request,
    tag_id: int,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Delete an encrypted tag. Junction table CASCADE handles cleanup."""
    db = _get_db()

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        cursor.execute('DELETE FROM private_gallery_tags WHERE id = ?', (tag_id,))
        if cursor.rowcount == 0:
            raise NotFoundError(f"Tag {tag_id} not found")
        conn.commit()

    return message_response("Tag deleted")


# ============================================================================
# IMPORT AUTH ENDPOINTS
# ============================================================================

def _normalize_domain(domain: str) -> str:
    """Normalize domain: strip protocol/path, lowercase, strip leading dots."""
    domain = domain.strip().lower()
    # Strip protocol if present
    if '://' in domain:
        domain = domain.split('://', 1)[1]
    # Strip path/query
    domain = domain.split('/')[0].split('?')[0].split('#')[0]
    # Strip port
    domain = domain.split(':')[0]
    # Strip leading/trailing dots
    domain = domain.strip('.')
    return domain


@router.get("/import-auth")
@limiter.limit("60/minute")
@handle_exceptions
async def get_import_auth(
    request: Request,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """List all import auth entries. Never exposes decrypted credentials."""
    db = _get_db()
    crypto = _get_crypto()

    with db.get_connection() as conn:
        cursor = conn.cursor()
        cursor.execute('SELECT * FROM private_gallery_import_auth ORDER BY domain')
        rows = cursor.fetchall()

    entries = []
    for row in rows:
        cookies_count = 0
        if row['encrypted_cookies_json']:
            try:
                cookies = json.loads(crypto.decrypt_field(row['encrypted_cookies_json']))
                cookies_count = len(cookies) if isinstance(cookies, list) else 0
            except Exception:
                pass

        has_user_agent = bool(row['encrypted_user_agent'])
        user_agent_display = None
        if has_user_agent:
            try:
                ua = crypto.decrypt_field(row['encrypted_user_agent'])
                user_agent_display = ua[:50] + '...' if len(ua) > 50 else ua
            except Exception:
                pass

        entries.append({
            'id': row['id'],
            'domain': row['domain'],
            'auth_type': row['auth_type'],
            'has_username': bool(row['encrypted_username']),
            'has_password': bool(row['encrypted_password']),
            'cookies_count': cookies_count,
            'user_agent': user_agent_display,
            'notes': row['notes'],
            'created_at': row['created_at'],
            'updated_at': row['updated_at'],
        })

    return {"entries": entries}


@router.post("/import-auth")
@limiter.limit("30/minute")
@handle_exceptions
async def create_import_auth(
    request: Request,
    body: ImportAuthCreateRequest,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Create a new import auth entry."""
    db = _get_db()
    crypto = _get_crypto()

    domain = _normalize_domain(body.domain)
    if not domain:
        raise ValidationError("Invalid domain")

    # Validate auth_type requirements
    if body.auth_type in ('basic', 'both') and (not body.username or not body.password):
        raise ValidationError("Username and password are required for basic/both auth type")
    if body.auth_type in ('cookies', 'both') and not body.cookies:
        raise ValidationError("Cookies are required for cookies/both auth type")

    encrypted_username = crypto.encrypt_field(body.username) if body.username else None
    encrypted_password = crypto.encrypt_field(body.password) if body.password else None
    encrypted_cookies_json = crypto.encrypt_field(json.dumps(body.cookies)) if body.cookies else None
    encrypted_user_agent = crypto.encrypt_field(body.user_agent) if body.user_agent else None

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        try:
            cursor.execute('''
                INSERT INTO private_gallery_import_auth
                    (domain, auth_type, encrypted_username, encrypted_password, encrypted_cookies_json, encrypted_user_agent, notes)
                VALUES (?, ?, ?, ?, ?, ?, ?)
            ''', (domain, body.auth_type, encrypted_username, encrypted_password, encrypted_cookies_json, encrypted_user_agent, body.notes))
            entry_id = cursor.lastrowid
            conn.commit()
        except Exception as e:
            if 'UNIQUE' in str(e).upper():
                raise ValidationError(f"Domain '{domain}' already has an auth entry")
            raise

    return {"id": entry_id, "domain": domain, "auth_type": body.auth_type}


@router.put("/import-auth/{entry_id}")
@limiter.limit("30/minute")
@handle_exceptions
async def update_import_auth(
    request: Request,
    entry_id: int,
    body: ImportAuthUpdateRequest,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Update an import auth entry."""
    db = _get_db()
    crypto = _get_crypto()

    updates = []
    params = []

    if body.domain is not None:
        domain = _normalize_domain(body.domain)
        if not domain:
            raise ValidationError("Invalid domain")
        updates.append("domain = ?")
        params.append(domain)

    if body.auth_type is not None:
        updates.append("auth_type = ?")
        params.append(body.auth_type)

    if body.username is not None:
        updates.append("encrypted_username = ?")
        params.append(crypto.encrypt_field(body.username) if body.username else None)

    if body.password is not None:
        updates.append("encrypted_password = ?")
        params.append(crypto.encrypt_field(body.password) if body.password else None)

    if body.cookies is not None:
        updates.append("encrypted_cookies_json = ?")
        params.append(crypto.encrypt_field(json.dumps(body.cookies)) if body.cookies else None)

    if body.user_agent is not None:
        updates.append("encrypted_user_agent = ?")
        params.append(crypto.encrypt_field(body.user_agent) if body.user_agent else None)

    if body.notes is not None:
        updates.append("notes = ?")
        params.append(body.notes)

    if not updates:
        raise ValidationError("No fields to update")

    updates.append("updated_at = CURRENT_TIMESTAMP")
    params.append(entry_id)

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        cursor.execute(f'''
            UPDATE private_gallery_import_auth
            SET {", ".join(updates)}
            WHERE id = ?
        ''', params)
        if cursor.rowcount == 0:
            raise NotFoundError(f"Import auth entry {entry_id} not found")
        conn.commit()

    return message_response("Import auth updated")


@router.delete("/import-auth/{entry_id}")
@limiter.limit("30/minute")
@handle_exceptions
async def delete_import_auth(
    request: Request,
    entry_id: int,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Delete an import auth entry."""
    db = _get_db()

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        cursor.execute('DELETE FROM private_gallery_import_auth WHERE id = ?', (entry_id,))
        if cursor.rowcount == 0:
            raise NotFoundError(f"Import auth entry {entry_id} not found")
        conn.commit()

    return message_response("Import auth deleted")


# ============================================================================
# MEDIA ENDPOINTS
# ============================================================================

@router.get("/media")
@limiter.limit("120/minute")
@handle_exceptions
async def get_media(
    request: Request,
    offset: int = Query(0, ge=0),
    limit: int = Query(50, ge=1, le=500),
    person_id: Optional[int] = None,
    person_group_id: Optional[int] = None,
    relationship_id: Optional[int] = None,
    tag_ids: Optional[str] = None,
    file_type: Optional[str] = None,
    search: Optional[str] = None,
    sort_by: str = Query("media_date", regex="^(media_date|created_at|filename)$"),
    sort_order: str = Query("desc", regex="^(asc|desc)$"),
    date_from: Optional[str] = None,
    date_to: Optional[str] = None,
    has_tags: Optional[bool] = None,
    has_description: Optional[bool] = None,
    include_attached: Optional[bool] = None,
    shuffle: Optional[bool] = None,
    shuffle_seed: Optional[int] = None,
    unread_only: Optional[bool] = None,
    min_resolution: Optional[int] = Query(None, ge=0, le=10000),
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Get media items with filtering and pagination."""
    db = _get_db()
    crypto = _get_crypto()

    # Ensure private_gallery_tags table exists and is migrated
    _ensure_private_gallery_tags_migrated(db, crypto)

    # Build query - we fetch all then filter in Python since many fields are encrypted
    with db.get_connection() as conn:
        cursor = conn.cursor()

        # Build WHERE clauses for non-encrypted fields
        where_clauses = []
        params = []

        # include_attached parameter is no longer used — media always appears
        # in the gallery regardless of being attached to another post

        if person_id is not None:
            where_clauses.append("m.person_id = ?")
            params.append(person_id)

        if person_group_id is not None:
            # WHO filter: persons + relationships (pass through if none defined)
            where_clauses.append("""(
                (
                    NOT EXISTS (SELECT 1 FROM private_media_person_group_members WHERE group_id = ?)
                    AND NOT EXISTS (SELECT 1 FROM private_media_person_group_relationship_members WHERE group_id = ?)
                )
                OR m.person_id IN (SELECT person_id FROM private_media_person_group_members WHERE group_id = ?)
                OR m.person_id IN (SELECT p2.id FROM private_media_persons p2 WHERE p2.relationship_id IN (SELECT relationship_id FROM private_media_person_group_relationship_members WHERE group_id = ?))
            )""")
            params.extend([person_group_id, person_group_id, person_group_id, person_group_id])
            # TAG filter: included tags (pass through if none defined)
            where_clauses.append("""(
                NOT EXISTS (SELECT 1 FROM private_media_person_group_tag_members WHERE group_id = ?)
                OR m.id IN (SELECT mt.media_id FROM private_media_tags mt WHERE mt.tag_id IN (SELECT tag_id FROM private_media_person_group_tag_members WHERE group_id = ?))
            )""")
            params.extend([person_group_id, person_group_id])
            # EXCLUDE filter: excluded tags
            where_clauses.append("""
                m.id NOT IN (SELECT mt2.media_id FROM private_media_tags mt2 WHERE mt2.tag_id IN (SELECT tag_id FROM private_media_person_group_excluded_tags WHERE group_id = ?))
            """)
            params.append(person_group_id)

        if file_type and file_type != 'all':
            where_clauses.append("m.file_type = ?")
            params.append(file_type)

        if tag_ids:
            tag_id_list = [int(t) for t in tag_ids.split(',') if t.strip().isdigit()]
            if tag_id_list:
                placeholders = ','.join('?' * len(tag_id_list))
                where_clauses.append(f'''
                    (m.id IN (SELECT media_id FROM private_media_tags WHERE tag_id IN ({placeholders}))
                     OR m.post_id IN (SELECT post_id FROM private_media_post_tags WHERE tag_id IN ({placeholders})))
                ''')
                params.extend(tag_id_list)
                params.extend(tag_id_list)

        if relationship_id is not None:
            where_clauses.append("p.relationship_id = ?")
            params.append(relationship_id)

        # Filter by has_tags (SQL-based for efficiency)
        if has_tags is True:
            where_clauses.append("EXISTS (SELECT 1 FROM private_media_tags WHERE media_id = m.id)")
        elif has_tags is False:
            where_clauses.append("NOT EXISTS (SELECT 1 FROM private_media_tags WHERE media_id = m.id)")

        if unread_only:
            where_clauses.append("post.is_read = 0")

        # Merge explicit min_resolution with person group's min_resolution (use higher)
        effective_min_res = min_resolution or 0
        if person_group_id is not None:
            cursor.execute('SELECT min_resolution FROM private_media_person_groups WHERE id = ?', (person_group_id,))
            group_row = cursor.fetchone()
            if group_row and group_row['min_resolution']:
                effective_min_res = max(effective_min_res, group_row['min_resolution'])

        if effective_min_res > 0:
            where_clauses.append("(m.file_type != 'image' OR (m.width >= ? AND m.height >= ?))")
            params.extend([effective_min_res, effective_min_res])

        where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"

        # Get total count (before pagination)
        count_sql = f'''
            SELECT COUNT(*) as total
            FROM private_media m
            LEFT JOIN private_media_posts post ON m.post_id = post.id
            LEFT JOIN private_media_persons p ON m.person_id = p.id
            WHERE {where_sql}
        '''
        cursor.execute(count_sql, params)
        total = cursor.fetchone()['total']

        # Shuffle mode: deterministic shuffle using PostgreSQL md5 hash
        if shuffle:
            seed = str(shuffle_seed if shuffle_seed is not None else 42)
            total_filtered = total
            query = f'''
                SELECT m.*, p.encrypted_name as person_encrypted_name,
                       p.relationship_id, r.encrypted_name as rel_encrypted_name, r.color as rel_color,
                       post.created_at as post_created_at, post.encrypted_media_date as post_encrypted_media_date
                FROM private_media m
                LEFT JOIN private_media_posts post ON m.post_id = post.id
                LEFT JOIN private_media_persons p ON m.person_id = p.id
                LEFT JOIN private_media_relationships r ON p.relationship_id = r.id
                WHERE {where_sql}
                ORDER BY md5(m.id::text || ?::text), m.id
                LIMIT ? OFFSET ?
            '''
            cursor.execute(query, params + [seed, limit, offset])
            rows = cursor.fetchall()

            # Skip re-sorting below — shuffle order is authoritative
            can_sql_paginate = True

        # Optimization: when no encrypted-field filters are active (no search, date_from,
        # date_to, has_description) and sort is by created_at, paginate in SQL directly
        # to avoid decrypting every row in the database.
        elif not shuffle:
            needs_decrypt_filter = bool(search or date_from or date_to or has_description is not None)
            needs_decrypt_sort = sort_by in ('media_date', 'filename')
            can_sql_paginate = not needs_decrypt_filter and not needs_decrypt_sort

        if not shuffle and can_sql_paginate:
            # Fast path: SQL-level pagination (sort_by=created_at only)
            count_sql = f'''
                SELECT COUNT(*) as total
                FROM private_media m
                LEFT JOIN private_media_posts post ON m.post_id = post.id
                LEFT JOIN private_media_persons p ON m.person_id = p.id
                WHERE {where_sql}
            '''
            cursor.execute(count_sql, params)
            total_filtered = cursor.fetchone()[0]

            order_dir = sort_order.upper() if sort_order.upper() in ('ASC', 'DESC') else 'DESC'
            # Sort by post.created_at to exactly match the posts endpoint sort order,
            # then m.id ASC within each post to match attachment display order
            order_clause = f'post.created_at {order_dir}, m.id ASC'
            query = f'''
                SELECT m.*, p.encrypted_name as person_encrypted_name,
                       p.relationship_id, r.encrypted_name as rel_encrypted_name, r.color as rel_color,
                       post.created_at as post_created_at, post.encrypted_media_date as post_encrypted_media_date
                FROM private_media m
                LEFT JOIN private_media_posts post ON m.post_id = post.id
                LEFT JOIN private_media_persons p ON m.person_id = p.id
                LEFT JOIN private_media_relationships r ON p.relationship_id = r.id
                WHERE {where_sql}
                ORDER BY {order_clause}
                LIMIT ? OFFSET ?
            '''
            cursor.execute(query, params + [limit, offset])
            rows = cursor.fetchall()
        elif not shuffle:
            # Slow path: fetch all, decrypt, filter, sort, then paginate in Python
            # Also fetch post-level fields for sorting by post.media_date (matching posts endpoint)
            query = f'''
                SELECT m.*, p.encrypted_name as person_encrypted_name,
                       p.relationship_id, r.encrypted_name as rel_encrypted_name, r.color as rel_color,
                       post.created_at as post_created_at, post.encrypted_media_date as post_encrypted_media_date
                FROM private_media m
                LEFT JOIN private_media_posts post ON m.post_id = post.id
                LEFT JOIN private_media_persons p ON m.person_id = p.id
                LEFT JOIN private_media_relationships r ON p.relationship_id = r.id
                WHERE {where_sql}
                ORDER BY post.created_at DESC, m.id ASC
            '''
            cursor.execute(query, params)
            rows = cursor.fetchall()

    # Decrypt and process items
    items = []
    config = _get_config(db)
    storage_path = Path(config.get('storage_path', '/opt/immich/private'))

    for row in rows:
        item = dict(row)

        # Decrypt fields
        item['filename'] = crypto.decrypt_field(item['encrypted_filename'])
        item['description'] = crypto.decrypt_field(item['encrypted_description']) if item['encrypted_description'] else None
        item['media_date'] = crypto.decrypt_field(item['encrypted_media_date'])
        item['source_path'] = crypto.decrypt_field(item['encrypted_source_path']) if item['encrypted_source_path'] else None

        # Decrypt post-level media_date for sorting (to match posts endpoint sort order)
        item['post_media_date'] = crypto.decrypt_field(item['post_encrypted_media_date']) if item.get('post_encrypted_media_date') else None

        # Remove encrypted fields
        del item['encrypted_filename']
        del item['encrypted_description']
        del item['encrypted_media_date']
        del item['encrypted_source_path']
        if 'post_encrypted_media_date' in item:
            del item['post_encrypted_media_date']

        # Add person info
        if item['person_encrypted_name']:
            item['person'] = {
                'id': item['person_id'],
                'name': crypto.decrypt_field(item['person_encrypted_name']),
                'relationship': {
                    'id': item['relationship_id'],
                    'name': crypto.decrypt_field(item['rel_encrypted_name']) if item['rel_encrypted_name'] else None,
                    'color': item['rel_color']
                }
            }
        else:
            item['person'] = None

        del item['person_encrypted_name']
        del item['rel_encrypted_name']
        del item['rel_color']

        # Add URLs
        item['thumbnail_url'] = f"/api/private-gallery/thumbnail/{item['id']}"
        item['stream_url'] = f"/api/private-gallery/stream/{item['id']}"
        item['file_url'] = f"/api/private-gallery/file/{item['id']}"

        items.append(item)

    # Re-sort items within each post by id ASC to match the posts endpoint and paginated attachments endpoint
    # Skip re-sorting in shuffle mode — shuffle order is authoritative
    if items and not shuffle:
        post_order = []
        seen = set()
        by_post = {}
        for item in items:
            pid = item.get('post_id')
            if pid not in seen:
                post_order.append(pid)
                seen.add(pid)
                by_post[pid] = []
            by_post[pid].append(item)
        for pid in by_post:
            by_post[pid].sort(key=lambda x: x.get('id', 0))
        items = []
        for pid in post_order:
            items.extend(by_post[pid])

    if not can_sql_paginate:
        # Apply date filtering (on decrypted dates)
        if date_from:
            items = [i for i in items if i['media_date'] >= date_from]
        if date_to:
            items = [i for i in items if i['media_date'] <= date_to]

        # Apply search (on decrypted fields) - each word must match in filename or description
        if search:
            words = search.lower().split()
            if words:
                items = [i for i in items if all(
                    w in (i.get('filename') or '').lower() or w in (i.get('description') or '').lower()
                    for w in words
                )]

        # Filter by has_description (after decryption since description is encrypted)
        if has_description is True:
            items = [i for i in items if i.get('description') and i['description'].strip()]
        elif has_description is False:
            items = [i for i in items if not i.get('description') or not i['description'].strip()]

        # Sort by decrypted field — use post-level media_date to match posts endpoint sort
        if sort_by == 'media_date':
            items.sort(key=lambda x: (x.get('post_media_date') or x.get('media_date') or '', x.get('post_created_at') or ''), reverse=(sort_order == 'desc'))
        elif sort_by == 'filename':
            items.sort(key=lambda x: (x.get('filename') or '').lower(), reverse=(sort_order == 'desc'))
        elif sort_by == 'created_at' and sort_order == 'asc':
            items.reverse()  # SQL default is DESC, reverse for ASC

        # Apply pagination
        total_filtered = len(items)
        items = items[offset:offset + limit]

    # Get tags for each item
    media_ids = [i['id'] for i in items]
    if media_ids:
        with db.get_connection() as conn:
            cursor = conn.cursor()
            placeholders = ','.join('?' * len(media_ids))
            cursor.execute(f'''
                SELECT mt.media_id, t.id, t.encrypted_name, t.color
                FROM private_media_tags mt
                JOIN private_gallery_tags t ON mt.tag_id = t.id
                WHERE mt.media_id IN ({placeholders})
            ''', media_ids)

            tags_by_media = {}
            for row in cursor.fetchall():
                media_id = row['media_id']
                if media_id not in tags_by_media:
                    tags_by_media[media_id] = []
                tags_by_media[media_id].append({
                    'id': row['id'],
                    'name': crypto.decrypt_field(row['encrypted_name']),
                    'color': row['color']
                })

            for item in items:
                item['tags'] = tags_by_media.get(item['id'], [])

    # Clean up internal sort fields before response
    for item in items:
        item.pop('post_media_date', None)
        item.pop('post_created_at', None)
        item.pop('post_encrypted_media_date', None)

    return {
        "items": items,
        "total": total_filtered,
        "offset": offset,
        "limit": limit,
        "has_more": offset + limit < total_filtered
    }


@router.get("/posts")
@limiter.limit("120/minute")
@handle_exceptions
async def get_posts(
    request: Request,
    offset: int = Query(0, ge=0),
    limit: int = Query(50, ge=1, le=200),
    person_id: Optional[int] = None,
    person_group_id: Optional[int] = None,
    relationship_id: Optional[int] = None,
    tag_ids: Optional[str] = None,
    file_type: Optional[str] = None,
    search: Optional[str] = None,
    sort_by: str = Query("media_date", regex="^(media_date|created_at)$"),
    sort_order: str = Query("desc", regex="^(asc|desc)$"),
    date_from: Optional[str] = None,
    date_to: Optional[str] = None,
    has_tags: Optional[bool] = None,
    has_description: Optional[bool] = None,
    unread_only: Optional[bool] = None,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Get posts with their grouped media items."""
    db = _get_db()
    crypto = _get_crypto()

    # Ensure private_gallery_tags table exists and is migrated
    _ensure_private_gallery_tags_migrated(db, crypto)

    # Build cache key from SQL-level filter params
    cache_key = f"{person_id}|{person_group_id}|{relationship_id}|{tag_ids}|{has_tags}|{file_type}|{unread_only}|{_posts_cache_version}"

    # Check cache for decrypted base post list (with TTL)
    cached = _posts_cache.get(cache_key)
    if cached is not None:
        cache_age = time.time() - _posts_cache_time.get(cache_key, 0)
        if cache_age > _POSTS_CACHE_TTL:
            cached = None  # expired
    if cached is not None:
        all_posts = [dict(p) for p in cached]  # shallow copy each dict
    else:
        with db.get_connection() as conn:
            cursor = conn.cursor()

            # Build WHERE clauses for posts
            where_clauses = []
            params = []

            if person_id is not None:
                where_clauses.append("post.person_id = ?")
                params.append(person_id)

            if person_group_id is not None:
                # WHO filter: persons + relationships (pass through if none defined)
                where_clauses.append("""(
                    (
                        NOT EXISTS (SELECT 1 FROM private_media_person_group_members WHERE group_id = ?)
                        AND NOT EXISTS (SELECT 1 FROM private_media_person_group_relationship_members WHERE group_id = ?)
                    )
                    OR post.person_id IN (SELECT person_id FROM private_media_person_group_members WHERE group_id = ?)
                    OR post.person_id IN (SELECT p2.id FROM private_media_persons p2 WHERE p2.relationship_id IN (SELECT relationship_id FROM private_media_person_group_relationship_members WHERE group_id = ?))
                )""")
                params.extend([person_group_id, person_group_id, person_group_id, person_group_id])
                # TAG filter: included tags (pass through if none defined)
                where_clauses.append("""(
                    NOT EXISTS (SELECT 1 FROM private_media_person_group_tag_members WHERE group_id = ?)
                    OR post.id IN (SELECT DISTINCT m_tg.post_id FROM private_media m_tg JOIN private_media_tags mt_tg ON mt_tg.media_id = m_tg.id WHERE mt_tg.tag_id IN (SELECT tag_id FROM private_media_person_group_tag_members WHERE group_id = ?) AND m_tg.post_id IS NOT NULL)
                )""")
                params.extend([person_group_id, person_group_id])
                # EXCLUDE filter: excluded tags
                where_clauses.append("""
                    post.id NOT IN (SELECT DISTINCT m_ex.post_id FROM private_media m_ex JOIN private_media_tags mt_ex ON mt_ex.media_id = m_ex.id WHERE mt_ex.tag_id IN (SELECT tag_id FROM private_media_person_group_excluded_tags WHERE group_id = ?) AND m_ex.post_id IS NOT NULL)
                """)
                params.append(person_group_id)

            if relationship_id is not None:
                where_clauses.append("p.relationship_id = ?")
                params.append(relationship_id)

            if tag_ids:
                tag_id_list = [int(t) for t in tag_ids.split(',') if t.strip().isdigit()]
                if tag_id_list:
                    placeholders = ','.join('?' * len(tag_id_list))
                    where_clauses.append(f'''
                        post.id IN (
                            SELECT post_id FROM private_media_post_tags WHERE tag_id IN ({placeholders})
                            UNION
                            SELECT DISTINCT m.post_id FROM private_media m
                                JOIN private_media_tags mt ON mt.media_id = m.id
                                WHERE mt.tag_id IN ({placeholders}) AND m.post_id IS NOT NULL
                        )
                    ''')
                    params.extend(tag_id_list)
                    params.extend(tag_id_list)

            if has_tags is True:
                where_clauses.append("EXISTS (SELECT 1 FROM private_media_post_tags WHERE post_id = post.id)")
            elif has_tags is False:
                where_clauses.append("NOT EXISTS (SELECT 1 FROM private_media_post_tags WHERE post_id = post.id)")

            # Filter by file_type (posts that have at least one media item of that type)
            if file_type and file_type != 'all':
                where_clauses.append(f'''
                    EXISTS (SELECT 1 FROM private_media m WHERE m.post_id = post.id AND m.file_type = ?)
                ''')
                params.append(file_type)

            if unread_only:
                where_clauses.append("post.is_read = 0")

            where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"

            # Get posts with person info
            query = f'''
                SELECT post.*, p.encrypted_name as person_encrypted_name,
                       p.relationship_id, r.encrypted_name as rel_encrypted_name, r.color as rel_color
                FROM private_media_posts post
                LEFT JOIN private_media_persons p ON post.person_id = p.id
                LEFT JOIN private_media_relationships r ON p.relationship_id = r.id
                WHERE {where_sql}
                ORDER BY post.created_at DESC
            '''
            cursor.execute(query, params)
            rows = cursor.fetchall()

        # Decrypt and process posts
        all_posts = []

        for row in rows:
            post = dict(row)

            # Decrypt post fields
            post['description'] = crypto.decrypt_field(post['encrypted_description']) if post['encrypted_description'] else None
            post['media_date'] = crypto.decrypt_field(post['encrypted_media_date'])

            # Remove encrypted fields
            del post['encrypted_description']
            del post['encrypted_media_date']

            # Add person info
            if post.get('person_encrypted_name'):
                post['person'] = {
                    'id': post['person_id'],
                    'name': crypto.decrypt_field(post['person_encrypted_name']),
                    'relationship': {
                        'id': post['relationship_id'],
                        'name': crypto.decrypt_field(post['rel_encrypted_name']) if post.get('rel_encrypted_name') else None,
                        'color': post.get('rel_color')
                    }
                }
            else:
                post['person'] = None

            # Clean up temp fields
            for key in ['person_encrypted_name', 'rel_encrypted_name', 'rel_color']:
                if key in post:
                    del post[key]

            all_posts.append(post)

        # Store in cache with timestamp
        with _posts_cache_lock:
            _posts_cache[cache_key] = all_posts
            _posts_cache_time[cache_key] = time.time()
            all_posts = [dict(p) for p in all_posts]  # work on copies

    posts = all_posts

    # Apply date filtering (on decrypted dates)
    if date_from:
        posts = [p for p in posts if p['media_date'] >= date_from]
    if date_to:
        posts = [p for p in posts if p['media_date'] <= date_to]

    # Apply search (on decrypted fields) - each word must match in description
    if search:
        words = search.lower().split()
        if words:
            posts = [p for p in posts if all(
                w in (p.get('description') or '').lower()
                for w in words
            )]

    # Filter by has_description (after decryption)
    if has_description is True:
        posts = [p for p in posts if p.get('description') and p['description'].strip()]
    elif has_description is False:
        posts = [p for p in posts if not p.get('description') or not p['description'].strip()]

    # Sort by decrypted field, with created_at as tiebreaker
    if sort_by == 'media_date':
        posts.sort(key=lambda x: (x.get('media_date') or '', x.get('created_at') or ''), reverse=(sort_order == 'desc'))
    elif sort_order == 'asc':
        posts.reverse()  # Default order is DESC from SQL, reverse for ASC

    # Apply pagination
    total_filtered = len(posts)

    # Count total media items across ALL filtered posts (before pagination)
    all_filtered_post_ids = [p['id'] for p in posts]
    total_media = 0
    if all_filtered_post_ids:
        with db.get_connection() as conn:
            cursor = conn.cursor()
            placeholders = ','.join('?' * len(all_filtered_post_ids))
            if file_type and file_type != 'all':
                cursor.execute(f'''
                    SELECT COUNT(*) as cnt FROM private_media
                    WHERE post_id IN ({placeholders}) AND file_type = ?
                ''', all_filtered_post_ids + [file_type])
            else:
                cursor.execute(f'''
                    SELECT COUNT(*) as cnt FROM private_media
                    WHERE post_id IN ({placeholders})
                ''', all_filtered_post_ids)
            total_media = cursor.fetchone()['cnt']

    posts = posts[offset:offset + limit]

    # Get media items for each post (capped at 100 per post for performance)
    ATTACHMENT_CAP = 100
    post_ids = [p['id'] for p in posts]
    media_by_post = {}
    media_count_by_post = {}
    media_type_counts_by_post = {}
    if post_ids:
        with db.get_connection() as conn:
            cursor = conn.cursor()
            placeholders = ','.join('?' * len(post_ids))
            file_type_clause = ''
            file_type_params = []
            if file_type and file_type != 'all':
                file_type_clause = ' AND m.file_type = ?'
                file_type_params = [file_type]

            # Get total counts per post with image/video breakdown (cheap — no decryption)
            cursor.execute(f'''
                SELECT m.post_id, COUNT(*) as cnt,
                       SUM(CASE WHEN m.file_type = 'image' THEN 1 ELSE 0 END) as image_count,
                       SUM(CASE WHEN m.file_type = 'video' THEN 1 ELSE 0 END) as video_count
                FROM private_media m
                WHERE m.post_id IN ({placeholders}){file_type_clause}
                GROUP BY m.post_id
            ''', list(post_ids) + file_type_params)
            for row in cursor.fetchall():
                media_count_by_post[row['post_id']] = row['cnt']
                media_type_counts_by_post[row['post_id']] = {
                    'image_count': row['image_count'],
                    'video_count': row['video_count']
                }

            # Fetch capped media per post using window function
            media_query_params = list(post_ids) + file_type_params
            cursor.execute(f'''
                SELECT * FROM (
                    SELECT m.*, p.encrypted_name as person_encrypted_name,
                           p.relationship_id, r.encrypted_name as rel_encrypted_name, r.color as rel_color,
                           ROW_NUMBER() OVER (PARTITION BY m.post_id ORDER BY m.id ASC) as rn
                    FROM private_media m
                    LEFT JOIN private_media_persons p ON m.person_id = p.id
                    LEFT JOIN private_media_relationships r ON p.relationship_id = r.id
                    WHERE m.post_id IN ({placeholders}){file_type_clause}
                ) WHERE rn <= {ATTACHMENT_CAP}
            ''', media_query_params)

            for row in cursor.fetchall():
                media = dict(row)
                post_id = media['post_id']
                del media['rn']

                # Decrypt media fields
                media['filename'] = crypto.decrypt_field(media['encrypted_filename'])
                media['description'] = crypto.decrypt_field(media['encrypted_description']) if media['encrypted_description'] else None
                media['media_date'] = crypto.decrypt_field(media['encrypted_media_date'])
                media['source_path'] = crypto.decrypt_field(media['encrypted_source_path']) if media['encrypted_source_path'] else None

                # Remove encrypted fields
                for key in ['encrypted_filename', 'encrypted_description', 'encrypted_media_date', 'encrypted_source_path']:
                    if key in media:
                        del media[key]

                # Add URLs
                media['thumbnail_url'] = f"/api/private-gallery/thumbnail/{media['id']}"
                media['stream_url'] = f"/api/private-gallery/stream/{media['id']}"
                media['file_url'] = f"/api/private-gallery/file/{media['id']}"

                # Clean up temp fields
                for key in ['person_encrypted_name', 'rel_encrypted_name', 'rel_color']:
                    if key in media:
                        del media[key]

                if post_id not in media_by_post:
                    media_by_post[post_id] = []
                media_by_post[post_id].append(media)

        # Sort attachments within each post by id ascending (matches ROW_NUMBER window and paginated endpoint)
        for pid in media_by_post:
            media_by_post[pid].sort(key=lambda x: x.get('id', 0))

    # Get tags for each post
    tags_by_post = {}
    if post_ids:
        with db.get_connection() as conn:
            cursor = conn.cursor()
            placeholders = ','.join('?' * len(post_ids))
            cursor.execute(f'''
                SELECT pt.post_id, t.id, t.encrypted_name, t.color
                FROM private_media_post_tags pt
                JOIN private_gallery_tags t ON pt.tag_id = t.id
                WHERE pt.post_id IN ({placeholders})
            ''', post_ids)

            for row in cursor.fetchall():
                post_id = row['post_id']
                if post_id not in tags_by_post:
                    tags_by_post[post_id] = []
                tags_by_post[post_id].append({
                    'id': row['id'],
                    'name': crypto.decrypt_field(row['encrypted_name']),
                    'color': row['color']
                })

    # Attach media and tags to posts
    for post in posts:
        post['attachments'] = media_by_post.get(post['id'], [])
        post['tags'] = tags_by_post.get(post['id'], [])
        post['attachment_count'] = media_count_by_post.get(post['id'], 0)
        type_counts = media_type_counts_by_post.get(post['id'], {})
        post['image_count'] = type_counts.get('image_count', 0)
        post['video_count'] = type_counts.get('video_count', 0)

    return {
        "posts": posts,
        "total": total_filtered,
        "total_media": total_media,
        "offset": offset,
        "limit": limit,
        "has_more": offset + limit < total_filtered
    }


@router.get("/new-posts-count")
@limiter.limit("120/minute")
@handle_exceptions
async def get_new_posts_count(
    request: Request,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Get count of unread posts."""
    db = _get_db()

    with db.get_connection() as conn:
        cursor = conn.cursor()
        cursor.execute('SELECT COUNT(*) as cnt FROM private_media_posts WHERE is_read = 0')
        count = cursor.fetchone()['cnt']

    return {"count": count}


@router.post("/mark-posts-seen")
@limiter.limit("30/minute")
@handle_exceptions
async def mark_posts_seen(
    request: Request,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Mark all unread posts as read."""
    db = _get_db()

    with db.get_connection() as conn:
        cursor = conn.cursor()
        cursor.execute('UPDATE private_media_posts SET is_read = 1 WHERE is_read = 0')
        updated = cursor.rowcount
        conn.commit()

    _invalidate_posts_cache()
    return {"updated": updated}


@router.put("/posts/batch/read-status")
@limiter.limit("60/minute")
@handle_exceptions
async def batch_update_read_status(
    request: Request,
    body: BatchReadStatusRequest,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Update read status for multiple posts."""
    if not body.post_ids:
        return {"updated": 0}

    db = _get_db()
    is_read_val = 1 if body.is_read else 0

    with db.get_connection() as conn:
        cursor = conn.cursor()
        placeholders = ','.join('?' * len(body.post_ids))
        cursor.execute(
            f'UPDATE private_media_posts SET is_read = ? WHERE id IN ({placeholders})',
            [is_read_val] + body.post_ids
        )
        updated = cursor.rowcount
        conn.commit()

    _invalidate_posts_cache()
    return {"updated": updated}


@router.put("/posts/{post_id}/read-status")
@limiter.limit("120/minute")
@handle_exceptions
async def update_post_read_status(
    request: Request,
    post_id: int,
    body: dict,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Update read status for a single post."""
    db = _get_db()
    is_read_val = 1 if body.get('is_read') else 0

    with db.get_connection() as conn:
        cursor = conn.cursor()
        cursor.execute('UPDATE private_media_posts SET is_read = ? WHERE id = ?', (is_read_val, post_id))
        if cursor.rowcount == 0:
            raise NotFoundError(f"Post {post_id} not found")
        conn.commit()

    _invalidate_posts_cache()
    return {"post_id": post_id, "is_read": is_read_val}


@router.get("/posts/{post_id}/attachments")
@limiter.limit("120/minute")
@handle_exceptions
async def get_post_attachments(
    request: Request,
    post_id: int,
    offset: int = Query(0, ge=0),
    limit: int = Query(100, ge=1, le=500),
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Get paginated attachments for a post."""
    db = _get_db()
    crypto = _get_crypto()

    with db.get_connection() as conn:
        cursor = conn.cursor()

        # Verify post exists
        cursor.execute('SELECT id FROM private_media_posts WHERE id = ?', (post_id,))
        if not cursor.fetchone():
            raise NotFoundError(f"Post {post_id} not found")

        # Fetch all media for this post, decrypt, sort by media_date DESC (matches post view)
        cursor.execute('''
            SELECT m.*, p.encrypted_name as person_encrypted_name,
                   p.relationship_id, r.encrypted_name as rel_encrypted_name, r.color as rel_color
            FROM private_media m
            LEFT JOIN private_media_persons p ON m.person_id = p.id
            LEFT JOIN private_media_relationships r ON p.relationship_id = r.id
            WHERE m.post_id = ?
        ''', (post_id,))

        all_items = []
        for row in cursor.fetchall():
            media = dict(row)
            media['filename'] = crypto.decrypt_field(media['encrypted_filename'])
            media['description'] = crypto.decrypt_field(media['encrypted_description']) if media['encrypted_description'] else None
            media['media_date'] = crypto.decrypt_field(media['encrypted_media_date'])
            media['source_path'] = crypto.decrypt_field(media['encrypted_source_path']) if media['encrypted_source_path'] else None

            for key in ['encrypted_filename', 'encrypted_description', 'encrypted_media_date', 'encrypted_source_path']:
                if key in media:
                    del media[key]

            media['thumbnail_url'] = f"/api/private-gallery/thumbnail/{media['id']}"
            media['stream_url'] = f"/api/private-gallery/stream/{media['id']}"
            media['file_url'] = f"/api/private-gallery/file/{media['id']}"

            for key in ['person_encrypted_name', 'rel_encrypted_name', 'rel_color']:
                if key in media:
                    del media[key]

            all_items.append(media)

    # Sort by id ascending (matches posts endpoint and ROW_NUMBER window function)
    all_items.sort(key=lambda x: x.get('id', 0))
    total = len(all_items)
    items = all_items[offset:offset + limit]

    return {
        "items": items,
        "total": total,
        "offset": offset,
        "limit": limit,
        "has_more": offset + limit < total
    }


@router.put("/posts/{post_id}")
@limiter.limit("60/minute")
@handle_exceptions
async def update_post(
    request: Request,
    post_id: int,
    body: PostUpdateRequest,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Update a post's metadata."""
    db = _get_db()
    crypto = _get_crypto()

    # Verify post exists
    with db.get_connection() as conn:
        cursor = conn.cursor()
        cursor.execute('SELECT id FROM private_media_posts WHERE id = ?', (post_id,))
        if not cursor.fetchone():
            raise NotFoundError(f"Post {post_id} not found")

    updates = []
    params = []

    if body.description is not None:
        updates.append("encrypted_description = ?")
        params.append(crypto.encrypt_field(body.description) if body.description else None)

    if body.person_id is not None:
        updates.append("person_id = ?")
        params.append(body.person_id if body.person_id > 0 else None)

    if body.media_date is not None:
        updates.append("encrypted_media_date = ?")
        params.append(crypto.encrypt_field(body.media_date))

    if updates:
        updates.append("updated_at = CURRENT_TIMESTAMP")
        params.append(post_id)

        with db.get_connection(for_write=True) as conn:
            cursor = conn.cursor()
            cursor.execute(f'''
                UPDATE private_media_posts
                SET {", ".join(updates)}
                WHERE id = ?
            ''', params)
            conn.commit()

    # Update tags if provided
    if body.tag_ids is not None:
        with db.get_connection(for_write=True) as conn:
            cursor = conn.cursor()
            # Remove existing tags
            cursor.execute('DELETE FROM private_media_post_tags WHERE post_id = ?', (post_id,))
            # Add new tags
            for tag_id in body.tag_ids:
                cursor.execute('''
                    INSERT INTO private_media_post_tags (post_id, tag_id)
                    VALUES (?, ?)
                ''', (post_id, tag_id))
            conn.commit()

    _invalidate_posts_cache()
    return message_response("Post updated")


@router.delete("/posts/{post_id}")
@limiter.limit("30/minute")
@handle_exceptions
async def delete_post(
    request: Request,
    post_id: int,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Delete a post and all its media items."""
    db = _get_db()
    crypto = _get_crypto()

    # Get all media items for this post
    with db.get_connection() as conn:
        cursor = conn.cursor()
        cursor.execute('SELECT storage_id FROM private_media WHERE post_id = ?', (post_id,))
        media_rows = cursor.fetchall()

    if not media_rows:
        # Check if post exists with no media
        with db.get_connection() as conn:
            cursor = conn.cursor()
            cursor.execute('SELECT id FROM private_media_posts WHERE id = ?', (post_id,))
            if not cursor.fetchone():
                raise NotFoundError(f"Post {post_id} not found")

    # Get storage path
    config = _get_config(db)
    storage_path = Path(config.get('storage_path', '/opt/immich/private'))
    thumbs_path = storage_path / 'thumbs'

    # Delete files (ignore errors — files may be missing or on a failed disk)
    # Skip exists() checks — they hang for ~6s on I/O error disks, causing request timeouts
    for row in media_rows:
        storage_id = row['storage_id']
        for path in [
            storage_path / 'data' / f"{storage_id}.enc",
            thumbs_path / f"{storage_id}.enc",
        ]:
            try:
                path.unlink()
            except FileNotFoundError:
                pass
            except OSError as e:
                logger.warning(f"Could not delete {path}: {e}")

    # Delete from database (cascades to media items and tags)
    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        # Delete media items (cascade from posts should handle this, but be explicit)
        cursor.execute('DELETE FROM private_media WHERE post_id = ?', (post_id,))
        # Delete post tags
        cursor.execute('DELETE FROM private_media_post_tags WHERE post_id = ?', (post_id,))
        # Delete post
        cursor.execute('DELETE FROM private_media_posts WHERE id = ?', (post_id,))
        conn.commit()

    _invalidate_posts_cache()
    return message_response("Post deleted")


@router.post("/posts")
@limiter.limit("30/minute")
@handle_exceptions
async def create_post(
    request: Request,
    body: PostCreateRequest,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Create a new post, optionally attaching existing media."""
    db = _get_db()
    crypto = _get_crypto()

    final_date = body.media_date or date.today().isoformat()

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()

        # Verify person exists
        cursor.execute('SELECT id FROM private_media_persons WHERE id = ?', (body.person_id,))
        if not cursor.fetchone():
            raise ValidationError(f"Person {body.person_id} not found")

        # Create the post
        cursor.execute('''
            INSERT INTO private_media_posts (
                person_id, encrypted_description, encrypted_media_date
            ) VALUES (?, ?, ?)
        ''', (
            body.person_id,
            crypto.encrypt_field(body.description) if body.description else None,
            crypto.encrypt_field(final_date)
        ))
        post_id = cursor.lastrowid

        # Add tags to the post
        if body.tag_ids:
            for tag_id in body.tag_ids:
                cursor.execute('''
                    INSERT OR IGNORE INTO private_media_post_tags (post_id, tag_id)
                    VALUES (?, ?)
                ''', (post_id, tag_id))

        # Attach existing media if provided
        attached = 0
        if body.media_ids:
            for media_id in body.media_ids:
                cursor.execute('SELECT id, post_id FROM private_media WHERE id = ?', (media_id,))
                row = cursor.fetchone()
                if not row:
                    continue

                source_post_id = row['post_id']

                # Record original_post_id if not already set
                if source_post_id is not None:
                    cursor.execute('SELECT original_post_id FROM private_media WHERE id = ?', (media_id,))
                    orig = cursor.fetchone()
                    if orig and orig['original_post_id'] is None:
                        cursor.execute('UPDATE private_media SET original_post_id = ? WHERE id = ?', (source_post_id, media_id))

                # Move to new post
                cursor.execute('UPDATE private_media SET post_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (post_id, media_id))
                attached += 1

        conn.commit()

    _invalidate_posts_cache()
    return {"post_id": post_id, "attached": attached}


def _scan_duplicates_background(job_id: str, post_id: int, person_id: int):
    """Background task to scan for perceptual duplicates across a person's media."""
    import threading
    db = _get_db()
    crypto = _get_crypto()
    config = _get_config(db)
    storage_path = Path(config.get('storage_path', '/opt/immich/private'))
    data_path = storage_path / 'data'

    try:
        # Get all media for this person
        with db.get_connection() as conn:
            cursor = conn.cursor()
            cursor.execute('''
                SELECT id, post_id, storage_id, encrypted_filename, file_type, perceptual_hash, width, height
                FROM private_media
                WHERE post_id IN (SELECT id FROM private_media_posts WHERE person_id = ?)
                  AND file_type IN ('image', 'video')
            ''', (person_id,))
            all_media = cursor.fetchall()

        total = len(all_media)
        _update_pg_job(job_id, {'total_files': total, 'current_phase': 'hashing'})

        if total < 2:
            _update_pg_job(job_id, {
                'status': 'completed',
                'scan_results': [],
                'total_scanned': total,
                'total_groups': 0,
                'completed_at': datetime.now().isoformat(),
                'message': f'Not enough media to compare ({total} found)'
            })
            return

        # Backfill perceptual hashes
        media_list = []
        for idx, row in enumerate(all_media):
            phash = row['perceptual_hash']
            media_id = row['id']
            if not phash:
                encrypted_file = data_path / f"{row['storage_id']}.enc"
                if encrypted_file.exists():
                    try:
                        temp_dir = Path(tempfile.gettempdir())
                        orig_name = crypto.decrypt_field(row['encrypted_filename']) or ''
                        ext = Path(orig_name).suffix or ('.jpg' if row['file_type'] == 'image' else '.mp4' if row['file_type'] == 'video' else '')
                        temp_file = temp_dir / f"pg_phash_{row['storage_id']}{ext}"
                        crypto.decrypt_file(encrypted_file, temp_file)
                        phash = _compute_perceptual_hash(temp_file)
                        temp_file.unlink(missing_ok=True)
                        if phash:
                            with db.get_connection(for_write=True) as conn:
                                cursor = conn.cursor()
                                cursor.execute('UPDATE private_media SET perceptual_hash = ? WHERE id = ?', (phash, media_id))
                                conn.commit()
                    except Exception:
                        pass
            if phash:
                media_list.append({
                    'id': media_id,
                    'post_id': row['post_id'],
                    'storage_id': row['storage_id'],
                    'filename': crypto.decrypt_field(row['encrypted_filename']),
                    'file_type': row['file_type'],
                    'perceptual_hash': phash,
                    'width': row['width'],
                    'height': row['height'],
                })
            _update_pg_job(job_id, {
                'processed_files': idx + 1,
                'success_count': len(media_list),
            })

        # Compare all pairs using hamming distance (optimized with pre-computed integers)
        total_pairs = len(media_list) * (len(media_list) - 1) // 2
        _update_pg_job(job_id, {'current_phase': 'comparing', 'total_pairs': total_pairs, 'compared_pairs': 0})

        # Pre-compute integer values for fast XOR-based hamming distance
        hash_ints = {}
        for m in media_list:
            try:
                hash_ints[m['id']] = int(m['perceptual_hash'], 16)
            except (ValueError, TypeError):
                pass

        threshold = 12
        parent = {m['id']: m['id'] for m in media_list}

        def find(x):
            while parent[x] != x:
                parent[x] = parent[parent[x]]
                x = parent[x]
            return x

        def union(x, y):
            px, py = find(x), find(y)
            if px != py:
                parent[px] = py

        pair_distances = {}
        compared = 0
        for i in range(len(media_list)):
            id_i = media_list[i]['id']
            if id_i not in hash_ints:
                compared += len(media_list) - i - 1
                continue
            hi = hash_ints[id_i]
            for j in range(i + 1, len(media_list)):
                id_j = media_list[j]['id']
                compared += 1
                if id_j not in hash_ints:
                    continue
                dist = bin(hi ^ hash_ints[id_j]).count('1')
                if dist <= threshold:
                    union(id_i, id_j)
                    pair_distances[(id_i, id_j)] = dist
                    pair_distances[(id_j, id_i)] = dist
            # Update progress periodically
            if i % 50 == 0:
                _update_pg_job(job_id, {'compared_pairs': compared})

        # Group by root
        groups: Dict[int, list] = {}
        for m in media_list:
            root = find(m['id'])
            if root not in groups:
                groups[root] = []
            groups[root].append(m)

        duplicate_groups = []
        for group in groups.values():
            if len(group) > 1:
                items = []
                for m in group:
                    distances_to_others = {}
                    for other in group:
                        if other['id'] != m['id']:
                            key = (m['id'], other['id'])
                            if key in pair_distances:
                                distances_to_others[str(other['id'])] = pair_distances[key]
                    items.append({
                        'media_id': m['id'],
                        'post_id': m['post_id'],
                        'storage_id': m['storage_id'],
                        'filename': m['filename'],
                        'file_type': m['file_type'],
                        'width': m['width'],
                        'height': m['height'],
                        'distances': distances_to_others,
                    })
                duplicate_groups.append({'items': items})

        results_data = {
            'scan_results': duplicate_groups,
            'total_scanned': len(media_list),
            'total_groups': len(duplicate_groups),
        }
        _update_pg_job(job_id, {
            'status': 'completed',
            'completed_at': datetime.now().isoformat(),
            **results_data,
        })

    except Exception as e:
        logger.error(f"[ScanDuplicates] Background scan failed: {e}", module="PrivateGallery")
        _update_pg_job(job_id, {
            'status': 'completed',
            'scan_results': [],
            'total_scanned': 0,
            'total_groups': 0,
            'completed_at': datetime.now().isoformat(),
            'message': f'Scan failed: {str(e)}'
        })


def _scan_all_duplicates_background(job_id: str):
    """Background task to scan for perceptual duplicates across ALL persons' media."""
    import threading
    db = _get_db()
    crypto = _get_crypto()
    config = _get_config(db)
    storage_path = Path(config.get('storage_path', '/opt/immich/private'))
    data_path = storage_path / 'data'

    try:
        # Get all persons and build name lookup
        with db.get_connection() as conn:
            cursor = conn.cursor()
            cursor.execute('SELECT id, encrypted_name FROM private_media_persons')
            person_rows = cursor.fetchall()

        person_names = {}
        for p in person_rows:
            try:
                person_names[p['id']] = crypto.decrypt_field(p['encrypted_name']) or f"Person {p['id']}"
            except Exception:
                person_names[p['id']] = f"Person {p['id']}"

        # Fetch ALL media with a person assigned
        with db.get_connection() as conn:
            cursor = conn.cursor()
            cursor.execute('''
                SELECT m.id, m.post_id, m.storage_id, m.encrypted_filename, m.file_type,
                       m.perceptual_hash, m.width, m.height, post.person_id
                FROM private_media m
                JOIN private_media_posts post ON m.post_id = post.id
                WHERE post.person_id IS NOT NULL
                  AND m.file_type IN ('image', 'video')
            ''')
            all_media = cursor.fetchall()

        total = len(all_media)
        _update_pg_job(job_id, {'total_files': total, 'current_phase': 'hashing'})

        if total < 2:
            _update_pg_job(job_id, {
                'status': 'completed',
                'scan_results': [],
                'total_scanned': total,
                'total_groups': 0,
                'completed_at': datetime.now().isoformat(),
                'message': f'Not enough media to compare ({total} found)'
            })
            return

        # Phase 1: Backfill perceptual hashes
        media_list = []
        for idx, row in enumerate(all_media):
            phash = row['perceptual_hash']
            media_id = row['id']
            if not phash:
                encrypted_file = data_path / f"{row['storage_id']}.enc"
                if encrypted_file.exists():
                    try:
                        temp_dir = Path(tempfile.gettempdir())
                        orig_name = crypto.decrypt_field(row['encrypted_filename']) or ''
                        ext = Path(orig_name).suffix or ('.jpg' if row['file_type'] == 'image' else '.mp4' if row['file_type'] == 'video' else '')
                        temp_file = temp_dir / f"pg_phash_{row['storage_id']}{ext}"
                        crypto.decrypt_file(encrypted_file, temp_file)
                        phash = _compute_perceptual_hash(temp_file)
                        temp_file.unlink(missing_ok=True)
                        if phash:
                            with db.get_connection(for_write=True) as conn:
                                cursor = conn.cursor()
                                cursor.execute('UPDATE private_media SET perceptual_hash = ? WHERE id = ?', (phash, media_id))
                                conn.commit()
                    except Exception:
                        pass
            if phash:
                media_list.append({
                    'id': media_id,
                    'post_id': row['post_id'],
                    'person_id': row['person_id'],
                    'storage_id': row['storage_id'],
                    'filename': crypto.decrypt_field(row['encrypted_filename']),
                    'file_type': row['file_type'],
                    'perceptual_hash': phash,
                    'width': row['width'],
                    'height': row['height'],
                })
            _update_pg_job(job_id, {
                'processed_files': idx + 1,
                'success_count': len(media_list),
            })

        # Phase 2: Compare within each person group
        # Group media by person_id
        person_groups: Dict[int, list] = {}
        for m in media_list:
            pid = m['person_id']
            if pid not in person_groups:
                person_groups[pid] = []
            person_groups[pid].append(m)

        # Count total pairs across all person groups
        total_pairs = 0
        for group in person_groups.values():
            n = len(group)
            total_pairs += n * (n - 1) // 2

        _update_pg_job(job_id, {'current_phase': 'comparing', 'total_pairs': total_pairs, 'compared_pairs': 0})

        threshold = 12
        parent = {m['id']: m['id'] for m in media_list}

        def find(x):
            while parent[x] != x:
                parent[x] = parent[parent[x]]
                x = parent[x]
            return x

        def union(x, y):
            px, py = find(x), find(y)
            if px != py:
                parent[px] = py

        pair_distances = {}
        compared = 0

        # Pre-compute integer values for fast XOR-based hamming distance
        hash_ints = {}
        for m in media_list:
            try:
                hash_ints[m['id']] = int(m['perceptual_hash'], 16)
            except (ValueError, TypeError):
                pass

        for pid, group in person_groups.items():
            pname = person_names.get(pid, f"Person {pid}")
            _update_pg_job(job_id, {'current_person': pname})

            for i in range(len(group)):
                id_i = group[i]['id']
                if id_i not in hash_ints:
                    compared += len(group) - i - 1
                    continue
                hi = hash_ints[id_i]
                for j in range(i + 1, len(group)):
                    id_j = group[j]['id']
                    compared += 1
                    if id_j not in hash_ints:
                        continue
                    dist = bin(hi ^ hash_ints[id_j]).count('1')
                    if dist <= threshold:
                        union(id_i, id_j)
                        pair_distances[(id_i, id_j)] = dist
                        pair_distances[(id_j, id_i)] = dist
                if i % 50 == 0:
                    _update_pg_job(job_id, {'compared_pairs': compared})

        # Group by root
        groups: Dict[int, list] = {}
        for m in media_list:
            root = find(m['id'])
            if root not in groups:
                groups[root] = []
            groups[root].append(m)

        duplicate_groups = []
        for group in groups.values():
            if len(group) > 1:
                # All items in a group share the same person_id (we only compare within person)
                pid = group[0]['person_id']
                pname = person_names.get(pid, f"Person {pid}")
                items = []
                for m in group:
                    distances_to_others = {}
                    for other in group:
                        if other['id'] != m['id']:
                            key = (m['id'], other['id'])
                            if key in pair_distances:
                                distances_to_others[str(other['id'])] = pair_distances[key]
                    items.append({
                        'media_id': m['id'],
                        'post_id': m['post_id'],
                        'person_id': m['person_id'],
                        'person_name': pname,
                        'storage_id': m['storage_id'],
                        'filename': m['filename'],
                        'file_type': m['file_type'],
                        'width': m['width'],
                        'height': m['height'],
                        'distances': distances_to_others,
                    })
                duplicate_groups.append({'items': items, 'person_name': pname})

        results_data = {
            'scan_results': duplicate_groups,
            'total_scanned': len(media_list),
            'total_groups': len(duplicate_groups),
        }
        _update_pg_job(job_id, {
            'status': 'completed',
            'completed_at': datetime.now().isoformat(),
            **results_data,
        })

    except Exception as e:
        logger.error(f"[ScanAllDuplicates] Background scan failed: {e}", module="PrivateGallery")
        _update_pg_job(job_id, {
            'status': 'completed',
            'scan_results': [],
            'total_scanned': 0,
            'total_groups': 0,
            'completed_at': datetime.now().isoformat(),
            'message': f'Scan failed: {str(e)}'
        })


@router.post("/posts/{post_id}/scan-duplicates")
@limiter.limit("5/minute")
@handle_exceptions
async def scan_duplicates_for_person(
    request: Request,
    post_id: int,
    background_tasks: BackgroundTasks,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Start a background scan for perceptual duplicates across this post's person's media."""
    db = _get_db()

    # Get person_id from the post
    with db.get_connection() as conn:
        cursor = conn.cursor()
        cursor.execute('SELECT person_id FROM private_media_posts WHERE id = ?', (post_id,))
        post_row = cursor.fetchone()

    if not post_row:
        raise NotFoundError(f"Post {post_id} not found")

    person_id = post_row['person_id']
    if not person_id:
        return {"job_id": None, "message": "Post has no person assigned"}

    job_id = f"scan-{post_id}-{uuid.uuid4().hex[:8]}"
    _create_pg_job(job_id, 0, 'scan-duplicates')
    _update_pg_job(job_id, {'person_id': person_id, 'current_phase': 'starting'})

    import threading
    thread = threading.Thread(target=_scan_duplicates_background, args=(job_id, post_id, person_id), daemon=True)
    thread.start()

    return {"job_id": job_id}


@router.post("/scan-all-duplicates")
@limiter.limit("2/minute")
@handle_exceptions
async def scan_all_duplicates(
    request: Request,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Start a background scan for perceptual duplicates across ALL persons' media."""
    db = _get_db()

    job_id = f"scan-all-{uuid.uuid4().hex[:8]}"
    _create_pg_job(job_id, 0, 'scan-all-duplicates')
    _update_pg_job(job_id, {'current_phase': 'starting'})

    import threading
    thread = threading.Thread(target=_scan_all_duplicates_background, args=(job_id,), daemon=True)
    thread.start()

    return {"job_id": job_id}


@router.post("/posts/cleanup-empty")
@limiter.limit("30/minute")
@handle_exceptions
async def cleanup_empty_posts(
    request: Request,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Delete posts that have no media and no description."""
    body = await request.json()
    post_ids = body.get('post_ids', [])
    if not post_ids or not isinstance(post_ids, list):
        return {"deleted_post_ids": []}

    db = _get_db()
    deleted = []

    for pid in post_ids:
        try:
            with db.get_connection() as conn:
                cursor = conn.cursor()
                # Check media count
                cursor.execute('SELECT COUNT(*) as cnt FROM private_media WHERE post_id = ?', (pid,))
                media_count = cursor.fetchone()['cnt']
                if media_count > 0:
                    continue
                # Check if post is tagged 'reddit' (skip description check for reddit posts)
                cursor.execute('SELECT id FROM private_media_posts WHERE id = ?', (pid,))
                post_row = cursor.fetchone()
                if not post_row:
                    continue
                is_reddit = False
                cursor.execute('''
                    SELECT t.encrypted_name FROM private_media_post_tags pt
                    JOIN private_gallery_tags t ON t.id = pt.tag_id
                    WHERE pt.post_id = ?
                ''', (pid,))
                crypto = _get_crypto()
                for tag_row in cursor.fetchall():
                    try:
                        tag_name = crypto.decrypt_field(tag_row['encrypted_name'])
                        if tag_name and tag_name.lower() == 'reddit':
                            is_reddit = True
                            break
                    except Exception:
                        continue

                if not is_reddit:
                    # For non-reddit posts, only delete if no description
                    cursor.execute('SELECT encrypted_description FROM private_media_posts WHERE id = ?', (pid,))
                    row = cursor.fetchone()
                    if row and row['encrypted_description']:
                        desc = crypto.decrypt_field(row['encrypted_description'])
                        if desc and desc.strip():
                            continue

            # Post has no media — delete it
            with db.get_connection(for_write=True) as conn:
                cursor = conn.cursor()
                cursor.execute('DELETE FROM private_media_post_tags WHERE post_id = ?', (pid,))
                cursor.execute('DELETE FROM private_media_posts WHERE id = ?', (pid,))
                conn.commit()
                deleted.append(pid)
        except Exception as e:
            logger.error(f"[CleanupEmpty] Failed to check/delete post {pid}: {e}", module="PrivateGallery")

    if deleted:
        _invalidate_posts_cache()
    return {"deleted_post_ids": deleted}


@router.post("/posts/{post_id}/attach-media")
@limiter.limit("30/minute")
@handle_exceptions
async def attach_media_to_post(
    request: Request,
    post_id: int,
    body: AttachMediaRequest,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Attach existing media items to a post by copying them (originals remain on their source post)."""
    db = _get_db()

    config = _get_config(db)
    storage_path = Path(config.get('storage_path', '/opt/immich/private'))
    data_path = storage_path / 'data'
    thumbs_path = storage_path / 'thumbs'

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()

        # Verify target post exists
        cursor.execute('SELECT id FROM private_media_posts WHERE id = ?', (post_id,))
        if not cursor.fetchone():
            raise NotFoundError(f"Post {post_id} not found")

        attached = 0
        for media_id in body.media_ids:
            # Get full media record to copy
            cursor.execute('''
                SELECT storage_id, encrypted_filename, encrypted_description,
                       file_hash, file_size, file_type, mime_type,
                       width, height, duration, person_id,
                       encrypted_media_date, source_type, post_id
                FROM private_media WHERE id = ?
            ''', (media_id,))
            row = cursor.fetchone()
            if not row:
                continue

            # Skip if already on target post
            if row['post_id'] == post_id:
                continue

            # Create a copy with a new storage_id
            new_storage_id = str(uuid.uuid4())

            # Hard-link the encrypted data file (shares disk space, independent deletion)
            src_data = data_path / f"{row['storage_id']}.enc"
            dst_data = data_path / f"{new_storage_id}.enc"
            if src_data.exists():
                try:
                    os.link(str(src_data), str(dst_data))
                except OSError:
                    # Fallback to copy if hard link fails (e.g. cross-device)
                    import shutil
                    shutil.copy2(str(src_data), str(dst_data))

            # Hard-link the encrypted thumbnail
            src_thumb = thumbs_path / f"{row['storage_id']}.enc"
            dst_thumb = thumbs_path / f"{new_storage_id}.enc"
            if src_thumb.exists():
                try:
                    os.link(str(src_thumb), str(dst_thumb))
                except OSError:
                    import shutil
                    shutil.copy2(str(src_thumb), str(dst_thumb))

            # Insert new media record pointing to the target post
            cursor.execute('''
                INSERT INTO private_media (
                    post_id, storage_id, encrypted_filename, encrypted_description,
                    file_hash, file_size, file_type, mime_type,
                    width, height, duration, person_id,
                    encrypted_media_date, source_type, original_post_id
                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            ''', (
                post_id, new_storage_id, row['encrypted_filename'], row['encrypted_description'],
                row['file_hash'], row['file_size'], row['file_type'], row['mime_type'],
                row['width'], row['height'], row['duration'], row['person_id'],
                row['encrypted_media_date'], row['source_type'], row['post_id']
            ))
            attached += 1

        conn.commit()

    return {"attached": attached}


@router.get("/media/{media_id}")
@limiter.limit("120/minute")
@handle_exceptions
async def get_media_item(
    request: Request,
    media_id: int,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Get a single media item."""
    db = _get_db()
    crypto = _get_crypto()

    # Ensure private_gallery_tags table exists and is migrated
    _ensure_private_gallery_tags_migrated(db, crypto)

    with db.get_connection() as conn:
        cursor = conn.cursor()
        cursor.execute('''
            SELECT m.*, p.encrypted_name as person_encrypted_name,
                   p.relationship_id, r.encrypted_name as rel_encrypted_name, r.color as rel_color
            FROM private_media m
            LEFT JOIN private_media_persons p ON m.person_id = p.id
            LEFT JOIN private_media_relationships r ON p.relationship_id = r.id
            WHERE m.id = ?
        ''', (media_id,))
        row = cursor.fetchone()

        if not row:
            raise NotFoundError(f"Media {media_id} not found")

        item = dict(row)

        # Decrypt fields
        item['filename'] = crypto.decrypt_field(item['encrypted_filename'])
        item['description'] = crypto.decrypt_field(item['encrypted_description']) if item['encrypted_description'] else None
        item['media_date'] = crypto.decrypt_field(item['encrypted_media_date'])
        item['source_path'] = crypto.decrypt_field(item['encrypted_source_path']) if item['encrypted_source_path'] else None

        del item['encrypted_filename']
        del item['encrypted_description']
        del item['encrypted_media_date']
        del item['encrypted_source_path']

        # Add person info
        if item['person_encrypted_name']:
            item['person'] = {
                'id': item['person_id'],
                'name': crypto.decrypt_field(item['person_encrypted_name']),
                'relationship': {
                    'id': item['relationship_id'],
                    'name': crypto.decrypt_field(item['rel_encrypted_name']) if item['rel_encrypted_name'] else None,
                    'color': item['rel_color']
                }
            }
        else:
            item['person'] = None

        del item['person_encrypted_name']
        del item['rel_encrypted_name']
        del item['rel_color']

        # Get tags
        cursor.execute('''
            SELECT t.id, t.encrypted_name, t.color
            FROM private_media_tags mt
            JOIN private_gallery_tags t ON mt.tag_id = t.id
            WHERE mt.media_id = ?
        ''', (media_id,))
        item['tags'] = [{
            'id': r['id'],
            'name': crypto.decrypt_field(r['encrypted_name']),
            'color': r['color']
        } for r in cursor.fetchall()]

        # Add URLs
        item['thumbnail_url'] = f"/api/private-gallery/thumbnail/{item['id']}"
        item['stream_url'] = f"/api/private-gallery/stream/{item['id']}"
        item['file_url'] = f"/api/private-gallery/file/{item['id']}"

    return item


@router.put("/media/{media_id}")
@limiter.limit("60/minute")
@handle_exceptions
async def update_media(
    request: Request,
    media_id: int,
    body: MediaUpdateRequest,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Update media metadata."""
    db = _get_db()
    crypto = _get_crypto()

    updates = []
    params = []

    if body.description is not None:
        updates.append("encrypted_description = ?")
        params.append(crypto.encrypt_field(body.description) if body.description else None)

    if body.person_id is not None:
        updates.append("person_id = ?")
        params.append(body.person_id if body.person_id > 0 else None)

    if body.media_date is not None:
        updates.append("encrypted_media_date = ?")
        params.append(crypto.encrypt_field(body.media_date))

    if updates:
        updates.append("updated_at = CURRENT_TIMESTAMP")
        params.append(media_id)

        with db.get_connection(for_write=True) as conn:
            cursor = conn.cursor()
            cursor.execute(f'''
                UPDATE private_media
                SET {", ".join(updates)}
                WHERE id = ?
            ''', params)
            if cursor.rowcount == 0:
                raise NotFoundError(f"Media {media_id} not found")
            conn.commit()

    # Update tags if provided
    if body.tag_ids is not None:
        with db.get_connection(for_write=True) as conn:
            cursor = conn.cursor()
            # Remove existing tags
            cursor.execute('DELETE FROM private_media_tags WHERE media_id = ?', (media_id,))
            # Add new tags
            for tag_id in body.tag_ids:
                cursor.execute('''
                    INSERT INTO private_media_tags (media_id, tag_id)
                    VALUES (?, ?)
                ''', (media_id, tag_id))
            conn.commit()

    return message_response("Media updated")


def _cleanup_empty_reddit_posts(db, crypto, storage_path: Path):
    """Delete posts that have no media attachments and are tagged 'reddit'."""
    try:
        # Find the reddit tag ID by decrypting tag names
        reddit_tag_id = None
        with db.get_connection() as conn:
            cursor = conn.cursor()
            cursor.execute("SELECT id, encrypted_name FROM private_gallery_tags")
            for row in cursor.fetchall():
                try:
                    name = crypto.decrypt_field(row['encrypted_name'])
                    if name and name.lower() == 'reddit':
                        reddit_tag_id = row['id']
                        break
                except Exception:
                    continue

        if reddit_tag_id is None:
            return 0

        # Find posts tagged 'reddit' that have zero media
        with db.get_connection() as conn:
            cursor = conn.cursor()
            cursor.execute('''
                SELECT p.id FROM private_media_posts p
                JOIN private_media_post_tags pt ON pt.post_id = p.id
                WHERE pt.tag_id = ?
                  AND NOT EXISTS (SELECT 1 FROM private_media m WHERE m.post_id = p.id)
            ''', (reddit_tag_id,))
            empty_posts = [row['id'] for row in cursor.fetchall()]

        if not empty_posts:
            return 0

        # Delete each empty post
        thumbs_path = storage_path / 'thumbs'
        with db.get_connection(for_write=True) as conn:
            cursor = conn.cursor()
            for post_id in empty_posts:
                cursor.execute('DELETE FROM private_media_post_tags WHERE post_id = ?', (post_id,))
                cursor.execute('DELETE FROM private_media_posts WHERE id = ?', (post_id,))
            conn.commit()

        if empty_posts:
            _invalidate_posts_cache()
            logger.info(f"Cleaned up {len(empty_posts)} empty reddit-tagged posts", module="PrivateGallery")

        return len(empty_posts)
    except Exception as e:
        logger.error(f"Failed to cleanup empty reddit posts: {e}", module="PrivateGallery")
        return 0


@router.delete("/media/{media_id}")
@limiter.limit("60/minute")
@handle_exceptions
async def delete_media(
    request: Request,
    media_id: int,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Delete a media item and its encrypted files."""
    db = _get_db()
    config = _get_config(db)
    storage_path = Path(config.get('storage_path', '/opt/immich/private'))

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()

        # Get storage_id
        cursor.execute('SELECT storage_id FROM private_media WHERE id = ?', (media_id,))
        row = cursor.fetchone()
        if not row:
            raise NotFoundError(f"Media {media_id} not found")

        storage_id = row['storage_id']

        # Delete files
        data_file = storage_path / 'data' / f"{storage_id}.enc"
        thumb_file = storage_path / 'thumbs' / f"{storage_id}.enc"

        if data_file.exists():
            data_file.unlink()
        if thumb_file.exists():
            thumb_file.unlink()

        # Delete database record (tags cascade)
        cursor.execute('DELETE FROM private_media WHERE id = ?', (media_id,))
        conn.commit()

    _thumb_cache_invalidate(storage_id)
    _invalidate_posts_cache()

    # Clean up empty reddit-tagged posts
    crypto = _get_crypto()
    _cleanup_empty_reddit_posts(db, crypto, storage_path)

    return message_response("Media deleted")


@router.post("/media/batch-delete")
@limiter.limit("120/minute")
@handle_exceptions
async def batch_delete_media(
    request: Request,
    body: BatchDeleteRequest,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Delete multiple media items."""
    db = _get_db()
    config = _get_config(db)
    storage_path = Path(config.get('storage_path', '/opt/immich/private'))

    deleted = 0

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()

        for media_id in body.media_ids:
            cursor.execute('SELECT storage_id FROM private_media WHERE id = ?', (media_id,))
            row = cursor.fetchone()
            if row:
                storage_id = row['storage_id']

                # Delete files (skip exists() — hangs on I/O error disks)
                for path in [
                    storage_path / 'data' / f"{storage_id}.enc",
                    storage_path / 'thumbs' / f"{storage_id}.enc",
                ]:
                    try:
                        path.unlink()
                    except FileNotFoundError:
                        pass
                    except OSError as e:
                        logger.warning(f"Could not delete {path}: {e}")

                cursor.execute('DELETE FROM private_media WHERE id = ?', (media_id,))
                _thumb_cache_invalidate(storage_id)
                deleted += 1

        conn.commit()

    _invalidate_posts_cache()

    # Clean up empty reddit-tagged posts
    crypto = _get_crypto()
    cleaned = _cleanup_empty_reddit_posts(db, crypto, storage_path)

    return {"deleted": deleted, "empty_reddit_posts_cleaned": cleaned}


# ============================================================================
# UPLOAD/COPY ENDPOINTS
# ============================================================================

def _upload_to_gallery_background(job_id, file_infos, post_id, person_id, tag_id_list, final_date):
    """Background task to process uploaded files into the private gallery."""
    db = _get_db()
    crypto = _get_crypto()
    config = _get_config(db)

    storage_path = Path(config.get('storage_path', '/opt/immich/private'))
    data_path = storage_path / 'data'
    thumbs_path = storage_path / 'thumbs'
    data_path.mkdir(parents=True, exist_ok=True)
    thumbs_path.mkdir(parents=True, exist_ok=True)

    results = []
    uploaded_media_ids = []
    success_count = 0
    failed_count = 0
    duplicate_count = 0
    skipped_count = 0

    for idx, fi in enumerate(file_infos):
        temp_file = Path(fi['temp_path'])
        original_filename = fi['original_filename']

        _update_pg_job(job_id, {
            'current_file': original_filename,
            'processed_files': idx
        })

        try:
            # Calculate hash for duplicate detection
            file_hash = _get_file_hash(temp_file)

            # Check for duplicates (scoped by person)
            with db.get_connection() as conn:
                cursor = conn.cursor()
                if person_id:
                    cursor.execute('SELECT id, person_id FROM private_media WHERE file_hash = ? AND person_id = ?', (file_hash, person_id))
                else:
                    cursor.execute('SELECT id, person_id FROM private_media WHERE file_hash = ?', (file_hash,))
                existing = cursor.fetchone()

            if existing:
                temp_file.unlink(missing_ok=True)
                duplicate_count += 1
                results.append({
                    'filename': original_filename,
                    'status': 'duplicate',
                    'existing_id': existing['id']
                })
                _update_pg_job(job_id, {
                    'results': list(results),
                    'duplicate_count': duplicate_count,
                    'processed_files': idx + 1
                })
                continue

            # Get file info
            file_info = _get_file_info(temp_file)
            file_size = temp_file.stat().st_size

            # Skip low-resolution images
            min_res = int(config.get('min_import_resolution', 0) or 0)
            if min_res > 0 and file_info['file_type'] == 'image':
                w = file_info.get('width') or 0
                h = file_info.get('height') or 0
                if w < min_res or h < min_res:
                    temp_file.unlink(missing_ok=True)
                    skipped_count += 1
                    results.append({
                        'filename': original_filename,
                        'status': 'skipped',
                        'reason': f'Low resolution ({w}x{h}, min {min_res}px)'
                    })
                    _update_pg_job(job_id, {
                        'results': list(results),
                        'skipped_count': skipped_count,
                        'processed_files': idx + 1
                    })
                    continue

            # Compute perceptual hash before encryption
            perceptual_hash = _compute_perceptual_hash(temp_file)

            # Try to extract date from EXIF or filename
            item_date = _extract_date_from_exif(temp_file)
            if not item_date:
                item_date = _extract_date_from_filename(original_filename)
            if not item_date:
                try:
                    from datetime import datetime as dt
                    mtime = temp_file.stat().st_mtime
                    item_date = dt.fromtimestamp(mtime).strftime('%Y-%m-%dT%H:%M:%S')
                except Exception:
                    pass
            if not item_date:
                item_date = final_date

            # Generate storage ID
            storage_id = str(uuid.uuid4())

            # Generate thumbnail first (before encrypting original)
            temp_dir = Path(tempfile.gettempdir())
            temp_thumb = temp_dir / f"pg_thumb_{storage_id}.jpg"
            _generate_thumbnail(temp_file, temp_thumb, file_info['file_type'])

            # Encrypt the original file
            encrypted_file = data_path / f"{storage_id}.enc"
            if not crypto.encrypt_file(temp_file, encrypted_file):
                raise Exception("Encryption failed")

            # Encrypt thumbnail if it exists
            if temp_thumb.exists():
                encrypted_thumb = thumbs_path / f"{storage_id}.enc"
                crypto.encrypt_file(temp_thumb, encrypted_thumb)
                temp_thumb.unlink()

            # Clean up temp file
            temp_file.unlink(missing_ok=True)

            # Insert into database with post_id reference
            with db.get_connection(for_write=True) as conn:
                cursor = conn.cursor()
                cursor.execute('''
                    INSERT INTO private_media (
                        post_id, storage_id, encrypted_filename, encrypted_description,
                        file_hash, file_size, file_type, mime_type,
                        width, height, duration, person_id,
                        encrypted_media_date, source_type, perceptual_hash
                    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                ''', (
                    post_id,
                    storage_id,
                    crypto.encrypt_field(original_filename),
                    None,
                    file_hash,
                    file_size,
                    file_info['file_type'],
                    file_info['mime_type'],
                    file_info['width'],
                    file_info['height'],
                    file_info['duration'],
                    person_id,
                    crypto.encrypt_field(item_date),
                    'upload',
                    perceptual_hash
                ))
                media_id = cursor.lastrowid
                uploaded_media_ids.append(media_id)

                for tag_id in tag_id_list:
                    cursor.execute('''
                        INSERT OR IGNORE INTO private_media_tags (media_id, tag_id)
                        VALUES (?, ?)
                    ''', (media_id, tag_id))

                conn.commit()

            success_count += 1
            results.append({
                'id': media_id,
                'filename': original_filename,
                'status': 'created',
                'media_date': item_date
            })

        except Exception as e:
            logger.error(f"Upload failed for {original_filename}: {e}")
            failed_count += 1
            temp_file.unlink(missing_ok=True)
            results.append({
                'filename': original_filename,
                'status': 'failed',
                'error': str(e)
            })

        _update_pg_job(job_id, {
            'results': list(results),
            'success_count': success_count,
            'failed_count': failed_count,
            'duplicate_count': duplicate_count,
            'skipped_count': skipped_count,
            'processed_files': idx + 1
        })

    # If no files were successfully uploaded, delete the empty post
    if not uploaded_media_ids and post_id:
        with db.get_connection(for_write=True) as conn:
            cursor = conn.cursor()
            cursor.execute('DELETE FROM private_media_post_tags WHERE post_id = ?', (post_id,))
            cursor.execute('DELETE FROM private_media_posts WHERE id = ?', (post_id,))
            conn.commit()

    _invalidate_posts_cache()
    _update_pg_job(job_id, {
        'status': 'completed',
        'completed_at': datetime.now().isoformat(),
        'current_file': None,
        'post_id': post_id if uploaded_media_ids else None
    })


@router.post("/upload")
@limiter.limit("30/minute")
@handle_exceptions
async def upload_media(
    request: Request,
    background_tasks: BackgroundTasks,
    files: List[UploadFile] = File(default=[]),
    person_id: int = Form(...),
    tag_ids: Optional[str] = Form(None),
    media_date: Optional[str] = Form(None),
    description: Optional[str] = Form(None),
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Upload media files with encryption. All files are grouped into a single post."""
    db = _get_db()
    crypto = _get_crypto()

    # Parse tag IDs (optional)
    tag_id_list = []
    if tag_ids:
        tag_id_list = [int(t) for t in tag_ids.split(',') if t.strip().isdigit()]

    # Determine the media date for the post
    final_date = media_date
    if not final_date:
        final_date = date.today().isoformat()

    # Save all uploaded files to temp before responding (UploadFile objects become invalid after response)
    file_infos = []
    temp_dir = Path(tempfile.gettempdir())
    for file in files:
        temp_path = temp_dir / f"pg_upload_{uuid.uuid4()}{Path(file.filename).suffix}"
        with open(temp_path, 'wb') as f:
            shutil.copyfileobj(file.file, f)
        file_infos.append({
            'temp_path': str(temp_path),
            'original_filename': file.filename,
            'file_size': temp_path.stat().st_size
        })

    # Create the post to group all media items
    post_id = None
    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        cursor.execute('''
            INSERT INTO private_media_posts (
                person_id, encrypted_description, encrypted_media_date
            ) VALUES (?, ?, ?)
        ''', (
            person_id,
            crypto.encrypt_field(description) if description else None,
            crypto.encrypt_field(final_date)
        ))
        post_id = cursor.lastrowid

        for tag_id in tag_id_list:
            cursor.execute('''
                INSERT OR IGNORE INTO private_media_post_tags (post_id, tag_id)
                VALUES (?, ?)
            ''', (post_id, tag_id))

        conn.commit()

    # Create job and launch background task
    job_id = f"pg_upload_{uuid.uuid4().hex[:12]}"
    _create_pg_job(job_id, len(file_infos), 'upload')

    background_tasks.add_task(
        _upload_to_gallery_background,
        job_id,
        file_infos,
        post_id,
        person_id,
        tag_id_list,
        final_date
    )

    return {
        "job_id": job_id,
        "post_id": post_id,
        "status": "processing",
        "total_files": len(file_infos)
    }


def _is_forum_thread_url(url):
    """Quick check if URL matches a Discourse thread pattern (no network request)."""
    from urllib.parse import urlparse
    path = urlparse(url).path
    return bool(re.match(r'/t/(?:[^/]+/)?(\d+)(?:/\d+)?$', path))


def _resolve_forum_thread_urls(url):
    """If URL is a Discourse forum thread, scrape it and return list of image URLs. Otherwise return None."""
    from urllib.parse import urlparse

    parsed = urlparse(url)
    path = parsed.path

    # Detect Discourse thread URL pattern: /t/slug/topic_id or /t/topic_id
    discourse_match = re.match(r'/t/(?:[^/]+/)?(\d+)(?:/\d+)?$', path)
    if not discourse_match:
        return None

    topic_id = discourse_match.group(1)
    base_url = f"{parsed.scheme}://{parsed.netloc}"

    try:
        from curl_cffi import requests as cf_requests
        from bs4 import BeautifulSoup

        session = cf_requests.Session(impersonate='chrome')

        # Fetch topic JSON
        r = session.get(f"{base_url}/t/{topic_id}.json", timeout=30)
        if r.status_code != 200:
            logger.warning(f"Forum thread fetch failed: {r.status_code}")
            return None

        data = r.json()
        stream = data.get('post_stream', {})
        posts = stream.get('posts', [])
        all_ids = stream.get('stream', [])
        loaded_ids = {p['id'] for p in posts}

        # Fetch remaining posts if needed (Discourse returns ~20 per page)
        remaining = [pid for pid in all_ids if pid not in loaded_ids]
        if remaining:
            for i in range(0, len(remaining), 20):
                chunk = remaining[i:i+20]
                params = '&'.join(f'post_ids[]={pid}' for pid in chunk)
                r2 = session.get(f"{base_url}/t/{topic_id}/posts.json?{params}", timeout=30)
                if r2.status_code == 200:
                    extra = r2.json().get('post_stream', {}).get('posts', [])
                    posts.extend(extra)

        # Extract full-size image URLs from lightbox links
        image_urls = []
        seen = set()
        for post in posts:
            cooked = post.get('cooked', '')
            soup = BeautifulSoup(cooked, 'html.parser')

            for a in soup.find_all('a', class_='lightbox'):
                href = a.get('href', '')
                if href and href not in seen:
                    seen.add(href)
                    image_urls.append(href)

        title = data.get('title', '')
        logger.info(f"Forum thread {topic_id}: found {len(image_urls)} media URLs from {len(posts)} posts")
        return {'urls': image_urls, 'title': title} if image_urls else None

    except Exception as e:
        logger.error(f"Forum thread scrape failed: {e}")
        return None


def _is_erome_album_url(url):
    """Quick check if URL matches an erome.com album pattern."""
    from urllib.parse import urlparse
    parsed = urlparse(url)
    return parsed.netloc in ('www.erome.com', 'erome.com') and bool(re.match(r'/a/[A-Za-z0-9]+$', parsed.path))


def _resolve_erome_album_urls(url):
    """If URL is an erome album, scrape it and return dict with media URLs and title. Otherwise return None."""
    from urllib.parse import urlparse

    parsed = urlparse(url)
    if parsed.netloc not in ('www.erome.com', 'erome.com'):
        return None
    if not re.match(r'/a/[A-Za-z0-9]+$', parsed.path):
        return None

    try:
        import requests as req_lib
        from bs4 import BeautifulSoup

        headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
            'Referer': 'https://www.erome.com/'
        }
        r = req_lib.get(url, headers=headers, timeout=30)
        if r.status_code != 200:
            logger.warning(f"Erome album fetch failed: {r.status_code}")
            return None

        soup = BeautifulSoup(r.text, 'html.parser')

        title_el = soup.find('h1', class_='album-title-page')
        title = title_el.text.strip() if title_el else ''

        media_urls = []
        seen = set()
        for mg in soup.find_all('div', class_='media-group'):
            img_div = mg.find('div', class_='img')
            if not img_div:
                continue

            data_src = img_div.get('data-src')
            data_html = img_div.get('data-html')

            if data_src and not data_html:
                # Image
                if data_src not in seen:
                    seen.add(data_src)
                    media_urls.append(data_src)
            elif data_html:
                # Video - get first <source> src
                source = mg.find('source')
                if source and source.get('src') and source['src'] not in seen:
                    seen.add(source['src'])
                    media_urls.append(source['src'])

        logger.info(f"Erome album {parsed.path}: found {len(media_urls)} media URLs")
        return {'urls': media_urls, 'title': title} if media_urls else None

    except Exception as e:
        logger.error(f"Erome album scrape failed: {e}")
        return None


_VENV_BIN = '/opt/media-downloader/venv/bin'

_TUBE_SITE_DOMAINS = {
    'xhamster.com', 'xhamster.one', 'xhamster.desi', 'xhamster2.com', 'xhamster3.com',
    'xvideos.com', 'xvideos2.com', 'xvideos.es',
    'motherless.com',
}


def _is_tube_site_url(url):
    """Quick check if URL is from a supported tube/media site."""
    from urllib.parse import urlparse
    domain = urlparse(url).netloc.lower().replace('www.', '')
    return domain in _TUBE_SITE_DOMAINS


def _resolve_tube_site_urls(url):
    """Resolve tube site URL into media URLs using gallery-dl/yt-dlp."""
    import subprocess, json

    try:
        # gallery-dl -j returns JSON with [type, url_or_info, metadata]
        result = subprocess.run(
            [f'{_VENV_BIN}/gallery-dl', '-j', url],
            capture_output=True, text=True, timeout=120
        )
        if result.returncode == 0 and result.stdout.strip():
            data = json.loads(result.stdout)
            media_urls = []
            title = ''
            for entry in data:
                if isinstance(entry, list) and len(entry) >= 3 and entry[0] == 3:
                    media_urls.append(entry[1])  # direct URL
                    if not title:
                        title = entry[2].get('title', '') or entry[2].get('album', '')
                elif isinstance(entry, list) and len(entry) >= 2 and entry[0] == 2:
                    if not title and isinstance(entry[1], dict):
                        title = entry[1].get('title', '') or entry[1].get('album', '')
            if media_urls:
                is_video = any(
                    entry[2].get('extension', '') in ('mp4', 'webm', 'mkv')
                    or entry[2].get('type', '') == 'video'
                    for entry in data
                    if isinstance(entry, list) and len(entry) >= 3 and entry[0] == 3
                )
                return {'urls': media_urls, 'title': title, 'use_ytdlp': is_video, 'original_url': url}
    except Exception as e:
        logger.debug(f"gallery-dl resolve failed for {url}: {e}")

    # Fallback: yt-dlp for video metadata
    try:
        result = subprocess.run(
            [f'{_VENV_BIN}/yt-dlp', '--dump-json', '--no-download', '--no-warnings', url],
            capture_output=True, text=True, timeout=60
        )
        if result.returncode == 0 and result.stdout.strip():
            info = json.loads(result.stdout.strip().split('\n')[0])
            title = info.get('title', '')
            return {'urls': [url], 'title': title, 'use_ytdlp': True, 'original_url': url}
    except Exception as e:
        logger.debug(f"yt-dlp resolve failed for {url}: {e}")

    return None


def _import_urls_to_gallery_background(job_id, urls, post_id, person_id, tag_id_list, final_date, description):
    """Background task to download URLs and import them into the private gallery."""
    import requests as req_lib
    from modules.paid_content.file_host_downloader import FileHostDownloader

    db = _get_db()
    crypto = _get_crypto()
    config = _get_config(db)

    storage_path = Path(config.get('storage_path', '/opt/immich/private'))
    data_path = storage_path / 'data'
    thumbs_path = storage_path / 'thumbs'
    data_path.mkdir(parents=True, exist_ok=True)
    thumbs_path.mkdir(parents=True, exist_ok=True)

    downloader = FileHostDownloader()
    results = []
    imported_media_ids = []
    success_count = 0
    failed_count = 0
    duplicate_count = 0
    skipped_count = 0

    # Deduplicate URLs in the input list
    seen_urls = set()
    unique_urls = []
    for u in urls:
        if u not in seen_urls:
            seen_urls.add(u)
            unique_urls.append(u)
    urls = unique_urls

    # Resolve page URLs (forum threads, erome albums) into individual media URLs
    resolved_urls = []
    url_post_ids = {}  # maps resolved URL -> post_id for album/thread-specific posts
    url_referers = {}  # maps resolved URL -> Referer header needed for download
    has_resolvable = any(_is_forum_thread_url(u) or _is_erome_album_url(u) or _is_tube_site_url(u) for u in urls)
    if has_resolvable:
        _update_pg_job(job_id, {'current_phase': 'resolving'})
    for u in urls:
        # Try each resolver in order
        page_result = _resolve_forum_thread_urls(u)
        referer = None
        if not page_result:
            page_result = _resolve_erome_album_urls(u)
            if page_result:
                referer = 'https://www.erome.com/'
        if not page_result and _is_tube_site_url(u):
            page_result = _resolve_tube_site_urls(u)
            if page_result and page_result.get('use_ytdlp'):
                # Mark ytdlp URLs for special download handling
                for media_url in page_result['urls']:
                    url_referers[media_url] = '__ytdlp__'

        if page_result:
            # Create a separate post for this album/thread
            album_title = page_result['title']
            album_post_id = None
            try:
                with db.get_connection(for_write=True) as conn:
                    cursor = conn.cursor()
                    cursor.execute('''
                        INSERT INTO private_media_posts (
                            person_id, encrypted_description, encrypted_media_date
                        ) VALUES (?, ?, ?)
                    ''', (
                        person_id,
                        crypto.encrypt_field(album_title) if album_title else None,
                        crypto.encrypt_field(final_date)
                    ))
                    album_post_id = cursor.lastrowid
                    for tag_id in tag_id_list:
                        cursor.execute('''
                            INSERT OR IGNORE INTO private_media_post_tags (post_id, tag_id)
                            VALUES (?, ?)
                        ''', (album_post_id, tag_id))
                    conn.commit()
            except Exception as e:
                logger.error(f"Failed to create post for '{album_title}': {e}")
                album_post_id = post_id  # fall back to original post

            for media_url in page_result['urls']:
                url_post_ids[media_url] = album_post_id
                if referer:
                    url_referers[media_url] = referer
                resolved_urls.append(media_url)
        else:
            resolved_urls.append(u)
    urls = resolved_urls

    # If ALL urls came from threads, delete the original (now-unused) post
    non_thread_urls = [u for u in urls if u not in url_post_ids]
    if url_post_ids and not non_thread_urls and post_id:
        try:
            with db.get_connection(for_write=True) as conn:
                cursor = conn.cursor()
                cursor.execute('DELETE FROM private_media_post_tags WHERE post_id = ?', (post_id,))
                cursor.execute('DELETE FROM private_media_posts WHERE id = ?', (post_id,))
                conn.commit()
            post_id = None
        except Exception:
            pass

    # Update job total and resolved filenames so frontend can rebuild items
    resolved_filenames = [u.rstrip('/').split('/')[-1].split('?')[0] or f'url-{i+1}' for i, u in enumerate(urls)]
    _update_pg_job(job_id, {
        'total_files': len(urls),
        'resolved_filenames': resolved_filenames,
        'current_phase': 'downloading'
    })

    # Build set of already-imported filenames for duplicate detection
    # Only count as duplicate if the encrypted data file actually exists on disk
    existing_filenames = set()
    try:
        with db.get_connection() as conn:
            cursor = conn.cursor()
            cursor.execute('SELECT storage_id, encrypted_filename FROM private_media WHERE encrypted_filename IS NOT NULL')
            for row in cursor.fetchall():
                try:
                    decrypted_name = crypto.decrypt_field(row['encrypted_filename'])
                    if decrypted_name:
                        enc_file = data_path / f"{row['storage_id']}.enc"
                        if enc_file.exists():
                            existing_filenames.add(decrypted_name)
                except Exception:
                    pass
    except Exception:
        pass

    for idx, url in enumerate(urls):
        url_basename = url.rstrip('/').split('/')[-1].split('?')[0] or f'url-{idx + 1}'

        _update_pg_job(job_id, {
            'current_file': url_basename,
            'current_phase': 'downloading',
            'bytes_downloaded': 0,
            'bytes_total': 0,
            'processed_files': idx
        })

        # Check for duplicate filename
        if url_basename in existing_filenames:
            duplicate_count += 1
            results.append({
                'filename': url_basename,
                'status': 'duplicate',
                'reason': 'File already exists'
            })
            logger.info(f"Skipping duplicate file: {url_basename}")
            continue

        temp_dir = Path(tempfile.mkdtemp(prefix='pg_import_'))
        downloaded_files = []

        try:
            if url_referers.get(url) == '__ytdlp__':
                # Download via yt-dlp (handles HLS/DASH streams, format merging)
                import subprocess
                _update_pg_job(job_id, {'current_phase': 'downloading', 'current_file': url_basename})
                yt_result = subprocess.run(
                    [f'{_VENV_BIN}/yt-dlp', '-o', str(temp_dir / '%(title).100B.%(ext)s'),
                     '--no-playlist', '--no-warnings', '--no-progress', url],
                    capture_output=True, text=True, timeout=600
                )
                if yt_result.returncode != 0:
                    raise Exception(f'yt-dlp failed: {yt_result.stderr[:200]}')
                downloaded_files = [f for f in temp_dir.iterdir() if f.is_file() and not f.name.startswith('.')]
                if not downloaded_files:
                    raise Exception('yt-dlp produced no output files')

            # Try file host downloader
            elif (host := downloader.detect_host(url)):
                loop = asyncio.new_event_loop()
                try:
                    result = loop.run_until_complete(downloader.download_url(url, temp_dir))
                finally:
                    loop.close()
                if result.get('success') and result.get('files'):
                    downloaded_files = [Path(f) for f in result['files']]
                else:
                    raise Exception(result.get('error', 'Download failed'))
            else:
                # Direct download - check for domain-specific auth
                import_auth = _get_import_auth_for_url(db, crypto, url)

                base_headers = {
                    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
                }
                if url in url_referers:
                    base_headers['Referer'] = url_referers[url]
                base_kwargs = {}

                if import_auth:
                    if import_auth.get('user_agent'):
                        base_headers['User-Agent'] = import_auth['user_agent']
                    if import_auth.get('username') and import_auth.get('password'):
                        base_kwargs['auth'] = (import_auth['username'], import_auth['password'])
                    if import_auth.get('cookies'):
                        base_kwargs['cookies'] = {c['name']: c['value'] for c in import_auth['cookies']}

                # HEAD request to get file size and check range support
                cf_fallback = False
                try:
                    head_resp = req_lib.head(url, headers=base_headers, timeout=30,
                                             allow_redirects=True, **base_kwargs)
                    head_resp.raise_for_status()
                except Exception as head_err:
                    status_code = getattr(getattr(head_err, 'response', None), 'status_code', 0)
                    if status_code == 403 or '403' in str(head_err):
                        # Cloudflare-protected - fall back to curl_cffi
                        logger.info(f"HEAD request got 403, falling back to curl_cffi for {url}")
                        cf_fallback = True
                    else:
                        raise

                if cf_fallback:
                    from curl_cffi import requests as cf_requests
                    cf_resp = cf_requests.get(url, impersonate='chrome', timeout=120)
                    cf_resp.raise_for_status()
                    ct = cf_resp.headers.get('Content-Type', '').lower()
                    if not (ct.startswith('image/') or ct.startswith('video/') or ct == 'application/octet-stream'):
                        raise Exception(f'Unsupported content type: {ct}')
                    filename = url_basename
                    save_path = temp_dir / filename
                    save_path.write_bytes(cf_resp.content)
                    downloaded_files = [save_path]
                    _update_pg_job(job_id, {
                        'current_phase': 'processing',
                        'bytes_downloaded': len(cf_resp.content),
                        'bytes_total': len(cf_resp.content)
                    })
                else:
                    content_type = head_resp.headers.get('Content-Type', '').lower()
                    if not (content_type.startswith('image/') or content_type.startswith('video/') or content_type == 'application/octet-stream'):
                        raise Exception(f'Unsupported content type: {content_type}')

                    # Determine filename from URL or content-disposition
                    filename = url_basename
                    cd = head_resp.headers.get('Content-Disposition', '')
                    if 'filename=' in cd:
                        fn_match = re.search(r'filename="?([^";\n]+)"?', cd)
                        if fn_match:
                            filename = fn_match.group(1).strip()

                    bytes_total = int(head_resp.headers.get('Content-Length', 0) or 0)
                    accepts_ranges = head_resp.headers.get('Accept-Ranges', '').lower() == 'bytes'
                    save_path = temp_dir / filename

                    if bytes_total:
                        _update_pg_job(job_id, {'bytes_total': bytes_total})

                    NUM_THREADS = 5
                    MAX_RETRIES = 3
                    STALL_TIMEOUT = 30  # seconds with no data = stalled

                    if accepts_ranges and bytes_total > 0:
                        # Multi-threaded segmented download with stall detection and retry
                        import threading

                        segment_size = bytes_total // NUM_THREADS
                        segments = []
                        for i in range(NUM_THREADS):
                            start = i * segment_size
                            end = bytes_total - 1 if i == NUM_THREADS - 1 else (i + 1) * segment_size - 1
                            segments.append((start, end))

                        segment_files = [temp_dir / f".part{i}" for i in range(NUM_THREADS)]
                        segment_progress = [0] * NUM_THREADS
                        segment_errors = [None] * NUM_THREADS
                        progress_lock = threading.Lock()

                        def download_segment(seg_idx, byte_start, byte_end, out_path):
                            expected_size = byte_end - byte_start + 1
                            bytes_written = 0
                            for attempt in range(MAX_RETRIES):
                                try:
                                    resume_start = byte_start + bytes_written
                                    if resume_start > byte_end:
                                        break
                                    seg_headers = {**base_headers, 'Range': f'bytes={resume_start}-{byte_end}'}
                                    r = req_lib.get(url, headers=seg_headers, stream=True,
                                                   timeout=(30, STALL_TIMEOUT), **base_kwargs)
                                    r.raise_for_status()

                                    # Verify server honored the Range request
                                    if r.status_code != 206:
                                        raise Exception(f"Server returned {r.status_code} instead of 206 for range request")

                                    mode = 'ab' if bytes_written > 0 else 'wb'
                                    with open(out_path, mode) as f:
                                        for chunk in r.iter_content(chunk_size=65536):
                                            f.write(chunk)
                                            bytes_written += len(chunk)
                                            with progress_lock:
                                                segment_progress[seg_idx] = bytes_written

                                    # Verify segment received the expected number of bytes
                                    if bytes_written != expected_size:
                                        raise Exception(f"Segment size mismatch: expected {expected_size} bytes, got {bytes_written}")

                                    break  # Success
                                except Exception as e:
                                    if attempt < MAX_RETRIES - 1:
                                        logger.warning(f"Segment {seg_idx} stalled/failed (attempt {attempt+1}), retrying from byte {byte_start + bytes_written}: {e}")
                                        time.sleep(2 ** attempt)
                                    else:
                                        segment_errors[seg_idx] = e

                        threads = []
                        for i, (start, end) in enumerate(segments):
                            t = threading.Thread(target=download_segment,
                                               args=(i, start, end, segment_files[i]))
                            t.start()
                            threads.append(t)

                        # Monitor progress while threads run
                        while any(t.is_alive() for t in threads):
                            time.sleep(0.3)
                            with progress_lock:
                                total_downloaded = sum(segment_progress)
                            _update_pg_job(job_id, {'bytes_downloaded': total_downloaded})

                        for t in threads:
                            t.join()

                        # Check for errors
                        for i, err in enumerate(segment_errors):
                            if err:
                                raise Exception(f'Segment {i} failed after {MAX_RETRIES} attempts: {err}')

                        # Combine segments into final file
                        with open(save_path, 'wb') as fout:
                            for seg_file in segment_files:
                                with open(seg_file, 'rb') as fin:
                                    while True:
                                        chunk = fin.read(1024 * 1024)
                                        if not chunk:
                                            break
                                        fout.write(chunk)
                                seg_file.unlink()

                        # Verify combined file size matches expected total
                        actual_size = save_path.stat().st_size
                        if actual_size != bytes_total:
                            save_path.unlink(missing_ok=True)
                            raise Exception(f'Downloaded file size mismatch: expected {bytes_total} bytes, got {actual_size}')

                        _update_pg_job(job_id, {'bytes_downloaded': bytes_total})
                    else:
                        # Single-threaded download with stall detection and retry
                        bytes_downloaded = 0
                        last_progress_update = 0
                        for attempt in range(MAX_RETRIES):
                            try:
                                dl_headers = {**base_headers}
                                if bytes_downloaded > 0 and accepts_ranges:
                                    dl_headers['Range'] = f'bytes={bytes_downloaded}-'
                                resp = req_lib.get(url, headers=dl_headers, stream=True,
                                                  timeout=(30, STALL_TIMEOUT), **base_kwargs)
                                resp.raise_for_status()

                                # On resume, verify server honored the Range request
                                if bytes_downloaded > 0 and accepts_ranges and resp.status_code != 206:
                                    raise Exception(f"Server returned {resp.status_code} instead of 206 for range resume")

                                mode = 'ab' if bytes_downloaded > 0 else 'wb'
                                with open(save_path, mode) as f:
                                    for chunk in resp.iter_content(chunk_size=65536):
                                        f.write(chunk)
                                        bytes_downloaded += len(chunk)
                                        if bytes_downloaded - last_progress_update >= 262144:
                                            _update_pg_job(job_id, {'bytes_downloaded': bytes_downloaded})
                                            last_progress_update = bytes_downloaded
                                break  # Success
                            except Exception as e:
                                if attempt < MAX_RETRIES - 1:
                                    logger.warning(f"Download stalled/failed (attempt {attempt+1}), retrying from byte {bytes_downloaded}: {e}")
                                    time.sleep(2 ** attempt)
                                else:
                                    raise

                        # Verify final file size if Content-Length was known
                        if bytes_total > 0:
                            actual_size = save_path.stat().st_size
                            if actual_size != bytes_total:
                                save_path.unlink(missing_ok=True)
                                raise Exception(f'Downloaded file size mismatch: expected {bytes_total} bytes, got {actual_size}')

                        _update_pg_job(job_id, {'bytes_downloaded': bytes_downloaded})

                    downloaded_files = [save_path]

            # Update phase to processing
            _update_pg_job(job_id, {'current_phase': 'processing'})

            # Process each downloaded file
            for dl_file in downloaded_files:
                dl_filename = dl_file.name

                try:
                    file_hash = _get_file_hash(dl_file)

                    # Duplicate check (scoped by person)
                    with db.get_connection() as conn:
                        cursor = conn.cursor()
                        if person_id:
                            cursor.execute('SELECT id, person_id FROM private_media WHERE file_hash = ? AND person_id = ?', (file_hash, person_id))
                        else:
                            cursor.execute('SELECT id, person_id FROM private_media WHERE file_hash = ?', (file_hash,))
                        existing = cursor.fetchone()

                    if existing:
                        duplicate_count += 1
                        results.append({
                            'filename': dl_filename,
                            'status': 'duplicate',
                            'existing_id': existing['id']
                        })
                        continue

                    file_info = _get_file_info(dl_file)
                    file_size = dl_file.stat().st_size

                    # Skip low-resolution images
                    min_res = int(config.get('min_import_resolution', 0) or 0)
                    if min_res > 0 and file_info['file_type'] == 'image':
                        w = file_info.get('width') or 0
                        h = file_info.get('height') or 0
                        if w < min_res or h < min_res:
                            skipped_count += 1
                            results.append({
                                'filename': dl_filename,
                                'status': 'skipped',
                                'reason': f'Low resolution ({w}x{h}, min {min_res}px)'
                            })
                            continue

                    # Compute perceptual hash before encryption
                    perceptual_hash = _compute_perceptual_hash(dl_file)

                    # Extract date (EXIF first, then filename, then fallback)
                    item_date = _extract_date_from_exif(dl_file)
                    if not item_date:
                        item_date = _extract_date_from_filename(dl_filename)
                    if not item_date:
                        item_date = final_date

                    storage_id = str(uuid.uuid4())

                    # Generate thumbnail
                    _update_pg_job(job_id, {'current_phase': 'thumbnail'})
                    temp_thumb = temp_dir / f"pg_thumb_{storage_id}.jpg"
                    _generate_thumbnail(dl_file, temp_thumb, file_info['file_type'])

                    # Encrypt original
                    _update_pg_job(job_id, {'current_phase': 'encrypting'})
                    encrypted_file = data_path / f"{storage_id}.enc"
                    if not crypto.encrypt_file(dl_file, encrypted_file):
                        raise Exception(f"Encryption failed for {dl_filename}")

                    # Encrypt thumbnail
                    if temp_thumb.exists():
                        encrypted_thumb = thumbs_path / f"{storage_id}.enc"
                        crypto.encrypt_file(temp_thumb, encrypted_thumb)

                    # Insert into database
                    item_post_id = url_post_ids.get(url, post_id)
                    with db.get_connection(for_write=True) as conn:
                        cursor = conn.cursor()
                        cursor.execute('''
                            INSERT INTO private_media (
                                post_id, storage_id, encrypted_filename, encrypted_description,
                                file_hash, file_size, file_type, mime_type,
                                width, height, duration, person_id,
                                encrypted_media_date, source_type, encrypted_source_path,
                                perceptual_hash
                            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                        ''', (
                            item_post_id,
                            storage_id,
                            crypto.encrypt_field(dl_filename),
                            None,
                            file_hash,
                            file_size,
                            file_info['file_type'],
                            file_info['mime_type'],
                            file_info['width'],
                            file_info['height'],
                            file_info['duration'],
                            person_id,
                            crypto.encrypt_field(item_date),
                            'import',
                            crypto.encrypt_field(url),
                            perceptual_hash
                        ))
                        media_id = cursor.lastrowid
                        imported_media_ids.append(media_id)

                        for tag_id in tag_id_list:
                            cursor.execute('''
                                INSERT OR IGNORE INTO private_media_tags (media_id, tag_id)
                                VALUES (?, ?)
                            ''', (media_id, tag_id))

                        conn.commit()

                    success_count += 1
                    existing_filenames.add(dl_filename)
                    results.append({
                        'id': media_id,
                        'filename': dl_filename,
                        'status': 'created',
                        'media_date': item_date
                    })

                except Exception as e:
                    logger.error(f"Import processing failed for {dl_filename}: {e}")
                    failed_count += 1
                    results.append({
                        'filename': dl_filename,
                        'status': 'failed',
                        'error': str(e)
                    })

        except Exception as e:
            logger.error(f"Import download failed for {url}: {e}")
            failed_count += 1
            results.append({
                'filename': url_basename,
                'status': 'failed',
                'error': str(e)
            })

        finally:
            # Clean up temp dir
            shutil.rmtree(temp_dir, ignore_errors=True)

        _update_pg_job(job_id, {
            'results': list(results),
            'success_count': success_count,
            'failed_count': failed_count,
            'duplicate_count': duplicate_count,
            'skipped_count': skipped_count,
            'processed_files': idx + 1
        })

    # Delete empty posts (original + any thread posts with no successful imports)
    all_post_ids = set()
    if post_id:
        all_post_ids.add(post_id)
    all_post_ids.update(url_post_ids.values())
    used_post_ids = set()
    if imported_media_ids:
        try:
            with db.get_connection() as conn:
                cursor = conn.cursor()
                placeholders = ','.join('?' for _ in imported_media_ids)
                cursor.execute(f'SELECT DISTINCT post_id FROM private_media WHERE id IN ({placeholders})', imported_media_ids)
                used_post_ids = {row['post_id'] for row in cursor.fetchall() if row['post_id']}
        except Exception:
            pass
    empty_post_ids = all_post_ids - used_post_ids
    if empty_post_ids:
        with db.get_connection(for_write=True) as conn:
            cursor = conn.cursor()
            for pid in empty_post_ids:
                cursor.execute('DELETE FROM private_media_post_tags WHERE post_id = ?', (pid,))
                cursor.execute('DELETE FROM private_media_posts WHERE id = ?', (pid,))
            conn.commit()

    _invalidate_posts_cache()
    _update_pg_job(job_id, {
        'status': 'completed',
        'completed_at': datetime.now().isoformat(),
        'current_file': None,
        'post_id': post_id if imported_media_ids else None
    })


@router.post("/import-urls")
@limiter.limit("30/minute")
@handle_exceptions
async def import_urls(
    request: Request,
    body: ImportUrlRequest,
    background_tasks: BackgroundTasks,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Import media from URLs (file hosts or direct links)."""
    if not body.urls:
        raise ValidationError("At least one URL is required")

    db = _get_db()
    crypto = _get_crypto()

    # Determine the post-level date
    post_date = body.media_date or date.today().isoformat()

    # Create a post to group imported media
    post_id = None
    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        cursor.execute('''
            INSERT INTO private_media_posts (
                person_id, encrypted_description, encrypted_media_date
            ) VALUES (?, ?, ?)
        ''', (
            body.person_id,
            crypto.encrypt_field(body.description) if body.description else None,
            crypto.encrypt_field(post_date)
        ))
        post_id = cursor.lastrowid

        for tag_id in body.tag_ids:
            cursor.execute('''
                INSERT OR IGNORE INTO private_media_post_tags (post_id, tag_id)
                VALUES (?, ?)
            ''', (post_id, tag_id))

        conn.commit()

    # Create job and launch background task
    job_id = f"pg_import_{uuid.uuid4().hex[:12]}"
    _create_pg_job(job_id, len(body.urls), 'import')

    background_tasks.add_task(
        _import_urls_to_gallery_background,
        job_id,
        body.urls,
        post_id,
        body.person_id,
        body.tag_ids,
        post_date,
        body.description
    )

    return {
        "job_id": job_id,
        "post_id": post_id,
        "status": "processing",
        "total_files": len(body.urls)
    }


def _copy_to_gallery_background(job_id, source_paths, post_id, person_id, tag_ids, media_date, original_filenames, post_date):
    """Background task to copy files to the private gallery."""
    db = _get_db()
    crypto = _get_crypto()
    config = _get_config(db)

    storage_path = Path(config.get('storage_path', '/opt/immich/private'))
    data_path = storage_path / 'data'
    thumbs_path = storage_path / 'thumbs'
    data_path.mkdir(parents=True, exist_ok=True)
    thumbs_path.mkdir(parents=True, exist_ok=True)

    results = []
    copied_media_ids = []
    success_count = 0
    failed_count = 0
    duplicate_count = 0
    skipped_count = 0

    for idx, source_path_str in enumerate(source_paths):
        source_path = Path(source_path_str)
        filename = original_filenames.get(source_path_str, source_path.name) if original_filenames else source_path.name

        _update_pg_job(job_id, {
            'current_file': filename,
            'processed_files': idx
        })

        if not source_path.exists():
            failed_count += 1
            results.append({
                'path': source_path_str,
                'filename': filename,
                'status': 'failed',
                'error': 'File not found'
            })
            _update_pg_job(job_id, {
                'results': list(results),
                'failed_count': failed_count,
                'processed_files': idx + 1
            })
            continue

        try:
            # Calculate hash for duplicate detection
            file_hash = _get_file_hash(source_path)

            # Check for duplicates (scoped by person)
            with db.get_connection() as conn:
                cursor = conn.cursor()
                if person_id:
                    cursor.execute('SELECT id, person_id FROM private_media WHERE file_hash = ? AND person_id = ?', (file_hash, person_id))
                else:
                    cursor.execute('SELECT id, person_id FROM private_media WHERE file_hash = ?', (file_hash,))
                existing = cursor.fetchone()

            if existing:
                duplicate_count += 1
                results.append({
                    'path': source_path_str,
                    'filename': filename,
                    'status': 'duplicate',
                    'existing_id': existing['id']
                })
                _update_pg_job(job_id, {
                    'results': list(results),
                    'duplicate_count': duplicate_count,
                    'processed_files': idx + 1
                })
                continue

            # Get file info
            file_info = _get_file_info(source_path)
            file_size = source_path.stat().st_size

            # Skip low-resolution images
            min_res = int(config.get('min_import_resolution', 0) or 0)
            if min_res > 0 and file_info['file_type'] == 'image':
                w = file_info.get('width') or 0
                h = file_info.get('height') or 0
                if w < min_res or h < min_res:
                    skipped_count += 1
                    results.append({
                        'path': source_path_str,
                        'filename': filename,
                        'status': 'skipped',
                        'reason': f'Low resolution ({w}x{h}, min {min_res}px)'
                    })
                    _update_pg_job(job_id, {
                        'results': list(results),
                        'skipped_count': skipped_count,
                        'processed_files': idx + 1
                    })
                    continue

            # Compute perceptual hash before encryption
            perceptual_hash = _compute_perceptual_hash(source_path)

            # Determine media date per file
            final_date = media_date
            if not final_date:
                final_date = _extract_date_from_exif(source_path)
            if not final_date:
                final_date = _extract_date_from_filename(source_path.name)
            if not final_date:
                try:
                    from datetime import datetime as dt
                    mtime = source_path.stat().st_mtime
                    final_date = dt.fromtimestamp(mtime).strftime('%Y-%m-%dT%H:%M:%S')
                except Exception:
                    pass
            if not final_date:
                final_date = post_date

            # Generate storage ID
            storage_id = str(uuid.uuid4())

            # Generate thumbnail first
            temp_dir = Path(tempfile.gettempdir())
            temp_thumb = temp_dir / f"pg_thumb_{storage_id}.jpg"
            _generate_thumbnail(source_path, temp_thumb, file_info['file_type'])

            # Encrypt the original file
            encrypted_file = data_path / f"{storage_id}.enc"
            if not crypto.encrypt_file(source_path, encrypted_file):
                raise Exception("Encryption failed")

            # Encrypt thumbnail if it exists
            if temp_thumb.exists():
                encrypted_thumb = thumbs_path / f"{storage_id}.enc"
                crypto.encrypt_file(temp_thumb, encrypted_thumb)
                temp_thumb.unlink()

            # Insert into database with post_id reference
            with db.get_connection(for_write=True) as conn:
                cursor = conn.cursor()
                cursor.execute('''
                    INSERT INTO private_media (
                        post_id, storage_id, encrypted_filename, encrypted_description,
                        file_hash, file_size, file_type, mime_type,
                        width, height, duration, person_id,
                        encrypted_media_date, source_type, encrypted_source_path,
                        perceptual_hash
                    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                ''', (
                    post_id,
                    storage_id,
                    crypto.encrypt_field(filename),
                    None,
                    file_hash,
                    file_size,
                    file_info['file_type'],
                    file_info['mime_type'],
                    file_info['width'],
                    file_info['height'],
                    file_info['duration'],
                    person_id,
                    crypto.encrypt_field(final_date),
                    'copy',
                    crypto.encrypt_field(source_path_str),
                    perceptual_hash
                ))
                media_id = cursor.lastrowid
                copied_media_ids.append(media_id)

                for tag_id in tag_ids:
                    cursor.execute('''
                        INSERT OR IGNORE INTO private_media_tags (media_id, tag_id)
                        VALUES (?, ?)
                    ''', (media_id, tag_id))

                conn.commit()

            success_count += 1
            results.append({
                'id': media_id,
                'path': source_path_str,
                'filename': filename,
                'status': 'created',
                'media_date': final_date
            })

        except Exception as e:
            logger.error(f"Copy failed for {source_path_str}: {e}")
            failed_count += 1
            results.append({
                'path': source_path_str,
                'filename': filename,
                'status': 'failed',
                'error': str(e)
            })

        _update_pg_job(job_id, {
            'results': list(results),
            'success_count': success_count,
            'failed_count': failed_count,
            'duplicate_count': duplicate_count,
            'skipped_count': skipped_count,
            'processed_files': idx + 1
        })

    # If no files were successfully copied, delete the empty post
    if not copied_media_ids and post_id:
        with db.get_connection(for_write=True) as conn:
            cursor = conn.cursor()
            cursor.execute('DELETE FROM private_media_post_tags WHERE post_id = ?', (post_id,))
            cursor.execute('DELETE FROM private_media_posts WHERE id = ?', (post_id,))
            conn.commit()

    _invalidate_posts_cache()
    _update_pg_job(job_id, {
        'status': 'completed',
        'completed_at': datetime.now().isoformat(),
        'current_file': None,
        'post_id': post_id if copied_media_ids else None
    })


@router.post("/copy")
@limiter.limit("30/minute")
@handle_exceptions
async def copy_to_gallery(
    request: Request,
    body: CopyRequest,
    background_tasks: BackgroundTasks,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Copy files from other locations to the gallery."""
    db = _get_db()
    crypto = _get_crypto()

    # Determine the post-level date
    post_date = body.media_date or date.today().isoformat()

    # Create a post to group copied media
    post_id = None
    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        cursor.execute('''
            INSERT INTO private_media_posts (
                person_id, encrypted_description, encrypted_media_date
            ) VALUES (?, ?, ?)
        ''', (
            body.person_id,
            crypto.encrypt_field(body.description) if body.description else None,
            crypto.encrypt_field(post_date)
        ))
        post_id = cursor.lastrowid

        for tag_id in body.tag_ids:
            cursor.execute('''
                INSERT OR IGNORE INTO private_media_post_tags (post_id, tag_id)
                VALUES (?, ?)
            ''', (post_id, tag_id))

        conn.commit()

    # Create job and launch background task
    job_id = f"pg_copy_{uuid.uuid4().hex[:12]}"
    _create_pg_job(job_id, len(body.source_paths), 'copy')

    background_tasks.add_task(
        _copy_to_gallery_background,
        job_id,
        body.source_paths,
        post_id,
        body.person_id,
        body.tag_ids,
        body.media_date,
        body.original_filenames or {},
        post_date
    )

    return {
        "job_id": job_id,
        "post_id": post_id,
        "status": "processing",
        "total_files": len(body.source_paths)
    }


@router.post("/import-directory")
@limiter.limit("30/minute")
@handle_exceptions
async def import_directory(
    request: Request,
    body: ImportDirectoryRequest,
    background_tasks: BackgroundTasks,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Import all media files from a server directory into the gallery."""
    dir_path = Path(body.directory_path)

    if not dir_path.exists():
        raise HTTPException(status_code=400, detail=f"Directory not found: {body.directory_path}")
    if not dir_path.is_dir():
        raise HTTPException(status_code=400, detail=f"Path is not a directory: {body.directory_path}")

    # Collect media files
    media_extensions = {
        '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.avif',
        '.mkv', '.avi', '.wmv', '.flv', '.webm', '.mov', '.mp4', '.m4v', '.ts'
    }

    source_paths = []
    if body.recursive:
        for root, _dirs, filenames in os.walk(dir_path):
            for fname in sorted(filenames):
                if Path(fname).suffix.lower() in media_extensions:
                    source_paths.append(str(Path(root) / fname))
    else:
        for entry in sorted(dir_path.iterdir()):
            if entry.is_file() and entry.suffix.lower() in media_extensions:
                source_paths.append(str(entry))

    if not source_paths:
        raise HTTPException(status_code=400, detail="No media files found in the specified directory")

    db = _get_db()
    crypto = _get_crypto()

    # Determine the post-level date
    post_date = body.media_date or date.today().isoformat()

    # Create a post to group imported media
    post_id = None
    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        cursor.execute('''
            INSERT INTO private_media_posts (
                person_id, encrypted_description, encrypted_media_date
            ) VALUES (?, ?, ?)
        ''', (
            body.person_id,
            crypto.encrypt_field(body.description) if body.description else None,
            crypto.encrypt_field(post_date)
        ))
        post_id = cursor.lastrowid

        for tag_id in body.tag_ids:
            cursor.execute('''
                INSERT OR IGNORE INTO private_media_post_tags (post_id, tag_id)
                VALUES (?, ?)
            ''', (post_id, tag_id))

        conn.commit()

    # Create job and launch background task
    job_id = f"pg_import_{uuid.uuid4().hex[:12]}"
    _create_pg_job(job_id, len(source_paths), 'directory')

    background_tasks.add_task(
        _copy_to_gallery_background,
        job_id,
        source_paths,
        post_id,
        body.person_id,
        body.tag_ids,
        body.media_date,
        {},
        post_date
    )

    return {
        "job_id": job_id,
        "post_id": post_id,
        "status": "processing",
        "total_files": len(source_paths)
    }


@router.get("/list-directory")
@limiter.limit("60/minute")
@handle_exceptions
async def list_directory(
    request: Request,
    path: str = Query(..., description="Server directory path to list"),
    recursive: bool = Query(False, description="Recursively scan subdirectories"),
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """List media files in a server directory for preview before import."""
    dir_path = Path(path)

    if not dir_path.exists():
        raise HTTPException(status_code=400, detail=f"Path not found: {path}")
    if not dir_path.is_dir():
        raise HTTPException(status_code=400, detail=f"Path is not a directory: {path}")

    media_extensions = {
        '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.avif',
        '.mkv', '.avi', '.wmv', '.flv', '.webm', '.mov', '.mp4', '.m4v', '.ts'
    }
    video_extensions = {'.mkv', '.avi', '.wmv', '.flv', '.webm', '.mov', '.mp4', '.m4v', '.ts'}

    files = []
    subdirectories = []
    total_size = 0

    try:
        if recursive:
            for root, dirs, filenames in os.walk(dir_path):
                rel_root = Path(root).relative_to(dir_path)
                if root == str(dir_path):
                    subdirectories.extend(sorted(dirs))
                for fname in sorted(filenames):
                    fpath = Path(root) / fname
                    if fpath.suffix.lower() in media_extensions:
                        size = fpath.stat().st_size
                        total_size += size
                        display_name = str(rel_root / fname) if str(rel_root) != '.' else fname
                        files.append({
                            'name': display_name,
                            'size': size,
                            'type': 'video' if fpath.suffix.lower() in video_extensions else 'image'
                        })
        else:
            for entry in sorted(dir_path.iterdir()):
                if entry.is_dir():
                    subdirectories.append(entry.name)
                elif entry.is_file() and entry.suffix.lower() in media_extensions:
                    size = entry.stat().st_size
                    total_size += size
                    files.append({
                        'name': entry.name,
                        'size': size,
                        'type': 'video' if entry.suffix.lower() in video_extensions else 'image'
                    })
    except PermissionError:
        raise HTTPException(status_code=403, detail=f"Permission denied: {path}")

    return {
        "path": path,
        "files": files,
        "subdirectories": subdirectories,
        "file_count": len(files),
        "total_size": total_size
    }


@router.post("/regenerate-thumbnails")
@limiter.limit("5/minute")
@handle_exceptions
async def regenerate_thumbnails(
    request: Request,
    missing_only: bool = Query(False, description="Only regenerate missing thumbnails"),
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Regenerate thumbnails. Use missing_only=true to only fix missing ones."""
    db = _get_db()
    crypto = _get_crypto()
    config = _get_config(db)

    storage_path = Path(config.get('storage_path', '/opt/immich/private'))
    data_path = storage_path / 'data'
    thumbs_path = storage_path / 'thumbs'

    with db.get_connection() as conn:
        cursor = conn.cursor()
        if missing_only:
            cursor.execute("SELECT id, storage_id, file_type, mime_type FROM private_media")
        else:
            cursor.execute("SELECT id, storage_id, file_type, mime_type FROM private_media WHERE file_type = 'image'")
        media_rows = cursor.fetchall()

    fixed = 0
    skipped = 0
    errors = []
    temp_dir = Path(tempfile.gettempdir())

    for row in media_rows:
        storage_id = row['storage_id']
        file_type = (row['file_type'] or 'image') if missing_only else 'image'
        encrypted_file = data_path / f"{storage_id}.enc"
        encrypted_thumb = thumbs_path / f"{storage_id}.enc"

        if not encrypted_file.exists():
            skipped += 1
            continue

        if missing_only and encrypted_thumb.exists():
            skipped += 1
            continue

        try:
            # Determine file extension from mime_type
            mime = row['mime_type'] or ''
            ext = mimetypes.guess_extension(mime) or ('.mp4' if file_type == 'video' else '.jpg')
            if ext == '.jpe':
                ext = '.jpg'

            # Decrypt original to temp file (with extension so PIL/ffmpeg can identify it)
            temp_original = temp_dir / f"pg_regen_{storage_id}{ext}"
            crypto.decrypt_file(encrypted_file, temp_original)

            # Generate thumbnail
            temp_thumb = temp_dir / f"pg_thumb_{storage_id}.jpg"
            if _generate_thumbnail(temp_original, temp_thumb, file_type):
                crypto.encrypt_file(temp_thumb, encrypted_thumb)
                fixed += 1
            else:
                errors.append({'id': row['id'], 'error': f'Thumbnail generation returned false ({file_type})'})

            # Clean up temp files
            if temp_original.exists():
                temp_original.unlink()
            if temp_thumb.exists():
                temp_thumb.unlink()

        except Exception as e:
            errors.append({'id': row['id'], 'error': str(e)})
            for f in [temp_dir / f"pg_regen_{storage_id}{ext}", temp_dir / f"pg_thumb_{storage_id}.jpg"]:
                if f.exists():
                    f.unlink()

    # Clear thumbnail cache
    with _thumb_cache_lock:
        _thumb_cache.clear()

    logger.info(f"Thumbnail regeneration complete: {fixed} regenerated, {skipped} skipped, {len(errors)} errors")
    return {
        'total': len(media_rows),
        'regenerated': fixed,
        'skipped': skipped,
        'errors': errors
    }


# ============================================================================
# FILE SERVING ENDPOINTS
# ============================================================================

@router.get("/thumbnail/{media_id}")
@limiter.limit("300/minute")
@handle_exceptions
async def get_thumbnail(
    request: Request,
    media_id: int,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Get decrypted thumbnail with in-memory LRU cache and ETag support."""
    db = _get_db()
    crypto = _get_crypto()
    config = _get_config(db)

    with db.get_connection() as conn:
        cursor = conn.cursor()
        cursor.execute('SELECT storage_id FROM private_media WHERE id = ?', (media_id,))
        row = cursor.fetchone()
        if not row:
            raise NotFoundError(f"Media {media_id} not found")

        storage_id = row['storage_id']

    # Check in-memory cache first (avoids disk I/O + AES decrypt)
    cached = _thumb_cache_get(storage_id)
    if cached:
        etag, decrypted = cached
        # Check If-None-Match for 304
        if_none_match = request.headers.get('if-none-match')
        if if_none_match and if_none_match.strip('" ') == etag:
            return Response(status_code=304, headers={
                "ETag": f'"{etag}"',
                "Cache-Control": "private, max-age=86400, stale-while-revalidate=604800",
            })
        return Response(
            content=decrypted,
            media_type="image/jpeg",
            headers={
                "Cache-Control": "private, max-age=86400, stale-while-revalidate=604800",
                "ETag": f'"{etag}"',
            }
        )

    storage_path = Path(config.get('storage_path', '/opt/immich/private'))
    thumb_file = storage_path / 'thumbs' / f"{storage_id}.enc"

    if not thumb_file.exists():
        raise NotFoundError("Thumbnail not available")

    # Decrypt thumbnail
    decrypted = crypto.decrypt_file_streaming(thumb_file)
    if not decrypted:
        raise NotFoundError("Failed to decrypt thumbnail")

    # Generate ETag from content hash (fast — md5 of ~20KB)
    etag = hashlib.md5(decrypted).hexdigest()

    # Store in LRU cache
    _thumb_cache_put(storage_id, etag, decrypted)

    # Check If-None-Match
    if_none_match = request.headers.get('if-none-match')
    if if_none_match and if_none_match.strip('" ') == etag:
        return Response(status_code=304, headers={
            "ETag": f'"{etag}"',
            "Cache-Control": "private, max-age=86400, stale-while-revalidate=604800",
        })

    return Response(
        content=decrypted,
        media_type="image/jpeg",
        headers={
            "Cache-Control": "private, max-age=86400, stale-while-revalidate=604800",
            "ETag": f'"{etag}"',
        }
    )


@router.get("/file/{media_id}")
@limiter.limit("120/minute")
@handle_exceptions
async def get_file(
    request: Request,
    media_id: int,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Get decrypted full file."""
    db = _get_db()
    crypto = _get_crypto()
    config = _get_config(db)

    with db.get_connection() as conn:
        cursor = conn.cursor()
        cursor.execute('''
            SELECT storage_id, encrypted_filename, mime_type, file_size
            FROM private_media WHERE id = ?
        ''', (media_id,))
        row = cursor.fetchone()
        if not row:
            raise NotFoundError(f"Media {media_id} not found")

        storage_id = row['storage_id']
        filename = crypto.decrypt_field(row['encrypted_filename'])
        mime_type = row['mime_type']
        file_size = row['file_size']

    storage_path = Path(config.get('storage_path', '/opt/immich/private'))
    data_file = storage_path / 'data' / f"{storage_id}.enc"

    if not data_file.exists():
        raise NotFoundError("File not available")

    # Use storage_id as ETag — file content never changes for a given storage_id
    etag = storage_id
    if_none_match = request.headers.get('if-none-match')
    if if_none_match and if_none_match.strip('" ') == etag:
        return Response(status_code=304, headers={
            "ETag": f'"{etag}"',
            "Cache-Control": "private, max-age=86400, stale-while-revalidate=604800",
        })

    # Sanitize filename for Content-Disposition header (remove quotes, non-ASCII)
    from urllib.parse import quote
    safe_filename = filename.replace('"', "'").encode('ascii', 'replace').decode('ascii')
    encoded_filename = quote(filename, safe='')
    headers = {
        "Cache-Control": "private, max-age=86400, stale-while-revalidate=604800",
        "Content-Disposition": f"inline; filename=\"{safe_filename}\"; filename*=UTF-8''{encoded_filename}",
        "ETag": f'"{etag}"',
    }
    if file_size:
        headers["Content-Length"] = str(file_size)

    # Stream file chunks without loading all into memory
    return StreamingResponse(
        crypto.decrypt_file_generator(data_file),
        media_type=mime_type,
        headers=headers
    )


@router.get("/stream/{media_id}")
@limiter.limit("120/minute")
@handle_exceptions
async def stream_video(
    request: Request,
    media_id: int,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Stream decrypted video file."""
    db = _get_db()
    crypto = _get_crypto()
    config = _get_config(db)

    with db.get_connection() as conn:
        cursor = conn.cursor()
        cursor.execute('''
            SELECT storage_id, encrypted_filename, mime_type, file_size
            FROM private_media WHERE id = ?
        ''', (media_id,))
        row = cursor.fetchone()
        if not row:
            raise NotFoundError(f"Media {media_id} not found")

        storage_id = row['storage_id']
        mime_type = row['mime_type']
        file_size = row['file_size']

    storage_path = Path(config.get('storage_path', '/opt/immich/private'))
    data_file = storage_path / 'data' / f"{storage_id}.enc"

    if not data_file.exists():
        raise NotFoundError("File not available")

    total_size = file_size  # Original plaintext size from DB

    # Handle Range requests for video seeking
    range_header = request.headers.get('range')

    if range_header:
        match = re.match(r'bytes=(\d+)-(\d*)', range_header)
        if match:
            start = int(match.group(1))
            end = int(match.group(2)) if match.group(2) else total_size - 1

            # Clamp values
            start = min(start, total_size - 1)
            end = min(end, total_size - 1)
            content_length = end - start + 1

            return StreamingResponse(
                crypto.decrypt_file_range_generator(data_file, start, end),
                status_code=206,
                media_type=mime_type,
                headers={
                    "Content-Range": f"bytes {start}-{end}/{total_size}",
                    "Accept-Ranges": "bytes",
                    "Content-Length": str(content_length),
                    "Cache-Control": "private, max-age=3600"
                }
            )

    # Full file request - stream chunks without loading all into memory
    return StreamingResponse(
        crypto.decrypt_file_generator(data_file),
        media_type=mime_type,
        headers={
            "Accept-Ranges": "bytes",
            "Content-Length": str(total_size),
            "Cache-Control": "private, max-age=3600"
        }
    )


# ============================================================================
# BATCH OPERATIONS
# ============================================================================

@router.put("/media/batch/tags")
@limiter.limit("30/minute")
@handle_exceptions
async def batch_update_tags(
    request: Request,
    body: BatchTagsRequest,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Add or remove tags from multiple items."""
    db = _get_db()

    added = 0
    removed = 0

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()

        for media_id in body.media_ids:
            # Add tags
            if body.add_tag_ids:
                for tag_id in body.add_tag_ids:
                    try:
                        cursor.execute('''
                            INSERT OR IGNORE INTO private_media_tags (media_id, tag_id)
                            VALUES (?, ?)
                        ''', (media_id, tag_id))
                        added += cursor.rowcount
                    except Exception:
                        pass

            # Remove tags
            if body.remove_tag_ids:
                for tag_id in body.remove_tag_ids:
                    cursor.execute('''
                        DELETE FROM private_media_tags
                        WHERE media_id = ? AND tag_id = ?
                    ''', (media_id, tag_id))
                    removed += cursor.rowcount

        conn.commit()

    return {"added": added, "removed": removed}


@router.put("/media/batch/date")
@limiter.limit("30/minute")
@handle_exceptions
async def batch_update_date(
    request: Request,
    body: BatchDateRequest,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Update date for multiple items."""
    db = _get_db()
    crypto = _get_crypto()

    encrypted_date = crypto.encrypt_field(body.new_date)
    updated = 0

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        for media_id in body.media_ids:
            cursor.execute('''
                UPDATE private_media
                SET encrypted_media_date = ?, updated_at = CURRENT_TIMESTAMP
                WHERE id = ?
            ''', (encrypted_date, media_id))
            updated += cursor.rowcount
        conn.commit()

    return {"updated": updated}


@router.put("/media/batch/person")
@limiter.limit("30/minute")
@handle_exceptions
async def batch_update_person(
    request: Request,
    body: BatchPersonRequest,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Update person for multiple items."""
    db = _get_db()

    updated = 0

    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        for media_id in body.media_ids:
            cursor.execute('''
                UPDATE private_media
                SET person_id = ?, updated_at = CURRENT_TIMESTAMP
                WHERE id = ?
            ''', (body.person_id, media_id))
            updated += cursor.rowcount
        conn.commit()

    return {"updated": updated}


# ============================================================================
# EXPORT ENDPOINTS
# ============================================================================

@router.get("/export/{media_id}")
@limiter.limit("60/minute")
@handle_exceptions
async def export_single(
    request: Request,
    media_id: int,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Export a single file (decrypted with original filename)."""
    db = _get_db()
    crypto = _get_crypto()
    config = _get_config(db)

    with db.get_connection() as conn:
        cursor = conn.cursor()
        cursor.execute('''
            SELECT storage_id, encrypted_filename, mime_type
            FROM private_media WHERE id = ?
        ''', (media_id,))
        row = cursor.fetchone()
        if not row:
            raise NotFoundError(f"Media {media_id} not found")

        storage_id = row['storage_id']
        filename = crypto.decrypt_field(row['encrypted_filename'])
        mime_type = row['mime_type']

    storage_path = Path(config.get('storage_path', '/opt/immich/private'))
    data_file = storage_path / 'data' / f"{storage_id}.enc"

    if not data_file.exists():
        raise NotFoundError("File not available")

    # Sanitize filename for Content-Disposition header
    from urllib.parse import quote
    safe_filename = filename.replace('"', "'").encode('ascii', 'replace').decode('ascii')
    encoded_filename = quote(filename, safe='')

    # Stream file chunks without loading all into memory
    return StreamingResponse(
        crypto.decrypt_file_generator(data_file),
        media_type=mime_type,
        headers={
            "Content-Disposition": f"attachment; filename=\"{safe_filename}\"; filename*=UTF-8''{encoded_filename}"
        }
    )


@router.post("/export/batch")
@limiter.limit("10/minute")
@handle_exceptions
async def export_batch(
    request: Request,
    body: ExportBatchRequest,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Export multiple files as a ZIP."""
    db = _get_db()
    crypto = _get_crypto()
    config = _get_config(db)

    storage_path = Path(config.get('storage_path', '/opt/immich/private'))

    # Create ZIP in memory
    zip_buffer = BytesIO()

    with ZipFile(zip_buffer, 'w') as zf:
        with db.get_connection() as conn:
            cursor = conn.cursor()

            for media_id in body.media_ids:
                cursor.execute('''
                    SELECT m.storage_id, m.encrypted_filename, m.encrypted_media_date,
                           p.encrypted_name as person_name
                    FROM private_media m
                    LEFT JOIN private_media_persons p ON m.person_id = p.id
                    WHERE m.id = ?
                ''', (media_id,))
                row = cursor.fetchone()

                if not row:
                    continue

                storage_id = row['storage_id']
                filename = crypto.decrypt_field(row['encrypted_filename'])
                media_date = crypto.decrypt_field(row['encrypted_media_date'])
                person_name = crypto.decrypt_field(row['person_name']) if row['person_name'] else 'Unknown'

                data_file = storage_path / 'data' / f"{storage_id}.enc"
                if not data_file.exists():
                    continue

                decrypted = crypto.decrypt_file_streaming(data_file)
                if not decrypted:
                    continue

                # Build path in ZIP
                path_parts = []
                if body.organize_by_person:
                    path_parts.append(person_name.replace('/', '_'))
                if body.organize_by_date:
                    path_parts.append(media_date)
                path_parts.append(filename)

                zip_path = '/'.join(path_parts)
                zf.writestr(zip_path, decrypted)

    zip_buffer.seek(0)

    return Response(
        content=zip_buffer.getvalue(),
        media_type="application/zip",
        headers={
            "Content-Disposition": f'attachment; filename="private_gallery_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.zip"'
        }
    )


# ============================================================================
# ALBUMS (Auto-generated from persons)
# ============================================================================

@router.get("/albums")
@limiter.limit("60/minute")
@handle_exceptions
async def get_albums(
    request: Request,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Get all person albums with cover image and count."""
    db = _get_db()
    crypto = _get_crypto()

    with db.get_connection() as conn:
        cursor = conn.cursor()

        # Get persons with media counts
        cursor.execute('''
            SELECT p.id, p.encrypted_name, p.relationship_id,
                   r.encrypted_name as rel_encrypted_name, r.color as rel_color,
                   COUNT(m.id) as item_count,
                   (SELECT id FROM private_media WHERE person_id = p.id ORDER BY created_at DESC LIMIT 1) as latest_media_id
            FROM private_media_persons p
            JOIN private_media_relationships r ON p.relationship_id = r.id
            LEFT JOIN private_media m ON m.person_id = p.id
            GROUP BY p.id
            ORDER BY p.encrypted_name
        ''')
        rows = cursor.fetchall()

    albums = []
    for row in rows:
        album = {
            'person_id': row['id'],
            'person_name': crypto.decrypt_field(row['encrypted_name']),
            'relationship': {
                'id': row['relationship_id'],
                'name': crypto.decrypt_field(row['rel_encrypted_name']),
                'color': row['rel_color']
            },
            'item_count': row['item_count'],
            'cover_thumbnail_url': f"/api/private-gallery/thumbnail/{row['latest_media_id']}" if row['latest_media_id'] else None
        }
        albums.append(album)

    # Sort by decrypted name
    albums.sort(key=lambda a: a['person_name'].lower())

    return {"albums": albums}


# ============================================================================
# STATS ENDPOINT
# ============================================================================

@router.post("/reparse-dates")
@limiter.limit("5/minute")
@handle_exceptions
async def reparse_dates(
    request: Request,
    current_user: Dict = Depends(get_current_user),
):
    """Re-extract dates from original filenames for all media items.
    Requires gallery to be unlocked (encryption key in memory) but does not require gallery token."""
    db = _get_db()
    crypto = _get_crypto()

    if not crypto.is_initialized():
        raise AuthError("Gallery must be unlocked to reparse dates")

    updated = 0
    debug_info = []
    with db.get_connection(for_write=True) as conn:
        cursor = conn.cursor()
        cursor.execute('SELECT id, encrypted_filename, encrypted_media_date FROM private_media')
        rows = cursor.fetchall()

        for row in rows:
            filename = crypto.decrypt_field(row['encrypted_filename'])
            old_date = crypto.decrypt_field(row['encrypted_media_date'])

            # Try to extract a better date from filename
            new_date = _extract_date_from_filename(filename)

            # Log first 5 for debugging
            if len(debug_info) < 5:
                debug_info.append({
                    "id": row['id'],
                    "filename": filename,
                    "old_date": old_date,
                    "extracted_date": new_date,
                    "will_update": bool(new_date and new_date != old_date)
                })

            if new_date and new_date != old_date:
                cursor.execute(
                    'UPDATE private_media SET encrypted_media_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
                    (crypto.encrypt_field(new_date), row['id'])
                )
                updated += 1

        conn.commit()

    return {
        "message": f"Updated {updated} media items with re-parsed dates",
        "updated": updated,
        "total": len(rows),
        "debug_samples": debug_info
    }


@router.get("/stats")
@limiter.limit("60/minute")
@handle_exceptions
async def get_stats(
    request: Request,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Get gallery statistics."""
    db = _get_db()
    config = _get_config(db)

    with db.get_connection() as conn:
        cursor = conn.cursor()

        # Total items
        cursor.execute('SELECT COUNT(*) as total FROM private_media')
        total_items = cursor.fetchone()['total']

        # Total size
        cursor.execute('SELECT COALESCE(SUM(file_size), 0) as total FROM private_media')
        total_size = cursor.fetchone()['total']

        # By type
        cursor.execute('''
            SELECT file_type, COUNT(*) as count
            FROM private_media
            GROUP BY file_type
        ''')
        by_type = {row['file_type']: row['count'] for row in cursor.fetchall()}

        # By person
        cursor.execute('''
            SELECT p.id, COUNT(m.id) as count
            FROM private_media_persons p
            LEFT JOIN private_media m ON m.person_id = p.id
            GROUP BY p.id
        ''')
        by_person = {row['id']: row['count'] for row in cursor.fetchall()}

        # Person count
        cursor.execute('SELECT COUNT(*) as total FROM private_media_persons')
        total_persons = cursor.fetchone()['total']

        # Recent items (last 7 days)
        cursor.execute('''
            SELECT COUNT(*) as count FROM private_media
            WHERE created_at >= datetime('now', '-7 days')
        ''')
        recent_7d = cursor.fetchone()['count']

    # Calculate storage usage
    storage_path = Path(config.get('storage_path', '/opt/immich/private'))
    try:
        data_size = sum(f.stat().st_size for f in (storage_path / 'data').glob('*.enc'))
        thumb_size = sum(f.stat().st_size for f in (storage_path / 'thumbs').glob('*.enc'))
        storage_used = data_size + thumb_size
    except Exception:
        storage_used = 0

    return {
        "total_items": total_items,
        "total_size": total_size,
        "total_persons": total_persons,
        "by_type": by_type,
        "by_person_count": by_person,
        "recent_7d": recent_7d,
        "storage_used": storage_used
    }


@router.post("/migrate-chunked")
@limiter.limit("2/minute")
@handle_exceptions
async def migrate_to_chunked(
    request: Request,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Re-encrypt single-shot files >50MB to chunked format for streaming."""
    db = _get_db()
    crypto = _get_crypto()
    config = _get_config(db)
    storage_path = Path(config.get('storage_path', '/opt/immich/private'))
    data_path = storage_path / 'data'

    min_size = 50 * 1024 * 1024  # 50MB threshold

    # Find files that need migration
    with db.get_connection() as conn:
        cursor = conn.cursor()
        cursor.execute('SELECT id, storage_id, file_size FROM private_media WHERE file_size > ? ORDER BY file_size ASC', (min_size,))
        rows = cursor.fetchall()

    to_migrate = []
    for row in rows:
        enc_file = data_path / f"{row['storage_id']}.enc"
        if enc_file.exists() and not crypto._is_chunked_format(enc_file):
            to_migrate.append(row)

    if not to_migrate:
        return {"status": "done", "message": "No files need migration", "migrated": 0}

    # Run migration in background thread
    job_id = f"pg_migrate_{secrets.token_hex(6)}"
    _create_pg_job(job_id, {
        'status': 'running',
        'total_files': len(to_migrate),
        'processed_files': 0,
        'migrated': 0,
        'failed': 0,
        'current_file': ''
    })

    def _run_migration():
        migrated = 0
        failed = 0
        for idx, row in enumerate(to_migrate):
            enc_file = data_path / f"{row['storage_id']}.enc"
            _update_pg_job(job_id, {
                'current_file': f"ID {row['id']} ({row['file_size'] / 1e6:.0f}MB)",
                'processed_files': idx
            })
            try:
                if crypto.re_encrypt_to_chunked(enc_file):
                    migrated += 1
                    logger.info(f"Migrated ID {row['id']} ({row['file_size']/1e6:.0f}MB) to chunked format")
                else:
                    failed += 1
            except Exception as e:
                logger.error(f"Migration failed for ID {row['id']}: {e}")
                failed += 1

            _update_pg_job(job_id, {
                'processed_files': idx + 1,
                'migrated': migrated,
                'failed': failed
            })

        _invalidate_posts_cache()
        _update_pg_job(job_id, {
            'status': 'completed',
            'processed_files': len(to_migrate),
            'migrated': migrated,
            'failed': failed
        })

    import threading
    t = threading.Thread(target=_run_migration, daemon=True)
    t.start()

    return {
        "status": "started",
        "job_id": job_id,
        "total_files": len(to_migrate),
        "total_size_mb": sum(r['file_size'] for r in to_migrate) / 1e6
    }


# ============================================================================
# REDDIT COMMUNITY MONITOR ENDPOINTS
# ============================================================================

def _get_reddit_monitor(with_activity_manager: bool = False):
    """Get or create the Reddit community monitor instance."""
    db = _get_db()
    db_path = str(Path(db.db_path) if hasattr(db, 'db_path') else Path(__file__).parent.parent.parent / 'database' / 'media_downloader.db')
    from modules.reddit_community_monitor import RedditCommunityMonitor
    activity_manager = None
    if with_activity_manager:
        from modules.activity_status import get_activity_manager
        app_state = get_app_state()
        activity_manager = get_activity_manager(app_state.db if app_state else None)
    return RedditCommunityMonitor(db_path, activity_manager)


@router.get("/reddit/settings")
@handle_exceptions
async def get_reddit_settings(
    request: Request,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Get Reddit monitor settings."""
    monitor = _get_reddit_monitor()
    settings = monitor.get_settings()
    # Check if encrypted cookies exist (don't expose them)
    crypto = _get_crypto()
    settings['has_cookies'] = monitor.has_cookies(crypto)
    return {"settings": settings}


@router.put("/reddit/settings")
@handle_exceptions
async def update_reddit_settings(
    request: Request,
    data: RedditMonitorSettingsUpdate,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Update Reddit monitor settings."""
    monitor = _get_reddit_monitor()
    crypto = _get_crypto()

    updates = {k: v for k, v in data.model_dump().items() if v is not None}

    # Handle key file export/deletion when enabling/disabling
    if 'enabled' in updates:
        from modules.private_gallery_crypto import export_key_to_file, delete_key_file
        from modules.reddit_community_monitor import REDDIT_MONITOR_KEY_FILE

        if updates['enabled']:
            if not crypto.is_initialized():
                raise AuthError("Gallery must be unlocked to enable Reddit monitor")
            if not export_key_to_file(REDDIT_MONITOR_KEY_FILE):
                raise ValidationError("Failed to export encryption key for background monitoring")
        else:
            delete_key_file(REDDIT_MONITOR_KEY_FILE)

    if monitor.update_settings(**updates):
        return {"message": "Reddit monitor settings updated"}
    raise ValidationError("Failed to update settings")


@router.put("/reddit/cookies")
@handle_exceptions
async def upload_reddit_cookies(
    request: Request,
    data: RedditCookiesUpload,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Upload JSON cookies for Reddit authentication. Stored encrypted."""
    crypto = _get_crypto()
    if not crypto.is_initialized():
        raise AuthError("Gallery must be unlocked to upload cookies")

    # Validate JSON
    try:
        parsed = json.loads(data.cookies_json)
        if not isinstance(parsed, list):
            raise ValidationError("Cookies must be a JSON array")
    except json.JSONDecodeError:
        raise ValidationError("Invalid JSON")

    monitor = _get_reddit_monitor()
    if monitor.save_cookies(crypto, data.cookies_json):
        return {"message": "Cookies saved"}
    raise ValidationError("Failed to save cookies")


@router.delete("/reddit/cookies")
@handle_exceptions
async def delete_reddit_cookies(
    request: Request,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Delete stored Reddit cookies."""
    monitor = _get_reddit_monitor()
    monitor.delete_cookies()
    return {"message": "Cookies deleted"}


@router.get("/reddit/communities")
@handle_exceptions
async def get_reddit_communities(
    request: Request,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Get all Reddit community mappings with decrypted person names."""
    monitor = _get_reddit_monitor()
    crypto = _get_crypto()
    communities = monitor.get_all_communities()

    # Decrypt person names
    result = []
    for c in communities:
        item = {
            'id': c['id'],
            'subreddit_name': c['subreddit_name'],
            'person_id': c['person_id'],
            'enabled': bool(c['enabled']),
            'last_checked': c['last_checked'],
            'total_media_found': c['total_media_found'],
            'created_at': c['created_at'],
            'updated_at': c['updated_at'],
        }
        if c.get('person_encrypted_name'):
            try:
                item['person_name'] = crypto.decrypt_field(c['person_encrypted_name'])
            except Exception:
                item['person_name'] = '[Decryption Error]'
        else:
            item['person_name'] = 'Unknown'

        if c.get('relationship_encrypted_name'):
            try:
                item['relationship_name'] = crypto.decrypt_field(c['relationship_encrypted_name'])
            except Exception:
                item['relationship_name'] = ''
        else:
            item['relationship_name'] = ''

        item['relationship_color'] = c.get('relationship_color', '#6b7280')
        result.append(item)

    return {"communities": result}


@router.post("/reddit/communities")
@handle_exceptions
async def add_reddit_community(
    request: Request,
    data: RedditCommunityCreate,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Add a new Reddit community mapping."""
    monitor = _get_reddit_monitor()
    try:
        community_id = monitor.add_community(data.subreddit_name, data.person_id)
        return {"id": community_id, "message": "Community added"}
    except Exception as e:
        if 'UNIQUE constraint' in str(e):
            raise ValidationError("This subreddit is already mapped to this person")
        raise


@router.put("/reddit/communities/{community_id}")
@handle_exceptions
async def update_reddit_community(
    request: Request,
    community_id: int,
    data: RedditCommunityUpdate,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Update a Reddit community mapping."""
    monitor = _get_reddit_monitor()
    updates = {k: v for k, v in data.model_dump().items() if v is not None}
    if monitor.update_community(community_id, **updates):
        return {"message": "Community updated"}
    raise NotFoundError("Community not found")


@router.delete("/reddit/communities/{community_id}")
@handle_exceptions
async def delete_reddit_community(
    request: Request,
    community_id: int,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Delete a Reddit community mapping."""
    monitor = _get_reddit_monitor()
    if monitor.delete_community(community_id):
        return {"message": "Community deleted"}
    raise NotFoundError("Community not found")


@router.post("/reddit/check-now")
@handle_exceptions
async def trigger_reddit_check(
    request: Request,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Trigger an immediate Reddit community check in the background."""
    monitor = _get_reddit_monitor(with_activity_manager=True)

    # Re-export key file to ensure it's current
    crypto = _get_crypto()
    if crypto.is_initialized():
        from modules.private_gallery_crypto import export_key_to_file
        from modules.reddit_community_monitor import REDDIT_MONITOR_KEY_FILE
        export_key_to_file(REDDIT_MONITOR_KEY_FILE)

    def run_check():
        try:
            import asyncio
            loop = asyncio.new_event_loop()
            asyncio.set_event_loop(loop)
            try:
                count = loop.run_until_complete(monitor.check_all_now())
                logger.info(f"Reddit manual check complete: {count} new media", module="PrivateGallery")
                if count > 0:
                    _invalidate_posts_cache()
            finally:
                loop.close()
        except Exception as e:
            logger.error(f"Reddit manual check failed: {e}", module="PrivateGallery")

    import threading
    t = threading.Thread(target=run_check, daemon=True)
    t.start()

    return {"message": "Reddit check started in background"}


@router.post("/reddit/communities/{community_id}/download-all")
@handle_exceptions
async def download_full_reddit_community(
    request: Request,
    community_id: int,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Download all available media from a Reddit community."""
    monitor = _get_reddit_monitor(with_activity_manager=True)

    community = monitor.get_community(community_id)
    if not community:
        raise NotFoundError("Community not found")

    # Re-export key file
    crypto = _get_crypto()
    if crypto.is_initialized():
        from modules.private_gallery_crypto import export_key_to_file
        from modules.reddit_community_monitor import REDDIT_MONITOR_KEY_FILE
        export_key_to_file(REDDIT_MONITOR_KEY_FILE)

    def run_download():
        try:
            import asyncio
            loop = asyncio.new_event_loop()
            asyncio.set_event_loop(loop)
            try:
                count = loop.run_until_complete(monitor.download_full_community(community_id))
                logger.info(f"Reddit full download complete: {count} media from r/{community['subreddit_name']}", module="PrivateGallery")
                if count > 0:
                    _invalidate_posts_cache()
            finally:
                loop.close()
        except Exception as e:
            logger.error(f"Reddit full download failed: {e}", module="PrivateGallery")

    import threading
    t = threading.Thread(target=run_download, daemon=True)
    t.start()

    return {"message": f"Full download started for r/{community['subreddit_name']}"}


@router.post("/reddit/communities/{community_id}/check")
@handle_exceptions
async def check_single_reddit_community(
    request: Request,
    community_id: int,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Check a single Reddit community for new posts."""
    monitor = _get_reddit_monitor(with_activity_manager=True)

    community = monitor.get_community(community_id)
    if not community:
        raise NotFoundError("Community not found")

    # Re-export key file
    crypto = _get_crypto()
    if crypto.is_initialized():
        from modules.private_gallery_crypto import export_key_to_file
        from modules.reddit_community_monitor import REDDIT_MONITOR_KEY_FILE
        export_key_to_file(REDDIT_MONITOR_KEY_FILE)

    def run_check():
        try:
            import asyncio
            loop = asyncio.new_event_loop()
            asyncio.set_event_loop(loop)
            try:
                count = loop.run_until_complete(monitor.check_single_community(community_id))
                logger.info(f"Reddit single community check complete: {count} new media from r/{community['subreddit_name']}", module="PrivateGallery")
                if count > 0:
                    _invalidate_posts_cache()
            finally:
                loop.close()
        except Exception as e:
            logger.error(f"Reddit single community check failed: {e}", module="PrivateGallery")

    import threading
    t = threading.Thread(target=run_check, daemon=True)
    t.start()

    return {"message": f"Check started for r/{community['subreddit_name']}"}


@router.post("/reddit/communities/check-by-person/{person_id}")
@handle_exceptions
async def check_reddit_communities_by_person(
    request: Request,
    person_id: int,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Check all Reddit communities for a specific person."""
    monitor = _get_reddit_monitor(with_activity_manager=True)

    # Re-export key file
    crypto = _get_crypto()
    if crypto.is_initialized():
        from modules.private_gallery_crypto import export_key_to_file
        from modules.reddit_community_monitor import REDDIT_MONITOR_KEY_FILE
        export_key_to_file(REDDIT_MONITOR_KEY_FILE)

    def run_check():
        try:
            import asyncio
            loop = asyncio.new_event_loop()
            asyncio.set_event_loop(loop)
            try:
                count = loop.run_until_complete(monitor.check_communities_by_person(person_id))
                logger.info(f"Reddit person check complete: {count} new media for person {person_id}", module="PrivateGallery")
                if count > 0:
                    _invalidate_posts_cache()
            finally:
                loop.close()
        except Exception as e:
            logger.error(f"Reddit person check failed: {e}", module="PrivateGallery")

    import threading
    t = threading.Thread(target=run_check, daemon=True)
    t.start()

    return {"message": f"Check started for all communities of person {person_id}"}


@router.get("/reddit/history/{community_id}")
@handle_exceptions
async def get_reddit_history(
    request: Request,
    community_id: int,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Get download history for a Reddit community."""
    monitor = _get_reddit_monitor()
    history = monitor.get_history(community_id)
    return {"history": history}


# ============================================================================
# SCRAPER ACCOUNT MAPPINGS (Instagram, TikTok, Snapchat)
# ============================================================================

VALID_SCRAPER_PLATFORMS = ('instagram', 'tiktok', 'snapchat')


def _validate_scraper_platform(platform: str):
    if platform not in VALID_SCRAPER_PLATFORMS:
        raise ValidationError(f"Invalid platform: {platform}. Must be one of: {', '.join(VALID_SCRAPER_PLATFORMS)}")


@router.get("/scraper-accounts/{platform}/available")
@handle_exceptions
async def get_available_scraper_accounts(
    request: Request,
    platform: str,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Get de-duplicated list of accounts from all scrapers for a platform."""
    _validate_scraper_platform(platform)
    db = _get_db()

    # Load config
    app_state = get_app_state()
    config = {}
    if hasattr(app_state, 'settings_manager') and app_state.settings_manager:
        config = app_state.settings_manager.get_all()
    elif hasattr(app_state, 'config'):
        config = app_state.config or {}

    from modules.scraper_gallery_bridge import get_available_accounts
    accounts = get_available_accounts(platform, config, db)
    return {"accounts": accounts}


@router.get("/scraper-accounts/{platform}")
@handle_exceptions
async def get_scraper_accounts(
    request: Request,
    platform: str,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """List all scraper account mappings for a platform."""
    _validate_scraper_platform(platform)
    db = _get_db()
    crypto = _get_crypto()

    import sqlite3
    conn = sqlite3.connect(db.db_path, timeout=10)
    conn.row_factory = sqlite3.Row
    try:
        cursor = conn.cursor()
        cursor.execute('''
            SELECT sa.*, p.encrypted_name as person_encrypted_name,
                   r.encrypted_name as relationship_encrypted_name,
                   r.color as relationship_color
            FROM private_media_scraper_accounts sa
            JOIN private_media_persons p ON sa.person_id = p.id
            LEFT JOIN private_media_relationships r ON p.relationship_id = r.id
            WHERE sa.platform = ?
            ORDER BY sa.created_at DESC
        ''', (platform,))
        rows = cursor.fetchall()
    finally:
        conn.close()

    accounts = []
    for row in rows:
        item = {
            'id': row['id'],
            'platform': row['platform'],
            'username': row['username'],
            'person_id': row['person_id'],
            'enabled': bool(row['enabled']),
            'last_imported_at': row['last_imported_at'],
            'total_media_imported': row['total_media_imported'],
            'created_at': row['created_at'],
            'updated_at': row['updated_at'],
        }
        try:
            item['person_name'] = crypto.decrypt_field(row['person_encrypted_name'])
        except Exception:
            item['person_name'] = '[Decryption Error]'
        try:
            item['relationship_name'] = crypto.decrypt_field(row['relationship_encrypted_name']) if row['relationship_encrypted_name'] else None
        except Exception:
            item['relationship_name'] = None
        item['relationship_color'] = row['relationship_color']
        accounts.append(item)

    return {"accounts": accounts}


@router.post("/scraper-accounts/{platform}")
@handle_exceptions
async def add_scraper_account(
    request: Request,
    platform: str,
    data: ScraperAccountCreate,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Add a new scraper account → person mapping."""
    _validate_scraper_platform(platform)
    db = _get_db()

    username = data.username.strip().lower().lstrip('@')
    if not username:
        raise ValidationError("Username is required")

    import sqlite3
    conn = sqlite3.connect(db.db_path, timeout=10)
    try:
        cursor = conn.cursor()
        now = datetime.now().isoformat()
        try:
            cursor.execute('''
                INSERT INTO private_media_scraper_accounts (platform, username, person_id, created_at, updated_at)
                VALUES (?, ?, ?, ?, ?)
            ''', (platform, username, data.person_id, now, now))
            conn.commit()

            # Export key file so scheduler can import even when gallery is locked
            crypto = _get_crypto()
            if crypto.is_initialized():
                from modules.private_gallery_crypto import export_key_to_file
                from modules.scraper_gallery_bridge import SCRAPER_BRIDGE_KEY_FILE
                export_key_to_file(SCRAPER_BRIDGE_KEY_FILE)

            return {"id": cursor.lastrowid, "message": "Account mapping added"}
        except sqlite3.IntegrityError as e:
            if 'UNIQUE constraint' in str(e):
                raise ValidationError("This account is already mapped to this person")
            if 'FOREIGN KEY constraint' in str(e):
                raise ValidationError("Person not found")
            raise
    finally:
        conn.close()


@router.put("/scraper-accounts/{platform}/{account_id}")
@handle_exceptions
async def update_scraper_account(
    request: Request,
    platform: str,
    account_id: int,
    data: ScraperAccountUpdate,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Update a scraper account mapping."""
    _validate_scraper_platform(platform)
    db = _get_db()

    import sqlite3
    conn = sqlite3.connect(db.db_path, timeout=10)
    try:
        cursor = conn.cursor()
        updates = []
        params = []

        if data.person_id is not None:
            updates.append("person_id = ?")
            params.append(data.person_id)
        if data.enabled is not None:
            updates.append("enabled = ?")
            params.append(1 if data.enabled else 0)

        if not updates:
            raise ValidationError("No fields to update")

        updates.append("updated_at = ?")
        params.append(datetime.now().isoformat())
        params.append(account_id)
        params.append(platform)

        cursor.execute(
            f'UPDATE private_media_scraper_accounts SET {", ".join(updates)} WHERE id = ? AND platform = ?',
            params
        )
        conn.commit()

        if cursor.rowcount == 0:
            raise NotFoundError("Account mapping not found")

        return {"message": "Account mapping updated"}
    finally:
        conn.close()


@router.delete("/scraper-accounts/{platform}/{account_id}")
@handle_exceptions
async def delete_scraper_account(
    request: Request,
    platform: str,
    account_id: int,
    current_user: Dict = Depends(get_current_user),
    session: Dict = Depends(_verify_gallery_token)
):
    """Delete a scraper account mapping."""
    _validate_scraper_platform(platform)
    db = _get_db()

    import sqlite3
    conn = sqlite3.connect(db.db_path, timeout=10)
    try:
        cursor = conn.cursor()
        cursor.execute(
            'DELETE FROM private_media_scraper_accounts WHERE id = ? AND platform = ?',
            (account_id, platform)
        )
        conn.commit()
        if cursor.rowcount == 0:
            raise NotFoundError("Account mapping not found")
        return {"message": "Account mapping deleted"}
    finally:
        conn.close()

