DataManager.php

39.84 KB
31/07/2025 12:25
PHP
DataManager.php
<?php

/**
 * DataManager class for handling JSON file-based data storage
 * Provides CRUD operations for albums, tags, photos, and configuration
 */
class DataManager
{
    /**
     * @var mixed
     */
    private $dataDir;

    /**
     * Constructor - Initialize data directory
     * @param string $dataDir Path to data directory
     */
    public function __construct($dataDir = 'data')
    {
        $this->dataDir = rtrim($dataDir, '/');
        $this->ensureDataDirectory();
    }

    /**
     * Ensure data directory exists
     */
    private function ensureDataDirectory()
    {
        if (!is_dir($this->dataDir)) {
            if (!mkdir($this->dataDir, 0755, true)) {
                throw new Exception('Failed to create data directory: '.$this->dataDir);
            }
        }
    }

    /**
     * Initialize data files with default values
     */
    public function initializeData()
    {
        try {
            // Initialize albums.json
            if (!file_exists($this->dataDir.'/albums.json')) {
                $this->saveJsonFile('albums.json', []);
            }

            // Initialize tags.json
            if (!file_exists($this->dataDir.'/tags.json')) {
                $this->saveJsonFile('tags.json', []);
            }

            // Initialize counters.json
            if (!file_exists($this->dataDir.'/counters.json')) {
                $this->saveJsonFile('counters.json', [
                    'next_album_id' => 1,
                    'next_photo_id' => 1,
                    'next_tag_id' => 1
                ]);
            }

            // Initialize config.json with default values
            if (!file_exists($this->dataDir.'/config.json')) {
                $this->setDefaultConfig();
            }

            return true;
        } catch (Exception $e) {
            throw new Exception('Data initialization failed: '.$e->getMessage());
        }
    }

    /**
     * Set default configuration values
     */
    private function setDefaultConfig()
    {
        $defaultConfig = [
            'upload_max_file_size' => 10485760, // 10MB
            'upload_allowed_types' => ['image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/tiff'],
            'image_webp_quality' => 80,
            'image_max_width' => 1000,
            'image_max_height' => 1000,
            'image_thumbnail_size' => 300,
            'rss_title' => 'Photo Gallery RSS Feed',
            'rss_description' => 'Latest photos from selected albums',
            'rss_max_items' => 50,
            'rss_base_url' => 'http://localhost/',
            'storage_max_total_size' => 1073741824, // 1GB
            'storage_cleanup_enabled' => true
        ];

        $this->saveJsonFile('config.json', $defaultConfig);
    }

    // ALBUM OPERATIONS

    /**
     * Get all albums with optional metadata
     * @param bool $includeMetadata Include tags and photo count
     * @return array Array of albums
     */
    public function getAlbums($includeMetadata = true)
    {
        try {
            $albums = $this->loadJsonFile('albums.json');

            if ($includeMetadata) {
                foreach ($albums as &$album) {
                    $album['photo_count'] = count($album['photos'] ?? []);
                }
            }

            // Sort by created_at descending
            uasort($albums, function ($a, $b) {
                return strtotime($b['created_at']) - strtotime($a['created_at']);
            });

            return array_values($albums);
        } catch (Exception $e) {
            throw new Exception('Failed to get albums: '.$e->getMessage());
        }
    }

    /**
     * Get single album by ID
     * @param int $id Album ID
     * @return array|null Album data or null if not found
     */
    public function getAlbum($id)
    {
        try {
            $albums = $this->loadJsonFile('albums.json');

            // Convert to integer for consistency
            $id = intval($id);

            if (isset($albums[$id])) {
                $album = $albums[$id];
                $album['photo_count'] = count($album['photos'] ?? []);
                return $album;
            }

            return null;
        } catch (Exception $e) {
            throw new Exception('Failed to get album: '.$e->getMessage());
        }
    }

    /**
     * Create new album
     * @param string $title Album title
     * @param string $description Album description
     * @return int Created album ID
     */
    public function createAlbum($title, $description = '')
    {
        try {
            // Validate title
            $title = trim($title);
            if (empty($title)) {
                throw new Exception('Album title is required and cannot be empty');
            }

            $albums = $this->loadJsonFile('albums.json');

            // Generate unique ID using timestamp + microseconds
            $id = time();

            $album = [
                'id' => $id,
                'title' => $title,
                'description' => $description,
                'created_at' => date('c'),
                'updated_at' => date('c'),
                'photo_count' => 0,
                'is_rss_enabled' => false,
                'tags' => [],
                'photos' => []
            ];

            $albums[$id] = $album;
            $this->saveJsonFile('albums.json', $albums);

            return $id;
        } catch (Exception $e) {
            throw new Exception('Failed to create album: '.$e->getMessage());
        }
    }

    /**
     * Update album
     * @param int $id Album ID
     * @param array $data Album data to update
     * @return bool Success status
     */
    public function updateAlbum($id, $data)
    {
        try {
            $albums = $this->loadJsonFile('albums.json');

            // Convert to integer for consistency
            $id = intval($id);

            if (!isset($albums[$id])) {
                return false;
            }

            $allowedFields = ['title', 'description', 'is_rss_enabled'];

            foreach ($data as $field => $value) {
                if (in_array($field, $allowedFields)) {
                    $albums[$id][$field] = $value;
                }
            }

            $albums[$id]['updated_at'] = date('c');
            $this->saveJsonFile('albums.json', $albums);

            return true;
        } catch (Exception $e) {
            throw new Exception('Failed to update album: '.$e->getMessage());
        }
    }

    /**
     * Delete album and associated data
     * @param int $id Album ID
     * @return bool Success status
     */
    public function deleteAlbum($id)
    {
        try {
            $albums = $this->loadJsonFile('albums.json');

            // Convert to integer for consistency
            $id = intval($id);

            if (!isset($albums[$id])) {
                return false;
            }

            unset($albums[$id]);
            $this->saveJsonFile('albums.json', $albums);

            return true;
        } catch (Exception $e) {
            throw new Exception('Failed to delete album: '.$e->getMessage());
        }
    }

    // TAG OPERATIONS

    /**
     * Get all tags
     * @return array Array of tags
     */
    public function getTags()
    {
        try {
            $tags = $this->loadJsonFile('tags.json');
            $albums = $this->loadJsonFile('albums.json');

            // Calculate album count for each tag
            foreach ($tags as &$tag) {
                $tag['album_count'] = 0;
                foreach ($albums as $album) {
                    if (in_array($tag['id'], $album['tags'] ?? [])) {
                        $tag['album_count']++;
                    }
                }
            }

            // Sort by name
            uasort($tags, function ($a, $b) {
                return strcmp($a['name'], $b['name']);
            });

            return array_values($tags);
        } catch (Exception $e) {
            throw new Exception('Failed to get tags: '.$e->getMessage());
        }
    }

    /**
     * Create new tag
     * @param string $name Tag name
     * @param string $color Tag color (hex)
     * @return int Created tag ID
     */
    public function createTag($name, $color = '#3498db')
    {
        try {
            $tags = $this->loadJsonFile('tags.json');

            // Check if tag name already exists
            foreach ($tags as $tag) {
                if (strtolower($tag['name']) === strtolower($name)) {
                    throw new Exception('Tag name already exists');
                }
            }

            $id = time();

            $tag = [
                'id' => $id,
                'name' => $name,
                'color' => $color
            ];

            $tags[$id] = $tag;
            $this->saveJsonFile('tags.json', $tags);

            return $id;
        } catch (Exception $e) {
            throw new Exception('Failed to create tag: '.$e->getMessage());
        }
    }

    /**
     * Get tags for specific album
     * @param int $albumId Album ID
     * @return array Array of tags
     */
    public function getAlbumTags($albumId)
    {
        try {
            $album = $this->getAlbum($albumId);
            if (!$album) {
                return [];
            }

            $tags = $this->loadJsonFile('tags.json');
            $albumTags = [];

            foreach ($album['tags'] ?? [] as $tagId) {
                if (isset($tags[$tagId])) {
                    $albumTags[] = $tags[$tagId];
                }
            }

            return $albumTags;
        } catch (Exception $e) {
            throw new Exception('Failed to get album tags: '.$e->getMessage());
        }
    }

    /**
     * Update album tags
     * @param int $albumId Album ID
     * @param array $tagIds Array of tag IDs
     * @return bool Success status
     */
    public function updateAlbumTags($albumId, $tagIds)
    {
        try {
            $albums = $this->loadJsonFile('albums.json');

            // Convert to integer for consistency
            $albumId = intval($albumId);

            if (!isset($albums[$albumId])) {
                return false;
            }

            // Validate all tag IDs exist
            $allTags = $this->loadJsonFile('tags.json');
            $validTagIds = array_column($allTags, 'id'); // ใช้ array_column แทน array_keys

            foreach ($tagIds as $tagId) {
                if (!in_array((int) $tagId, $validTagIds)) {
                    // แปลงเป็น int สำหรับเปรียบเทียบ
                    throw new Exception('Invalid tag ID: '.$tagId);
                }
            }

            $albums[$albumId]['tags'] = array_map('intval', $tagIds);
            $albums[$albumId]['updated_at'] = date('c');
            $this->saveJsonFile('albums.json', $albums);

            return true;
        } catch (Exception $e) {
            throw new Exception('Failed to update album tags: '.$e->getMessage());
        }
    }

    // PHOTO OPERATIONS

    /**
     * Get photos for album
     * @param int $albumId Album ID
     * @return array Array of photos
     */
    public function getPhotos($albumId)
    {
        try {
            $album = $this->getAlbum($albumId);
            if (!$album) {
                return [];
            }

            $photos = $album['photos'] ?? [];

            // Sort by uploaded_at descending
            usort($photos, function ($a, $b) {
                return strtotime($b['uploaded_at']) - strtotime($a['uploaded_at']);
            });

            return $photos;
        } catch (Exception $e) {
            throw new Exception('Failed to get photos: '.$e->getMessage());
        }
    }

    /**
     * Add photo to database
     * @param int $albumId Album ID
     * @param array $photoData Photo metadata
     * @return int Created photo ID
     */
    public function addPhoto($albumId, $photoData)
    {
        try {
            $albums = $this->loadJsonFile('albums.json');

            if (!isset($albums[$albumId])) {
                throw new Exception('Album not found');
            }

            $photoId = time();

            $photo = [
                'id' => $photoId,
                'filename' => $photoData['filename'],
                'original_filename' => $photoData['original_filename'] ?? null,
                'file_size' => $photoData['file_size'] ?? null,
                'width' => $photoData['width'] ?? null,
                'height' => $photoData['height'] ?? null,
                'uploaded_at' => date('c')
            ];

            if (!isset($albums[$albumId]['photos'])) {
                $albums[$albumId]['photos'] = [];
            }

            $albums[$albumId]['photos'][] = $photo;
            $albums[$albumId]['photo_count'] = count($albums[$albumId]['photos']);
            $albums[$albumId]['updated_at'] = date('c');

            $this->saveJsonFile('albums.json', $albums);

            return $photoId;
        } catch (Exception $e) {
            throw new Exception('Failed to add photo: '.$e->getMessage());
        }
    }

    /**
     * Delete photo
     * @param int $id Photo ID
     * @return bool Success status
     */
    public function deletePhoto($id)
    {
        try {
            $albums = $this->loadJsonFile('albums.json');

            foreach ($albums as $albumId => &$album) {
                if (isset($album['photos'])) {
                    foreach ($album['photos'] as $index => $photo) {
                        if ($photo['id'] == $id) {
                            unset($album['photos'][$index]);
                            $album['photos'] = array_values($album['photos']); // Re-index array
                            $album['photo_count'] = count($album['photos']);
                            $album['updated_at'] = date('c');

                            $this->saveJsonFile('albums.json', $albums);
                            return true;
                        }
                    }
                }
            }

            return false;
        } catch (Exception $e) {
            throw new Exception('Failed to delete photo: '.$e->getMessage());
        }
    }

    /**
     * Delete photo from album by filename
     * @param int $albumId Album ID
     * @param string $filename Photo filename
     * @return bool Success status
     */
    public function deletePhotoByFilename($albumId, $filename)
    {
        try {
            $albums = $this->loadJsonFile('albums.json');

            if (!isset($albums[$albumId])) {
                return false;
            }

            $album = &$albums[$albumId];

            if (isset($album['photos'])) {
                foreach ($album['photos'] as $index => $photo) {
                    if ($photo['filename'] === $filename) {
                        unset($album['photos'][$index]);
                        $album['photos'] = array_values($album['photos']); // Re-index array
                        $album['photo_count'] = count($album['photos']);
                        $album['updated_at'] = date('c');

                        $this->saveJsonFile('albums.json', $albums);
                        return true;
                    }
                }
            }

            return false;
        } catch (Exception $e) {
            throw new Exception('Failed to delete photo by filename: '.$e->getMessage());
        }
    }

    // CONFIGURATION OPERATIONS

    /**
     * Get configuration value
     * @param string $key Configuration key
     * @return mixed Configuration value or null if not found
     */
    public function getConfig($key)
    {
        try {
            $config = $this->loadJsonFile('config.json');
            return isset($config[$key]) ? $config[$key] : null;
        } catch (Exception $e) {
            throw new Exception('Failed to get config: '.$e->getMessage());
        }
    }

    /**
     * Set configuration value
     * @param string $key Configuration key
     * @param mixed $value Configuration value
     * @return bool Success status
     */
    public function setConfig($key, $value)
    {
        try {
            $config = $this->loadJsonFile('config.json');
            $config[$key] = $value;
            $this->saveJsonFile('config.json', $config);
            return true;
        } catch (Exception $e) {
            throw new Exception('Failed to set config: '.$e->getMessage());
        }
    }

    /**
     * Get all configuration values
     * @return array Associative array of configuration
     */
    public function getAllConfig()
    {
        try {
            return $this->loadJsonFile('config.json');
        } catch (Exception $e) {
            throw new Exception('Failed to get all config: '.$e->getMessage());
        }
    }

    /**
     * Update multiple configuration values at once
     * @param array $configData Associative array of configuration keys and values
     * @return bool Success status
     */
    public function updateConfig($configData)
    {
        try {
            $config = $this->loadJsonFile('config.json');

            foreach ($configData as $key => $value) {
                $config[$key] = $value;
            }

            $this->saveJsonFile('config.json', $config);
            return true;
        } catch (Exception $e) {
            throw new Exception('Failed to update config: '.$e->getMessage());
        }
    }

    /**
     * Reset configuration to default values
     * @return bool Success status
     */
    public function resetConfig()
    {
        try {
            $this->setDefaultConfig();
            return true;
        } catch (Exception $e) {
            throw new Exception('Failed to reset config: '.$e->getMessage());
        }
    }

    /**
     * Get configuration schema with validation rules
     * @return array Configuration schema
     */
    public function getConfigSchema()
    {
        return [
            'upload_max_file_size' => [
                'type' => 'integer',
                'min' => 1048576, // 1MB
                'max' => 104857600, // 100MB
                'default' => 10485760, // 10MB
                'description' => 'Maximum file size for uploads in bytes'
            ],
            'upload_allowed_types' => [
                'type' => 'array',
                'allowed_values' => ['image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/tiff', 'image/webp'],
                'default' => ['image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/tiff'],
                'description' => 'Allowed MIME types for file uploads'
            ],
            'image_webp_quality' => [
                'type' => 'integer',
                'min' => 1,
                'max' => 100,
                'default' => 80,
                'description' => 'WebP compression quality (1-100, higher is better quality)'
            ],
            'image_max_width' => [
                'type' => 'integer',
                'min' => 100,
                'max' => 5000,
                'default' => 1000,
                'description' => 'Maximum image width in pixels'
            ],
            'image_max_height' => [
                'type' => 'integer',
                'min' => 100,
                'max' => 5000,
                'default' => 1000,
                'description' => 'Maximum image height in pixels'
            ],
            'image_thumbnail_size' => [
                'type' => 'integer',
                'min' => 50,
                'max' => 500,
                'default' => 300,
                'description' => 'Thumbnail size in pixels (square)'
            ],
            'rss_title' => [
                'type' => 'string',
                'max_length' => 255,
                'default' => 'Photo Gallery RSS Feed',
                'description' => 'Title for the RSS feed'
            ],
            'rss_description' => [
                'type' => 'string',
                'max_length' => 1000,
                'default' => 'Latest photos from selected albums',
                'description' => 'Description for the RSS feed'
            ],
            'rss_max_items' => [
                'type' => 'integer',
                'min' => 1,
                'max' => 200,
                'default' => 50,
                'description' => 'Maximum number of items in RSS feed'
            ],
            'rss_base_url' => [
                'type' => 'url',
                'default' => 'http://localhost/',
                'description' => 'Base URL for RSS feed links'
            ],
            'storage_max_total_size' => [
                'type' => 'integer',
                'min' => 104857600, // 100MB
                'max' => 10737418240, // 10GB
                'default' => 1073741824, // 1GB
                'description' => 'Maximum total storage size in bytes'
            ],
            'storage_cleanup_enabled' => [
                'type' => 'boolean',
                'default' => true,
                'description' => 'Enable automatic cleanup of orphaned files'
            ]
        ];
    }

    // STORAGE MANAGEMENT OPERATIONS

    /**
     * Get storage usage statistics
     * @param string $albumsPath Path to albums directory
     * @return array Storage statistics
     */
    public function getStorageStats($albumsPath = 'albums')
    {
        try {
            $stats = [
                'total_size' => 0,
                'total_files' => 0,
                'total_thumbnails' => 0,
                'albums_count' => 0,
                'orphaned_files' => [],
                'largest_files' => [],
                'album_sizes' => [],
                'file_types' => [],
                'max_storage_size' => $this->getConfig('storage_max_total_size') ?? 1073741824,
                'usage_percentage' => 0,
                'available_space' => 0
            ];

            if (!is_dir($albumsPath)) {
                return $stats;
            }

            $albums = $this->loadJsonFile('albums.json');
            $registeredFiles = [];

            // Collect all registered files from database
            foreach ($albums as $albumId => $album) {
                if (isset($album['photos'])) {
                    foreach ($album['photos'] as $photo) {
                        $registeredFiles[$albumId][] = $photo['filename'];
                        if (isset($photo['file_size'])) {
                            $stats['total_size'] += $photo['file_size'];
                        }
                    }
                }
            }

            // Scan filesystem for actual files
            $dirs = scandir($albumsPath);
            if ($dirs !== false) {
                foreach ($dirs as $dir) {
                    if ($dir === '.' || $dir === '..') {
                        continue;
                    }

                    $albumPath = $albumsPath.'/'.$dir;
                    if (!is_dir($albumPath)) {
                        continue;
                    }

                    $stats['albums_count']++;
                    $albumSize = 0;
                    $albumFiles = 0;
                    $albumThumbnails = 0;

                    $files = scandir($albumPath);
                    if ($files !== false) {
                        foreach ($files as $file) {
                            if ($file === '.' || $file === '..') {
                                continue;
                            }

                            $filePath = $albumPath.'/'.$file;
                            if (!is_file($filePath)) {
                                continue;
                            }

                            $fileSize = filesize($filePath);
                            $fileExt = strtolower(pathinfo($file, PATHINFO_EXTENSION));

                            // Track file types
                            if (!isset($stats['file_types'][$fileExt])) {
                                $stats['file_types'][$fileExt] = ['count' => 0, 'size' => 0];
                            }
                            $stats['file_types'][$fileExt]['count']++;
                            $stats['file_types'][$fileExt]['size'] += $fileSize;

                            // Check if it's a thumbnail
                            if (strpos($file, 'thumb_') === 0) {
                                $stats['total_thumbnails']++;
                                $albumThumbnails++;
                            } else {
                                $stats['total_files']++;
                                $albumFiles++;

                                // Add to largest files list
                                $stats['largest_files'][] = [
                                    'filename' => $file,
                                    'album_id' => $dir,
                                    'size' => $fileSize,
                                    'path' => $filePath
                                ];

                                // Check if file is orphaned (not in database)
                                if (!isset($registeredFiles[$dir]) || !in_array($file, $registeredFiles[$dir])) {
                                    $stats['orphaned_files'][] = [
                                        'filename' => $file,
                                        'album_id' => $dir,
                                        'size' => $fileSize,
                                        'path' => $filePath,
                                        'modified_at' => date('c', filemtime($filePath))
                                    ];
                                }
                            }

                            $albumSize += $fileSize;
                        }
                    }

                    $stats['album_sizes'][] = [
                        'album_id' => $dir,
                        'size' => $albumSize,
                        'files' => $albumFiles,
                        'thumbnails' => $albumThumbnails
                    ];
                }
            }

            // Sort largest files by size (descending)
            usort($stats['largest_files'], function ($a, $b) {
                return $b['size'] - $a['size'];
            });
            $stats['largest_files'] = array_slice($stats['largest_files'], 0, 20); // Top 20

            // Sort album sizes by size (descending)
            usort($stats['album_sizes'], function ($a, $b) {
                return $b['size'] - $a['size'];
            });

            // Calculate usage percentage and available space
            if ($stats['max_storage_size'] > 0) {
                $stats['usage_percentage'] = round(($stats['total_size'] / $stats['max_storage_size']) * 100, 2);
                $stats['available_space'] = $stats['max_storage_size'] - $stats['total_size'];
            }

            return $stats;

        } catch (Exception $e) {
            throw new Exception('Failed to get storage stats: '.$e->getMessage());
        }
    }

    /**
     * Clean up orphaned files (files not referenced in database)
     * @param string $albumsPath Path to albums directory
     * @param bool $dryRun If true, only return what would be deleted without actually deleting
     * @return array Cleanup results
     */
    public function cleanupOrphanedFiles($albumsPath = 'albums', $dryRun = false)
    {
        try {
            $result = [
                'deleted_files' => [],
                'deleted_thumbnails' => [],
                'total_deleted' => 0,
                'space_freed' => 0,
                'errors' => []
            ];

            if (!$this->getConfig('storage_cleanup_enabled')) {
                throw new Exception('Storage cleanup is disabled in configuration');
            }

            $stats = $this->getStorageStats($albumsPath);
            $orphanedFiles = $stats['orphaned_files'];

            foreach ($orphanedFiles as $orphan) {
                try {
                    if ($dryRun) {
                        $result['deleted_files'][] = $orphan;
                        $result['space_freed'] += $orphan['size'];
                    } else {
                        if (unlink($orphan['path'])) {
                            $result['deleted_files'][] = $orphan;
                            $result['space_freed'] += $orphan['size'];

                            // Also try to delete corresponding thumbnail
                            $thumbnailPath = dirname($orphan['path']).'/thumb_'.$orphan['filename'];
                            if (file_exists($thumbnailPath)) {
                                if (unlink($thumbnailPath)) {
                                    $result['deleted_thumbnails'][] = [
                                        'filename' => 'thumb_'.$orphan['filename'],
                                        'album_id' => $orphan['album_id'],
                                        'size' => filesize($thumbnailPath),
                                        'path' => $thumbnailPath
                                    ];
                                }
                            }
                        } else {
                            $result['errors'][] = 'Failed to delete: '.$orphan['path'];
                        }
                    }
                } catch (Exception $e) {
                    $result['errors'][] = 'Error deleting '.$orphan['path'].': '.$e->getMessage();
                }
            }

            $result['total_deleted'] = count($result['deleted_files']) + count($result['deleted_thumbnails']);

            return $result;

        } catch (Exception $e) {
            throw new Exception('Failed to cleanup orphaned files: '.$e->getMessage());
        }
    }

    /**
     * Generate thumbnails for photos that don't have them
     * @param string $albumsPath Path to albums directory
     * @param ImageProcessor $imageProcessor Image processor instance
     * @return array Generation results
     */
    public function generateMissingThumbnails($albumsPath = 'albums', $imageProcessor = null)
    {
        try {
            $result = [
                'generated_thumbnails' => [],
                'skipped_files' => [],
                'errors' => [],
                'total_generated' => 0
            ];

            if (!$imageProcessor) {
                throw new Exception('ImageProcessor instance required for thumbnail generation');
            }

            $albums = $this->loadJsonFile('albums.json');

            foreach ($albums as $albumId => $album) {
                if (!isset($album['photos'])) {
                    continue;
                }

                $albumPath = $albumsPath.'/'.$albumId;
                if (!is_dir($albumPath)) {
                    continue;
                }

                foreach ($album['photos'] as $photo) {
                    $imagePath = $albumPath.'/'.$photo['filename'];
                    $thumbnailPath = $albumPath.'/thumb_'.$photo['filename'];

                    // Skip if thumbnail already exists
                    if (file_exists($thumbnailPath)) {
                        $result['skipped_files'][] = [
                            'filename' => $photo['filename'],
                            'album_id' => $albumId,
                            'reason' => 'Thumbnail already exists'
                        ];
                        continue;
                    }

                    // Skip if original image doesn't exist
                    if (!file_exists($imagePath)) {
                        $result['skipped_files'][] = [
                            'filename' => $photo['filename'],
                            'album_id' => $albumId,
                            'reason' => 'Original image not found'
                        ];
                        continue;
                    }

                    try {
                        $thumbnailSize = $this->getConfig('image_thumbnail_size') ?? 300;

                        if ($imageProcessor->generateThumbnail($imagePath, $thumbnailPath, $thumbnailSize)) {
                            $result['generated_thumbnails'][] = [
                                'filename' => $photo['filename'],
                                'album_id' => $albumId,
                                'thumbnail_path' => $thumbnailPath,
                                'size' => filesize($thumbnailPath)
                            ];
                            $result['total_generated']++;
                        } else {
                            $result['errors'][] = 'Failed to generate thumbnail for: '.$imagePath;
                        }
                    } catch (Exception $e) {
                        $result['errors'][] = 'Error generating thumbnail for '.$imagePath.': '.$e->getMessage();
                    }
                }
            }

            return $result;

        } catch (Exception $e) {
            throw new Exception('Failed to generate missing thumbnails: '.$e->getMessage());
        }
    }

    /**
     * Check if storage limit would be exceeded by adding specified size
     * @param int $additionalSize Size to be added in bytes
     * @param string $albumsPath Path to albums directory
     * @return array Storage check result
     */
    public function checkStorageLimit($additionalSize, $albumsPath = 'albums')
    {
        try {
            $stats = $this->getStorageStats($albumsPath);
            $maxSize = $stats['max_storage_size'];
            $currentSize = $stats['total_size'];
            $newTotalSize = $currentSize + $additionalSize;

            return [
                'can_upload' => $newTotalSize <= $maxSize,
                'current_usage' => $currentSize,
                'max_size' => $maxSize,
                'additional_size' => $additionalSize,
                'new_total_size' => $newTotalSize,
                'available_space' => $maxSize - $currentSize,
                'usage_percentage' => $maxSize > 0 ? round(($currentSize / $maxSize) * 100, 2) : 0,
                'new_usage_percentage' => $maxSize > 0 ? round(($newTotalSize / $maxSize) * 100, 2) : 0
            ];

        } catch (Exception $e) {
            throw new Exception('Failed to check storage limit: '.$e->getMessage());
        }
    }

    /**
     * Update file size tracking for a photo
     * @param int $photoId Photo ID
     * @param int $fileSize File size in bytes
     * @return bool Success status
     */
    public function updatePhotoFileSize($photoId, $fileSize)
    {
        try {
            $albums = $this->loadJsonFile('albums.json');

            foreach ($albums as $albumId => &$album) {
                if (isset($album['photos'])) {
                    foreach ($album['photos'] as &$photo) {
                        if ($photo['id'] == $photoId) {
                            $photo['file_size'] = $fileSize;
                            $album['updated_at'] = date('c');
                            $this->saveJsonFile('albums.json', $albums);
                            return true;
                        }
                    }
                }
            }

            return false;

        } catch (Exception $e) {
            throw new Exception('Failed to update photo file size: '.$e->getMessage());
        }
    }

    /**
     * Get storage usage by album
     * @param string $albumsPath Path to albums directory
     * @return array Album storage usage
     */
    public function getAlbumStorageUsage($albumsPath = 'albums')
    {
        try {
            $stats = $this->getStorageStats($albumsPath);
            $albums = $this->loadJsonFile('albums.json');

            $usage = [];

            foreach ($stats['album_sizes'] as $albumStat) {
                $albumId = $albumStat['album_id'];
                $albumData = isset($albums[$albumId]) ? $albums[$albumId] : null;

                $usage[] = [
                    'album_id' => $albumId,
                    'title' => $albumData['title'] ?? "Album $albumId",
                    'size' => $albumStat['size'],
                    'size_formatted' => $this->formatBytes($albumStat['size']),
                    'files' => $albumStat['files'],
                    'thumbnails' => $albumStat['thumbnails'],
                    'photo_count' => $albumData['photo_count'] ?? 0,
                    'created_at' => $albumData['created_at'] ?? null,
                    'updated_at' => $albumData['updated_at'] ?? null
                ];
            }

            return $usage;

        } catch (Exception $e) {
            throw new Exception('Failed to get album storage usage: '.$e->getMessage());
        }
    }

    /**
     * Format bytes to human readable format
     * @param int $bytes Size in bytes
     * @param int $precision Decimal precision
     * @return string Formatted size
     */
    private function formatBytes($bytes, $precision = 2)
    {
        $units = ['B', 'KB', 'MB', 'GB', 'TB'];

        for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
            $bytes /= 1024;
        }

        return round($bytes, $precision).' '.$units[$i];
    }

    /**
     * Remove configuration key
     * @param string $key Configuration key to remove
     * @return bool Success status
     */
    public function removeConfig($key)
    {
        try {
            $config = $this->loadJsonFile('config.json');

            if (isset($config[$key])) {
                unset($config[$key]);
                $this->saveJsonFile('config.json', $config);
                return true;
            }

            return false;
        } catch (Exception $e) {
            throw new Exception('Failed to remove config: '.$e->getMessage());
        }
    }

    // UTILITY METHODS

    /**
     * Load JSON file
     * @param string $filename Filename relative to data directory
     * @return array Decoded JSON data
     */
    private function loadJsonFile($filename)
    {
        $filepath = $this->dataDir.'/'.$filename;

        if (!file_exists($filepath)) {
            return [];
        }

        $content = file_get_contents($filepath);
        if ($content === false) {
            throw new Exception('Failed to read file: '.$filepath);
        }

        $data = json_decode($content, true);
        if (json_last_error() !== JSON_ERROR_NONE) {
            throw new Exception('Invalid JSON in file '.$filename.': '.json_last_error_msg());
        }

        return $data;
    }

    /**
     * Save data to JSON file
     * @param string $filename Filename relative to data directory
     * @param array $data Data to save
     * @return bool Success status
     */
    private function saveJsonFile($filename, $data)
    {
        $filepath = $this->dataDir.'/'.$filename;

        $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
        if ($json === false) {
            throw new Exception('Failed to encode JSON for file: '.$filename);
        }

        $result = file_put_contents($filepath, $json, LOCK_EX);
        if ($result === false) {
            throw new Exception('Failed to write file: '.$filepath);
        }

        return true;
    }

    /**
     * Get a single tag by ID
     * @param int $tagId Tag ID
     * @return array|null Tag data or null if not found
     */
    public function getTag($tagId)
    {
        try {
            $tags = $this->loadJsonFile('tags.json');

            // Convert to integer for consistency
            $tagId = intval($tagId);

            foreach ($tags as $tag) {
                if ($tag['id'] === $tagId) {
                    return $tag;
                }
            }

            return null;
        } catch (Exception $e) {
            throw new Exception('Failed to get tag: '.$e->getMessage());
        }
    }

    /**
     * Delete a tag by ID
     * @param int $tagId Tag ID
     * @return bool True if deletion was successful, false otherwise
     */
    public function deleteTag($tagId)
    {
        try {
            $tags = $this->loadJsonFile('tags.json');

            // Filter out the tag with the specified ID
            $updatedTags = array_filter($tags, function ($tag) use ($tagId) {
                return $tag['id'] !== intval($tagId);
            });

            // Check if any tag was removed
            if (count($tags) === count($updatedTags)) {
                return false; // No tag was deleted
            }

            // Save the updated tags back to the file
            $this->saveJsonFile('tags.json', array_values($updatedTags));

            return true;
        } catch (Exception $e) {
            throw new Exception('Failed to delete tag: '.$e->getMessage());
        }
    }
}