<?php

namespace App\Http\Controllers\Admin;

use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Models\Media;
use App\Models\MediaFolder;
use App\Services\UploadService;

/**
 * ITSWEBER CMS – Media Manager Controller (Phase 4a)
 *
 * Central media management:
 *  - Browse, search, filter media by folder / type
 *  - Upload into specific folders (uses UploadService)
 *  - Edit metadata (title, alt_text, description, folder assignment)
 *  - Delete media (physical files + DB record)
 *  - Folder CRUD (create, delete)
 *  - JSON picker endpoint for WYSIWYG editor integration
 *  - Legacy image optimisation tool (unchanged from Phase 3e)
 */
class MediaController extends Controller
{
    private const PER_PAGE = 40;

    // ─────────────────────────────────────────────────────────────────────────
    // INDEX – main media manager view
    // ─────────────────────────────────────────────────────────────────────────

    public function index(Request $request, array $params = []): void
    {
        $q        = trim($request->query('q', ''));
        $folderId = $request->query('folder', '');
        $type     = $request->query('type', '');
        $page     = max(1, (int) $request->query('page', 1));

        // '' = all, '0' = unassigned, '1+' = specific folder
        $folderFilter = ($folderId === '') ? null : (int) $folderId;

        $result          = Media::search($q, $folderFilter, $type, $page, self::PER_PAGE);
        $folders         = MediaFolder::allWithCount();
        $total           = $result['total'];
        $pages           = max(1, (int) ceil($total / self::PER_PAGE));
        $unassignedCount = Media::countUnassigned();

        $this->renderAdmin('media/index', [
            'pageTitle'       => __('admin.media.title'),
            'auth'            => $this->auth(),
            'csrf'            => $this->csrf(),
            'items'           => $result['items'],
            'folders'         => $folders,
            'total'           => $total,
            'pages'           => $pages,
            'page'            => $page,
            'q'               => $q,
            'folderId'        => $folderId,
            'type'            => $type,
            'unassignedCount' => $unassignedCount,
        ]);
    }

    // ─────────────────────────────────────────────────────────────────────────
    // UPLOAD
    // ─────────────────────────────────────────────────────────────────────────

    public function upload(Request $request, array $params = []): void
    {
        // Detect truncated POST: if Content-Length > 0 but $_POST and $_FILES are both
        // empty, PHP has silently discarded the body (post_max_size exceeded).
        $contentLength = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0);
        if ($contentLength > 0 && empty($_POST) && empty($_FILES)) {
            $maxSize = ini_get('post_max_size') ?: '?';
            $isAjax = (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest');
            if ($isAjax) {
                Response::json([
                    'success' => false,
                    'error'   => __('messages.upload_too_large', ['max' => $maxSize]),
                ], 413);
                return;
            }
            http_response_code(413);
            echo "Upload too large (post_max_size={$maxSize}).";
            exit;
        }

        $this->validateCsrf();

        @set_time_limit(120); // Allow time for multi-file uploads with image processing

        $rawFile  = $request->file('media_file') ?? [];
        $folderId = ((int) $request->input('folder_id', 0)) ?: null;

        // Determine upload subdirectory
        $subdir = 'media';
        if ($folderId) {
            $folder = MediaFolder::find($folderId);
            if ($folder) {
                $subdir = 'media/' . $folder['slug'];
            }
        }

        // Normalise to an array of individual file entries (supports single + multi-file input)
        $files = self::normaliseFileInput($rawFile);

        if (empty($files)) {
            if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') {
                Response::json(['success' => false, 'error' => 'No file provided.'], 422);
                return;
            }
            set_flash('error', __('messages.file_required'));
            Response::redirect($this->backUrl(admin_url('media'), $folderId));
            return;
        }

        // Cap at 10 files per batch to prevent memory exhaustion (ADR-065)
        $files        = array_slice($files, 0, 10);
        $uploadsDir   = UploadService::uploadsDir();
        $successCount = 0;
        $errorCount   = 0;
        $userId       = $this->auth()->userId();

        foreach ($files as $file) {
            // Skip files that failed PHP upload (e.g. exceeds upload_max_filesize)
            if (empty($file['tmp_name']) || ($file['error'] ?? 0) !== UPLOAD_ERR_OK) {
                $errorCount++;
                continue;
            }
            // Videos: skip thumbnail/WebP generation (handled inside UploadService)
            $tmpMime  = mime_content_type($file['tmp_name']) ?: '';
            $isVideo  = UploadService::isVideoMime($tmpMime);
            $relPath  = UploadService::upload($file, $subdir, 'media', !$isVideo, !$isVideo);

            if ($relPath === null) {
                $errorCount++;
                continue;
            }

            // Build a clean title from the original filename
            $rawTitle = pathinfo($file['name'] ?? '', PATHINFO_FILENAME);
            $title    = ucwords(strtolower(preg_replace('/[_\-]+/', ' ', $rawTitle)));

            $fileSize = (int) @filesize($uploadsDir . $relPath);
            $mime     = mime_content_type($uploadsDir . $relPath) ?: 'application/octet-stream';

            Media::create([
                'folder_id'     => $folderId,
                'filename'      => basename($relPath),
                'original_name' => $file['name'] ?? basename($relPath),
                'file_path'     => $relPath,
                'file_type'     => $mime,
                'file_size'     => $fileSize,
                'title'         => $title,
                'alt_text'      => null,
                'description'   => null,
                'video_poster'  => null,
                'uploaded_by'   => $userId,
            ]);

            $successCount++;
        }

        // Build a meaningful flash message covering all outcomes
        $total = $successCount + $errorCount;
        $isAjax = (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest');

        if ($successCount > 0 && $errorCount === 0) {
            if ($isAjax) {
                // Return relative path (without /uploads/ prefix) – templates add the prefix themselves
                $lastUrl = $relPath !== null ? ltrim($relPath, '/') : '';
                Response::json(['success' => true, 'url' => $lastUrl]);
                return;
            }
            set_flash('success', __('messages.file_uploaded'));
        } elseif ($successCount > 0 && $errorCount > 0) {
            if ($isAjax) {
                $lastUrl = $relPath !== null ? ltrim($relPath, '/') : '';
                Response::json(['success' => true, 'url' => $lastUrl, 'warning' => "{$errorCount} file(s) could not be processed."]);
                return;
            }
            set_flash('warning', "{$successCount} file(s) uploaded; {$errorCount} could not be processed. Check file types and sizes.");
        } else {
            if ($isAjax) {
                Response::json(['success' => false, 'error' => 'Upload failed. Check file type and size.'], 422);
                return;
            }
            // All failed – UploadService set a flash error for the last individual failure;
            // override with a clearer batch-failure message when multiple files were attempted.
            if ($total > 1) {
                set_flash('error', __('messages.all_uploads_failed', ['count' => $total]));
            }
            // For single-file failure, UploadService's own error flash is sufficient
        }

        Response::redirect($this->backUrl(admin_url('media'), $folderId));
    }

    // ─────────────────────────────────────────────────────────────────────────
    // INTERNAL HELPERS (continued)
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Normalise a $_FILES entry to an array of individual file arrays.
     *
     * PHP's multi-file upload creates a parallel-array structure inside a single
     * $_FILES key. This helper flattens it into an array of per-file arrays that
     * UploadService::upload() can process one by one.
     *
     * Handles both single-file (string 'name') and multi-file (array 'name') inputs.
     * Empty / no-file entries are filtered out.
     *
     * @param  array   $rawFile  Entry from $_FILES / $request->file()
     * @return array[]           Array of individual per-file arrays
     */
    private static function normaliseFileInput(array $rawFile): array
    {
        if (empty($rawFile)) {
            return [];
        }

        // Multi-file input: PHP delivers parallel arrays for each key
        if (is_array($rawFile['name'] ?? null)) {
            $files = [];
            foreach ($rawFile['name'] as $i => $name) {
                $error = $rawFile['error'][$i] ?? UPLOAD_ERR_NO_FILE;
                // Skip entries with no file selected
                if ($error === UPLOAD_ERR_NO_FILE || empty($name)) {
                    continue;
                }
                $files[] = [
                    'name'     => $name,
                    'tmp_name' => $rawFile['tmp_name'][$i] ?? '',
                    'error'    => $error,
                    'size'     => $rawFile['size'][$i]     ?? 0,
                    'type'     => $rawFile['type'][$i]     ?? '',
                ];
            }
            return $files;
        }

        // Single-file input: return as a one-element array
        return [$rawFile];
    }

    // ─────────────────────────────────────────────────────────────────────────
    // EDIT / UPDATE METADATA
    // ─────────────────────────────────────────────────────────────────────────

    public function edit(Request $request, array $params = []): void
    {
        $id    = (int) ($params['id'] ?? 0);
        $media = Media::find($id);

        if (!$media) {
            set_flash('error', __('messages.not_found', ['item' => 'Media']));
            Response::redirect(admin_url('media'));
            return;
        }

        $folders = MediaFolder::all('name');

        // Determine media kind
        $fileMime = $media['file_type'] ?? '';
        $fileExt  = strtolower(pathinfo($media['file_path'] ?? '', PATHINFO_EXTENSION));
        $isVideo  = str_starts_with($fileMime, 'video/') || UploadService::isVideoExtension($fileExt);
        $isImage  = !$isVideo && (str_starts_with($fileMime, 'image/') || in_array($fileExt, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'ico', 'avif'], true));

        // Image dimensions (raster images only; SVG/ICO/video silently returns null)
        $dimensions = null;
        if ($isImage) {
            $absPath = UploadService::uploadsDir() . ltrim($media['file_path'], '/');
            if (is_file($absPath)) {
                $imgInfo = @getimagesize($absPath);
                if ($imgInfo && $imgInfo[0] > 0) {
                    $dimensions = $imgInfo[0] . ' × ' . $imgInfo[1] . ' px';
                }
            }
        }

        $this->renderAdmin('media/edit', [
            'pageTitle'  => __('admin.media_edit.title'),
            'auth'       => $this->auth(),
            'csrf'       => $this->csrf(),
            'media'      => $media,
            'folders'    => $folders,
            'dimensions' => $dimensions,
            'isVideo'    => $isVideo,
            'isImage'    => $isImage,
        ]);
    }

    public function update(Request $request, array $params = []): void
    {
        $this->validateCsrf();

        $id    = (int) ($params['id'] ?? 0);
        $media = Media::find($id);

        if (!$media) {
            set_flash('error', __('messages.not_found', ['item' => 'Media']));
            Response::redirect(admin_url('media'));
            return;
        }

        $folderId = ((int) $request->input('folder_id', 0)) ?: null;

        $updateData = [
            'folder_id'   => $folderId,
            'title'       => trim($request->input('title', '')),
            'alt_text'    => trim($request->input('alt_text', '')),
            'description' => trim($request->input('description', '')),
        ];

        // Video poster: allow clearing or setting a new path from the media picker
        $videoPosterInput = $request->input('video_poster', null);
        if ($videoPosterInput !== null) {
            $updateData['video_poster'] = trim($videoPosterInput) ?: null;
        }

        Media::updateById($id, $updateData);

        set_flash('success', __('messages.file_updated'));
        Response::redirect(admin_url('media/edit/' . $id));
    }

    // ─────────────────────────────────────────────────────────────────────────
    // DELETE
    // ─────────────────────────────────────────────────────────────────────────

    public function delete(Request $request, array $params = []): void
    {
        $this->validateCsrf();

        $id    = (int) ($params['id'] ?? 0);
        $media = Media::find($id);

        if (!$media) {
            set_flash('error', __('messages.not_found', ['item' => 'Media']));
            Response::redirect(admin_url('media'));
            return;
        }

        // Delete physical files (original + thumbnail + WebP companions)
        UploadService::delete($media['file_path'], true, true);

        // Remove DB record
        Media::deleteById($id);

        set_flash('success', __('messages.file_deleted'));

        $ref = $request->input('ref', '');
        Response::redirect($ref ?: admin_url('media'));
    }

    // ─────────────────────────────────────────────────────────────────────────
    // MOVE (JSON, used by drag-and-drop or inline folder assign)
    // ─────────────────────────────────────────────────────────────────────────

    public function move(Request $request, array $params = []): void
    {
        $this->validateCsrf();

        $id       = (int) ($params['id'] ?? 0);
        $folderId = ((int) $request->input('folder_id', 0)) ?: null;

        if (!Media::find($id)) {
            Response::json(['ok' => false, 'error' => 'Not found'], 404);
            return;
        }

        Media::updateById($id, ['folder_id' => $folderId]);
        Response::json(['ok' => true]);
    }

    // ─────────────────────────────────────────────────────────────────────────
    // FOLDER MANAGEMENT
    // ─────────────────────────────────────────────────────────────────────────

    public function createFolder(Request $request, array $params = []): void
    {
        $this->validateCsrf();

        $name = trim($request->input('name', ''));

        if ($name === '') {
            set_flash('error', __('messages.error.folder_name_empty'));
            Response::redirect(admin_url('media'));
            return;
        }

        $slug = slugify($name);
        $base = $slug;
        $n    = 1;

        while (MediaFolder::slugExists($slug)) {
            $slug = $base . '-' . $n++;
        }

        MediaFolder::create([
            'name'       => $name,
            'slug'       => $slug,
            'parent_id'  => null,
            'sort_order' => 0,
        ]);

        set_flash('success', __('messages.folder_created'));
        Response::redirect(admin_url('media'));
    }

    public function deleteFolder(Request $request, array $params = []): void
    {
        $this->validateCsrf();

        $id     = (int) ($params['id'] ?? 0);
        $folder = MediaFolder::find($id);

        if (!$folder) {
            set_flash('error', __('messages.not_found', ['item' => 'Folder']));
            Response::redirect(admin_url('media'));
            return;
        }

        // FK ON DELETE SET NULL: media in this folder becomes unassigned
        MediaFolder::deleteById($id);

        set_flash('success', __('messages.folder_deleted'));
        Response::redirect(admin_url('media'));
    }

    public function renameFolder(Request $request, array $params = []): void
    {
        $this->validateCsrf();

        $id     = (int) ($params['id'] ?? 0);
        $folder = MediaFolder::find($id);

        if (!$folder) {
            set_flash('error', __('messages.not_found', ['item' => 'Folder']));
            Response::redirect(admin_url('media'));
            return;
        }

        $name = trim($request->input('name', ''));

        if ($name === '') {
            set_flash('error', __('messages.error.folder_name_empty'));
            Response::redirect(admin_url('media') . '?folder=' . $id);
            return;
        }

        MediaFolder::updateById($id, ['name' => $name]);

        set_flash('success', __('messages.folder_renamed'));
        Response::redirect(admin_url('media') . '?folder=' . $id);
    }

    // ─────────────────────────────────────────────────────────────────────────
    // MEDIA PICKER – JSON endpoint for WYSIWYG editor
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Returns a JSON list of images from iw_media for the editor media picker.
     * Replaces the old filesystem-scan approach.
     *
     * GET params: q (search), folder (folder_id filter)
     */
    public function pickerImages(Request $request, array $params = []): void
    {
        $q            = trim($request->query('q', ''));
        $folderId     = $request->query('folder', '');
        $folderFilter = ($folderId === '') ? null : (int) $folderId;

        // Limit picker to images only, up to 80 per call
        $result = Media::search($q, $folderFilter, 'image', 1, 80);

        $images = array_map(static function (array $item): array {
            $relPath = $item['file_path'];
            return [
                'id'           => (int) $item['id'],
                'url'          => ltrim($relPath, '/'),
                'thumb_url'    => thumb_url($relPath),
                'name'         => $item['title'] ?: pathinfo($item['original_name'], PATHINFO_FILENAME),
                'original_name'=> $item['original_name'],
                'alt'          => $item['alt_text'] ?? '',
                'folder_id'    => $item['folder_id'] ? (int) $item['folder_id'] : null,
                'folder_name'  => $item['folder_name'] ?? null,
            ];
        }, $result['items']);

        $folders = MediaFolder::all('name');

        Response::json([
            'images'  => $images,
            'folders' => array_map(
                static fn($f) => ['id' => (int) $f['id'], 'name' => $f['name']],
                $folders
            ),
        ]);
    }

    /**
     * Returns a JSON list of videos from iw_media for the hero video picker.
     *
     * GET params: q (search), folder (folder_id filter)
     */
    public function pickerVideos(Request $request, array $params = []): void
    {
        $q            = trim($request->query('q', ''));
        $folderId     = $request->query('folder', '');
        $folderFilter = ($folderId === '') ? null : (int) $folderId;

        // Limit picker to videos only, up to 80 per call
        $result = Media::search($q, $folderFilter, 'video', 1, 80);

        $videos = array_map(static function (array $item): array {
            $relPath    = $item['file_path'];
            $posterPath = $item['video_poster'] ?? null;
            return [
                'id'           => (int) $item['id'],
                'url'          => ltrim($relPath, '/'),
                'poster_url'   => $posterPath ? ltrim($posterPath, '/') : null,
                'name'         => $item['title'] ?: pathinfo($item['original_name'], PATHINFO_FILENAME),
                'original_name'=> $item['original_name'],
                'file_type'    => $item['file_type'],
                'file_size'    => (int) $item['file_size'],
                'folder_id'    => $item['folder_id'] ? (int) $item['folder_id'] : null,
                'folder_name'  => $item['folder_name'] ?? null,
            ];
        }, $result['items']);

        $folders = MediaFolder::all('name');

        Response::json([
            'videos'  => $videos,
            'total'   => $result['total'],
            'folders' => array_map(
                static fn($f) => ['id' => (int) $f['id'], 'name' => $f['name']],
                $folders
            ),
        ]);
    }

    // ─────────────────────────────────────────────────────────────────────────
    // MEDIA SYNC (FTP uploads + orphan cleanup)
    // ─────────────────────────────────────────────────────────────────────────

    public function sync(Request $request, array $params = []): void
    {
        $this->validateCsrf();
        @set_time_limit(120);

        $syncService = new \App\Services\MediaSyncService();
        $result = $syncService->sync();

        $parts = [];
        if ($result['imported'] > 0) {
            $parts[] = __('admin.media.sync_imported', ['count' => $result['imported']]);
        }
        if ($result['removed'] > 0) {
            $parts[] = __('admin.media.sync_removed', ['count' => $result['removed']]);
        }
        if (empty($parts) && empty($result['errors'])) {
            $parts[] = __('admin.media.sync_up_to_date');
        }

        if (!empty($result['errors'])) {
            set_flash('error', implode('; ', $result['errors']));
        } else {
            set_flash('success', implode(' ', $parts));
        }

        Response::redirect(admin_url('media'));
    }

    // ─────────────────────────────────────────────────────────────────────────
    // LEGACY IMAGE OPTIMIZATION (Phase 3e – unchanged)
    // ─────────────────────────────────────────────────────────────────────────

    public function optimize(Request $request, array $params = []): void
    {
        $stats = $this->scanForMissing();

        $this->renderAdmin('media/optimize', [
            'pageTitle' => __('admin.media_optimize.title'),
            'auth'      => $this->auth(),
            'csrf'      => $this->csrf(),
            'stats'     => $stats,
        ]);
    }

    public function runOptimize(Request $request, array $params = []): void
    {
        $this->validateCsrf();

        @set_time_limit(120);

        $action   = $request->input('action', 'both');
        $doThumbs = in_array($action, ['thumbs', 'both'], true);
        $doWebp   = in_array($action, ['webp',   'both'], true);

        $thumbsGenerated = $webpGenerated = $thumbsSkipped = $webpSkipped = $errors = 0;
        $uploadsDir = UploadService::uploadsDir();

        foreach ($this->collectImages() as $relPath) {
            if ($doThumbs) {
                $thumbRel = UploadService::thumbPath($relPath);
                if ($thumbRel !== null) {
                    if (is_file($uploadsDir . $thumbRel)) {
                        $thumbsSkipped++;
                    } elseif (UploadService::generateThumbnail($relPath)) {
                        $thumbsGenerated++;
                    } else {
                        $errors++;
                    }
                }
            }
            if ($doWebp) {
                $webpRel = UploadService::webpPath($relPath);
                if ($webpRel !== null) {
                    if (is_file($uploadsDir . $webpRel)) {
                        $webpSkipped++;
                    } elseif (UploadService::generateWebp($relPath)) {
                        $webpGenerated++;
                    } else {
                        $errors++;
                    }
                }
            }
        }

        $parts = [];
        if ($doThumbs) {
            $parts[] = "{$thumbsGenerated} thumbnail(s) generated"
                     . ($thumbsSkipped > 0 ? ", {$thumbsSkipped} already existed" : '');
        }
        if ($doWebp) {
            $parts[] = "{$webpGenerated} WebP file(s) generated"
                     . ($webpSkipped > 0 ? ", {$webpSkipped} already existed" : '');
        }
        if ($errors > 0) {
            $parts[] = "{$errors} error(s) — GD unavailable or source unreadable";
        }

        set_flash(
            $errors > 0 ? 'warning' : 'success',
            'Optimization complete: ' . implode('; ', $parts) . '.'
        );
        Response::redirect(admin_url('media/optimize'));
    }

    // ─────────────────────────────────────────────────────────────────────────
    // PRIVATE HELPERS
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Build redirect URL back to media manager, optionally keeping folder context.
     */
    private function backUrl(string $base, ?int $folderId): string
    {
        return $folderId ? $base . '?folder=' . $folderId : $base;
    }

    /**
     * Collect relative image paths from uploads/ for the optimisation tool.
     *
     * Covers:
     *  – blog/         (flat scan, no subdirs)
     *  – gallery/      (flat scan, no subdirs)
     *  – media/        (root files + one level of named folder subdirs, thumbs/ excluded)
     *
     * The thumbs/ subdirectories are intentionally excluded: they are derived files
     * managed by UploadService and must not be treated as source originals.
     *
     * @return string[]  Relative paths such as 'blog/blog_abc.jpg', 'media/team/media_xyz.png'
     */
    private function collectImages(): array
    {
        $uploadsDir  = UploadService::uploadsDir();
        $allowedExts = ['jpg', 'jpeg', 'png', 'gif'];
        $images      = [];

        // ── blog/ and gallery/: flat scan only ────────────────────────────
        foreach (['blog', 'gallery'] as $subdir) {
            $dir = $uploadsDir . $subdir . '/';
            if (!is_dir($dir)) {
                continue;
            }
            foreach (scandir($dir) ?: [] as $file) {
                if ($file === '.' || $file === '..') {
                    continue;
                }
                $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
                if (in_array($ext, $allowedExts, true) && is_file($dir . $file)) {
                    $images[] = $subdir . '/' . $file;
                }
            }
        }

        // ── media/: root files + one level of named folder subdirs ────────
        // Excludes thumbs/ at every level (derived files, not source originals).
        $mediaBase = $uploadsDir . 'media/';
        if (is_dir($mediaBase)) {
            foreach (scandir($mediaBase) ?: [] as $entry) {
                if ($entry === '.' || $entry === '..') {
                    continue;
                }

                $fullEntry = $mediaBase . $entry;

                // Root-level files in media/
                if (is_file($fullEntry)) {
                    $ext = strtolower(pathinfo($entry, PATHINFO_EXTENSION));
                    if (in_array($ext, $allowedExts, true)) {
                        $images[] = 'media/' . $entry;
                    }
                    continue;
                }

                // One-level subdirectories – skip thumbs/ (reserved by UploadService)
                if (is_dir($fullEntry) && $entry !== 'thumbs') {
                    foreach (scandir($fullEntry . '/') ?: [] as $file) {
                        if ($file === '.' || $file === '..') {
                            continue;
                        }
                        $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
                        if (in_array($ext, $allowedExts, true) && is_file($fullEntry . '/' . $file)) {
                            $images[] = 'media/' . $entry . '/' . $file;
                        }
                    }
                }
            }
        }

        return $images;
    }

    /**
     * Scan uploads for missing thumbnails and WebP companions.
     *
     * @return array{total: int, missing_thumbs: int, missing_webp: int}
     */
    private function scanForMissing(): array
    {
        $images     = $this->collectImages();
        $uploadsDir = UploadService::uploadsDir();
        $missingThumbs = $missingWebp = 0;

        foreach ($images as $relPath) {
            $thumbRel = UploadService::thumbPath($relPath);
            if ($thumbRel !== null && !is_file($uploadsDir . $thumbRel)) {
                $missingThumbs++;
            }
            $webpRel = UploadService::webpPath($relPath);
            if ($webpRel !== null && !is_file($uploadsDir . $webpRel)) {
                $missingWebp++;
            }
        }

        return [
            'total'          => count($images),
            'missing_thumbs' => $missingThumbs,
            'missing_webp'   => $missingWebp,
        ];
    }
}
