<?php

namespace App\Services;

/**
 * ITSWEBER CMS – UploadService
 *
 * Centralised file upload handling reusable across all CMS modules
 * (Blog, Gallery, Branding, Media Manager, etc.).
 *
 * Responsibilities:
 *  - MIME-type validation (images and videos)
 *  - File-size limit enforcement (separate limits for images / videos)
 *  - Secure filename generation (no original filenames in the filesystem)
 *  - Target directory creation
 *  - Secure file deletion (path-traversal guard)
 *  - Automatic resize of large originals (GD, graceful fallback)
 *  - Optional thumbnail generation in a thumbs/ subdirectory
 */
class UploadService
{
    /** Allowed image MIME types and their canonical extensions. */
    private const ALLOWED_IMAGE_MIMES = [
        'image/jpeg'               => 'jpg',
        'image/png'                => 'png',
        'image/gif'                => 'gif',
        'image/webp'               => 'webp',
        'image/svg+xml'            => 'svg',
        'image/x-icon'             => 'ico',
        'image/vnd.microsoft.icon' => 'ico',
        'image/heic'               => 'heic',
        'image/heif'               => 'heif',
    ];

    /** Allowed video MIME types and their canonical extensions. */
    private const ALLOWED_VIDEO_MIMES = [
        'video/mp4'       => 'mp4',
        'video/webm'      => 'webm',
        'video/ogg'       => 'ogv',
        'video/quicktime' => 'mov',
    ];

    /**
     * Combined MIME map (images + videos) – kept for backwards compatibility
     * with callers that pass any file type to upload().
     */
    private const ALLOWED_MIMES = self::ALLOWED_IMAGE_MIMES + self::ALLOWED_VIDEO_MIMES;

    /** Maximum upload size for images: 5 MB. */
    private const MAX_SIZE = 5 * 1024 * 1024;

    /** Maximum upload size for videos: 200 MB. */
    private const MAX_VIDEO_SIZE = 200 * 1024 * 1024;

    /**
     * Maximum width (px) for stored originals.
     * Images wider than this are down-scaled on upload (maintains aspect ratio).
     */
    private const MAX_ORIGINAL_WIDTH = 1920;

    /** Thumbnail dimensions (px). */
    private const THUMB_MAX_WIDTH  = 400;
    private const THUMB_MAX_HEIGHT = 300;

    /** JPEG/WEBP compression quality (0-100). */
    private const RESIZE_QUALITY = 85;

    // -------------------------------------------------------------------------
    // UPLOAD
    // -------------------------------------------------------------------------

    /**
     * Handle a file upload from a $_FILES-style array.
     *
     * Returns the relative path (e.g. "gallery/img_abc123de.jpg") on success,
     * or null on failure. Flash error messages are set automatically.
     *
     * If $withThumb is true a scaled-down thumbnail is created in
     * {subdir}/thumbs/{filename} alongside the original.
     *
     * If $withWebp is true a WebP companion is created alongside the original
     * (and alongside the thumbnail when $withThumb is also true).
     * WebP files are derived by replacing the extension: e.g. img_abc.jpg → img_abc.webp
     * WebP generation is non-critical: it is skipped silently when GD's imagewebp()
     * is unavailable or the conversion fails.
     *
     * @param array  $file      Entry from $_FILES (via $request->file('field'))
     * @param string $subdir    Subdirectory inside public/uploads/ (e.g. 'blog', 'gallery')
     * @param string $prefix    Filename prefix (e.g. 'blog', 'gallery')
     * @param bool   $withThumb Whether to also generate a thumbnail
     * @param bool   $withWebp  Whether to also generate a WebP companion
     */
    public static function upload(
        array  $file,
        string $subdir,
        string $prefix    = 'img',
        bool   $withThumb = false,
        bool   $withWebp  = false
    ): ?string {
        // No file selected – not an error, just nothing to do
        if (empty($file) || empty($file['name']) || ($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
            return null;
        }

        if ($file['error'] !== UPLOAD_ERR_OK) {
            set_flash('error', 'Upload error (code ' . (int) $file['error'] . '). Please try again.');
            return null;
        }

        $mime    = mime_content_type($file['tmp_name']);
        $isVideo = array_key_exists($mime, self::ALLOWED_VIDEO_MIMES);
        $isImage = array_key_exists($mime, self::ALLOWED_IMAGE_MIMES);

        if (!$isImage && !$isVideo) {
            set_flash('error', 'Invalid file type. Images: JPG, PNG, GIF, WEBP, SVG, ICO, HEIC. Videos: MP4, WEBM, OGV, MOV.');
            return null;
        }

        // Check extension against admin-configurable whitelist (if set)
        $ext = self::ALLOWED_MIMES[$mime];
        $allowedSetting = function_exists('setting') ? setting('upload_allowed_extensions', '') : '';
        if ($allowedSetting !== '') {
            $allowedList = array_map('trim', array_map('strtolower', explode(',', $allowedSetting)));
            if (!in_array(strtolower($ext), $allowedList, true)) {
                set_flash('error', 'File type ".' . $ext . '" is not allowed. Permitted: ' . $allowedSetting);
                return null;
            }
        }

        // Check file size against admin-configurable max (images only, videos keep 200 MB)
        $adminMaxMb = function_exists('setting') ? (int) setting('upload_max_filesize_mb', 0) : 0;
        $maxSize = $isVideo ? self::MAX_VIDEO_SIZE : ($adminMaxMb > 0 ? $adminMaxMb * 1024 * 1024 : self::MAX_SIZE);
        if ((int) $file['size'] > $maxSize) {
            $label = $isVideo ? '200 MB' : ($adminMaxMb > 0 ? $adminMaxMb . ' MB' : '5 MB');
            set_flash('error', ($isVideo ? 'Video' : 'Image') . ' too large. Maximum size is ' . $label . '.');
            return null;
        }

        $filename = $prefix . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
        $subdir   = trim($subdir, '/');
        $destDir  = self::uploadsDir() . $subdir . '/';

        if (!is_dir($destDir) && !mkdir($destDir, 0755, true)) {
            set_flash('error', 'Could not create upload directory. Check server permissions.');
            return null;
        }

        $destFile = $destDir . $filename;

        if (!move_uploaded_file($file['tmp_name'], $destFile)) {
            set_flash('error', 'Upload failed. Check folder permissions for public/uploads/' . $subdir . '/.');
            return null;
        }

        // SVG sanitization (XSS defense): rewrite the stored file with a whitelist-cleaned
        // version. Reject if parsing fails or nothing usable remains after cleaning.
        if ($mime === 'image/svg+xml') {
            $raw  = @file_get_contents($destFile);
            $safe = \App\Services\Security\SvgSanitizer::sanitize((string) $raw);
            if ($safe === null) {
                @unlink($destFile);
                set_flash('error', 'SVG file contains unsafe content and was rejected.');
                return null;
            }
            if (file_put_contents($destFile, $safe) === false) {
                @unlink($destFile);
                set_flash('error', 'Could not rewrite sanitized SVG.');
                return null;
            }
            return $subdir . '/' . $filename;
        }

        // Videos are stored as-is – no image processing applies.
        if ($isVideo) {
            return $subdir . '/' . $filename;
        }

        // HEIC/HEIF → JPEG conversion (requires ImageMagick).
        // After conversion the stored file becomes a JPEG; all downstream GD
        // processing (resize, thumb, WebP) works with the converted file.
        if (in_array($mime, ['image/heic', 'image/heif'], true)) {
            $converted = self::convertHeicToJpeg($destFile);
            if ($converted === null) {
                @unlink($destFile);
                set_flash('error', 'HEIC/HEIF conversion failed. The server requires ImageMagick with HEIC support to process this format.');
                return null;
            }
            // Replace the HEIC file with the JPEG version
            $destFile = $converted;
            $filename = basename($converted);
        }

        // Down-scale large originals (non-critical: skip silently if GD unavailable)
        self::resizeInPlace($destFile, self::MAX_ORIGINAL_WIDTH);

        $relPath = $subdir . '/' . $filename;

        // GD processing: thumbnails and WebP companions.
        // For raster images (JPEG/PNG/GIF/WEBP) we surface failures as a warning flash
        // so admins are aware when GD is unavailable or an image is unreadable.
        // For SVG and ICO the skip is expected and silent (GD cannot process vector formats).
        // After HEIC conversion, the file is now a JPEG – treat as raster
        $rasterMimes  = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/heic', 'image/heif'];
        $isRaster     = in_array($mime, $rasterMimes, true);
        $gdWarnings   = [];

        // Optional thumbnail
        if ($withThumb) {
            if (!self::generateThumbnail($relPath) && $isRaster) {
                $gdWarnings[] = 'thumbnail';
            }
        }

        // Optional WebP companion for original (and for thumbnail if generated)
        if ($withWebp) {
            if (!self::generateWebp($relPath) && $isRaster) {
                $gdWarnings[] = 'WebP companion';
            }
            if ($withThumb) {
                $thumbRel = self::thumbPath($relPath);
                if ($thumbRel !== null) {
                    self::generateWebp($thumbRel);
                }
            }

            // Also generate AVIF companion if supported by GD
            if (function_exists('imageavif')) {
                if (!self::generateAvif($relPath) && $isRaster) {
                    $gdWarnings[] = 'AVIF companion';
                }
                if ($withThumb && $thumbRel !== null) {
                    self::generateAvif($thumbRel);
                }
            }
        }

        // Surface GD processing issues to admins without blocking the upload
        if (!empty($gdWarnings)) {
            set_flash(
                'warning',
                'File saved, but ' . implode(' and ', $gdWarnings) . ' generation failed. '
                    . 'Verify the GD extension is available and supports the uploaded format.'
            );
        }

        return $relPath;
    }

    // -------------------------------------------------------------------------
    // DELETE
    // -------------------------------------------------------------------------

    /**
     * Delete an uploaded file by its relative path.
     *
     * Includes a path-traversal guard: only files inside the uploads directory
     * will be deleted.
     *
     * If $withThumb is true the corresponding thumbnail (if it exists) is
     * also removed.
     *
     * If $withWebp is true the WebP companion(s) are also removed:
     * – the WebP of the original
     * – the WebP of the thumbnail (when $withThumb is also true)
     *
     * @param string|null $relativePath Relative path such as "blog/blog_abc123.jpg"
     * @param bool        $withThumb    Whether to also delete the thumbnail
     * @param bool        $withWebp     Whether to also delete the WebP companion(s)
     */
    public static function delete(?string $relativePath, bool $withThumb = false, bool $withWebp = false): void
    {
        if (empty($relativePath)) {
            return;
        }

        self::deleteFile($relativePath);

        if ($withThumb) {
            $thumbRel = self::thumbPath($relativePath);
            self::deleteFile($thumbRel);

            if ($withWebp && $thumbRel !== null) {
                self::deleteFile(self::webpPath($thumbRel));
                self::deleteFile(self::avifPath($thumbRel));
            }
        }

        if ($withWebp) {
            self::deleteFile(self::webpPath($relativePath));
            self::deleteFile(self::avifPath($relativePath));
        }
    }

    // -------------------------------------------------------------------------
    // THUMBNAIL HELPERS
    // -------------------------------------------------------------------------

    /**
     * Derive the relative thumbnail path for a given original path.
     *
     * Example: "gallery/img_abc.jpg" -> "gallery/thumbs/img_abc.jpg"
     *
     * @param  string|null $relativePath Original relative path
     * @return string|null               Thumbnail relative path, or null on empty input
     */
    public static function thumbPath(?string $relativePath): ?string
    {
        if (empty($relativePath)) {
            return null;
        }

        $dir      = ltrim(dirname($relativePath), '.');
        $basename = basename($relativePath);

        if ($dir !== '' && $dir !== '/') {
            return $dir . '/thumbs/' . $basename;
        }

        return 'thumbs/' . $basename;
    }

    /**
     * Generate (or regenerate) a thumbnail for an already-uploaded file.
     *
     * Returns true on success, false if GD is unavailable or the source
     * cannot be read.
     *
     * @param string $relativePath Relative path of the original (e.g. "gallery/img_abc.jpg")
     */
    public static function generateThumbnail(string $relativePath): bool
    {
        $thumbRel = self::thumbPath($relativePath);
        if ($thumbRel === null) {
            return false;
        }

        $uploadsDir = self::uploadsDir();
        $sourcePath = $uploadsDir . ltrim($relativePath, '/');
        $thumbPath  = $uploadsDir . $thumbRel;
        $thumbDir   = dirname($thumbPath);

        if (!is_file($sourcePath)) {
            return false;
        }

        if (!is_dir($thumbDir) && !mkdir($thumbDir, 0755, true)) {
            return false;
        }

        return self::resizeImage(
            $sourcePath,
            $thumbPath,
            self::THUMB_MAX_WIDTH,
            self::THUMB_MAX_HEIGHT
        );
    }

    // -------------------------------------------------------------------------
    // WEBP HELPERS
    // -------------------------------------------------------------------------

    /**
     * Derive the relative WebP companion path for a given original path.
     *
     * The WebP companion uses the same path but with the extension replaced
     * by ".webp". If the source is already a .webp file the path is returned
     * unchanged (nothing to convert).
     *
     * Example: "gallery/img_abc.jpg"          → "gallery/img_abc.webp"
     *          "gallery/thumbs/img_abc.png"   → "gallery/thumbs/img_abc.webp"
     *          "gallery/img_abc.webp"          → "gallery/img_abc.webp"  (unchanged)
     *
     * @param  string|null $relativePath Original relative path
     * @return string|null               WebP relative path, or null on empty input
     */
    public static function webpPath(?string $relativePath): ?string
    {
        if (empty($relativePath)) {
            return null;
        }

        // Already WebP – return as-is
        if (strtolower(pathinfo($relativePath, PATHINFO_EXTENSION)) === 'webp') {
            return $relativePath;
        }

        return preg_replace('/\.[^.\/]+$/', '.webp', $relativePath);
    }

    /**
     * Generate (or regenerate) a WebP companion for an already-uploaded file.
     *
     * Returns true on success, false if GD is unavailable, the source
     * cannot be read, or the file is already WebP (treat as success).
     *
     * @param string $relativePath Relative path of the source file (e.g. "gallery/img_abc.jpg")
     */
    public static function generateWebp(string $relativePath): bool
    {
        // Already WebP – nothing to do
        if (strtolower(pathinfo($relativePath, PATHINFO_EXTENSION)) === 'webp') {
            return true;
        }

        $webpRel    = self::webpPath($relativePath);
        if ($webpRel === null) {
            return false;
        }

        $uploadsDir = self::uploadsDir();
        $sourcePath = $uploadsDir . ltrim($relativePath, '/');
        $destPath   = $uploadsDir . $webpRel;
        $destDir    = dirname($destPath);

        if (!is_file($sourcePath)) {
            return false;
        }

        if (!is_dir($destDir) && !mkdir($destDir, 0755, true)) {
            return false;
        }

        return self::convertToWebp($sourcePath, $destPath);
    }

    // -------------------------------------------------------------------------
    // AVIF HELPERS
    // -------------------------------------------------------------------------

    /**
     * Derive the relative AVIF companion path for a given original path.
     */
    public static function avifPath(?string $relativePath): ?string
    {
        if (empty($relativePath)) {
            return null;
        }

        $ext = strtolower(pathinfo($relativePath, PATHINFO_EXTENSION));
        if ($ext === 'avif') {
            return $relativePath;
        }

        return preg_replace('/\.[^.\/]+$/', '.avif', $relativePath);
    }

    /**
     * Generate an AVIF companion for an already-uploaded file.
     *
     * Returns true on success, false if GD's imageavif() is unavailable.
     */
    public static function generateAvif(string $relativePath): bool
    {
        if (strtolower(pathinfo($relativePath, PATHINFO_EXTENSION)) === 'avif') {
            return true;
        }

        $avifRel = self::avifPath($relativePath);
        if ($avifRel === null) {
            return false;
        }

        $uploadsDir = self::uploadsDir();
        $sourcePath = $uploadsDir . ltrim($relativePath, '/');
        $destPath   = $uploadsDir . $avifRel;
        $destDir    = dirname($destPath);

        if (!is_file($sourcePath)) {
            return false;
        }

        if (!is_dir($destDir) && !mkdir($destDir, 0755, true)) {
            return false;
        }

        return self::convertToAvif($sourcePath, $destPath);
    }

    /**
     * Convert any supported image to AVIF and save to $dest.
     *
     * Requires PHP 8.1+ with GD AVIF support (imageavif).
     * Returns false if unavailable or the source cannot be read.
     */
    private static function convertToAvif(string $source, string $dest, int $quality = 63, int $speed = 6): bool
    {
        if (!function_exists('imageavif')) {
            return false;
        }

        $info = @getimagesize($source);
        if (!$info) {
            return false;
        }

        [$origW, $origH, $type] = $info;

        $srcImg = match ($type) {
            IMAGETYPE_JPEG => @imagecreatefromjpeg($source),
            IMAGETYPE_PNG  => @imagecreatefrompng($source),
            IMAGETYPE_GIF  => @imagecreatefromgif($source),
            IMAGETYPE_WEBP => @imagecreatefromwebp($source),
            default        => false,
        };

        if (!$srcImg) {
            return false;
        }

        if (in_array($type, [IMAGETYPE_PNG, IMAGETYPE_GIF], true)) {
            $canvas = imagecreatetruecolor($origW, $origH);
            imagealphablending($canvas, false);
            imagesavealpha($canvas, true);
            $transparent = imagecolorallocatealpha($canvas, 0, 0, 0, 127);
            imagefilledrectangle($canvas, 0, 0, $origW, $origH, $transparent);
            imagecopy($canvas, $srcImg, 0, 0, 0, 0, $origW, $origH);
            imagedestroy($srcImg);
            $srcImg = $canvas;
        }

        $result = imageavif($srcImg, $dest, $quality, $speed);
        imagedestroy($srcImg);

        return $result !== false;
    }

    /**
     * Absolute path to the public/uploads/ directory (with trailing slash).
     */
    public static function uploadsDir(): string
    {
        return rtrim($_SERVER['DOCUMENT_ROOT'], '/') . '/uploads/';
    }

    /**
     * Return true if the given MIME type is a supported video format.
     */
    public static function isVideoMime(string $mime): bool
    {
        return array_key_exists($mime, self::ALLOWED_VIDEO_MIMES);
    }

    /**
     * Return true if the given file extension belongs to a supported video format.
     */
    public static function isVideoExtension(string $ext): bool
    {
        return in_array(strtolower($ext), ['mp4', 'webm', 'ogv', 'mov'], true);
    }

    /**
     * Human-readable list of allowed image formats (for error messages / hints).
     */
    public static function allowedImageFormats(): string
    {
        return 'JPG, PNG, GIF, WEBP, SVG, ICO, HEIC';
    }

    /**
     * Human-readable list of allowed video formats (for error messages / hints).
     */
    public static function allowedVideoFormats(): string
    {
        return 'MP4, WEBM, OGV, MOV';
    }

    // -------------------------------------------------------------------------
    // INTERNAL HELPERS
    // -------------------------------------------------------------------------

    /**
     * Resize an image file in-place (overwrite) to fit within $maxWidth.
     * If the image is already smaller, nothing happens.
     * Silently returns if GD is not available.
     */
    private static function resizeInPlace(string $path, int $maxWidth): void
    {
        if (!function_exists('imagecreatefromjpeg') || $maxWidth <= 0) {
            return;
        }

        $info = @getimagesize($path);
        if (!$info) {
            return;
        }

        [$origW] = $info;

        // Only resize if the image is actually wider than the max
        if ($origW <= $maxWidth) {
            return;
        }

        self::resizeImage($path, $path, $maxWidth, 0);
    }

    /**
     * Resize an image from $source to $dest, maintaining the aspect ratio
     * within $maxW x $maxH (pass 0 to skip a dimension constraint).
     *
     * Supports JPEG, PNG, GIF, WEBP via GD.
     * Returns false if GD is unavailable or the source cannot be read.
     */
    private static function resizeImage(
        string $source,
        string $dest,
        int    $maxW,
        int    $maxH,
        int    $quality = self::RESIZE_QUALITY
    ): bool {
        if (!function_exists('imagecreatefromjpeg')) {
            return false; // GD not available
        }

        $info = @getimagesize($source);
        if (!$info) {
            return false;
        }

        [$origW, $origH, $type] = $info;

        if ($origW <= 0 || $origH <= 0) {
            return false;
        }

        // Calculate target dimensions (scale-to-fit within maxW x maxH)
        $newW = $origW;
        $newH = $origH;

        if ($maxW > 0 && $newW > $maxW) {
            $newH = (int) round($newH * $maxW / $newW);
            $newW = $maxW;
        }
        if ($maxH > 0 && $newH > $maxH) {
            $newW = (int) round($newW * $maxH / $newH);
            $newH = $maxH;
        }

        // Nothing to do if dimensions are unchanged and writing to same file
        if ($newW === $origW && $newH === $origH && $source === $dest) {
            return true;
        }

        // Load source image
        $srcImg = match ($type) {
            IMAGETYPE_JPEG => @imagecreatefromjpeg($source),
            IMAGETYPE_PNG  => @imagecreatefrompng($source),
            IMAGETYPE_GIF  => @imagecreatefromgif($source),
            IMAGETYPE_WEBP => @imagecreatefromwebp($source),
            default        => false,
        };

        if (!$srcImg) {
            return false;
        }

        $dstImg = imagecreatetruecolor($newW, $newH);

        // Preserve transparency for PNG / GIF / WEBP
        if (in_array($type, [IMAGETYPE_PNG, IMAGETYPE_GIF, IMAGETYPE_WEBP], true)) {
            imagealphablending($dstImg, false);
            imagesavealpha($dstImg, true);
            $transparent = imagecolorallocatealpha($dstImg, 0, 0, 0, 127);
            imagefilledrectangle($dstImg, 0, 0, $newW, $newH, $transparent);
        }

        imagecopyresampled($dstImg, $srcImg, 0, 0, 0, 0, $newW, $newH, $origW, $origH);

        // Save to destination
        $pngQuality = (int) max(0, min(9, round((100 - $quality) / 11)));
        $result = match ($type) {
            IMAGETYPE_JPEG => imagejpeg($dstImg, $dest, $quality),
            IMAGETYPE_PNG  => imagepng($dstImg, $dest, $pngQuality),
            IMAGETYPE_GIF  => imagegif($dstImg, $dest),
            IMAGETYPE_WEBP => imagewebp($dstImg, $dest, $quality),
            default        => false,
        };

        imagedestroy($srcImg);
        imagedestroy($dstImg);

        return $result !== false;
    }

    /**
     * Convert any supported image to WebP and save to $dest.
     *
     * Supports JPEG, PNG, GIF as source (WEBP source is handled by the caller).
     * Transparency in PNG/GIF/WEBP is preserved via an alpha-enabled canvas.
     * Returns false if GD's imagewebp() is unavailable or the source cannot be read.
     */
    private static function convertToWebp(string $source, string $dest, int $quality = self::RESIZE_QUALITY): bool
    {
        if (!function_exists('imagewebp')) {
            return false; // GD imagewebp not available
        }

        $info = @getimagesize($source);
        if (!$info) {
            return false;
        }

        [$origW, $origH, $type] = $info;

        // WEBP source: plain file copy is sufficient
        if ($type === IMAGETYPE_WEBP) {
            return copy($source, $dest);
        }

        $srcImg = match ($type) {
            IMAGETYPE_JPEG => @imagecreatefromjpeg($source),
            IMAGETYPE_PNG  => @imagecreatefrompng($source),
            IMAGETYPE_GIF  => @imagecreatefromgif($source),
            default        => false,
        };

        if (!$srcImg) {
            return false;
        }

        // For formats with transparency (PNG, GIF), merge onto an alpha-enabled canvas
        // so that WebP output preserves the alpha channel correctly.
        if (in_array($type, [IMAGETYPE_PNG, IMAGETYPE_GIF], true)) {
            $canvas = imagecreatetruecolor($origW, $origH);
            imagealphablending($canvas, false);
            imagesavealpha($canvas, true);
            $transparent = imagecolorallocatealpha($canvas, 0, 0, 0, 127);
            imagefilledrectangle($canvas, 0, 0, $origW, $origH, $transparent);
            imagecopy($canvas, $srcImg, 0, 0, 0, 0, $origW, $origH);
            imagedestroy($srcImg);
            $srcImg = $canvas;
        }

        $result = imagewebp($srcImg, $dest, $quality);
        imagedestroy($srcImg);

        return $result !== false;
    }

    /**
     * Convert a HEIC/HEIF file to JPEG using ImageMagick.
     *
     * Requires the `imagick` PHP extension or the `convert` CLI tool.
     * Returns the path to the new JPEG file on success, or null on failure.
     * The original HEIC file is removed upon successful conversion.
     */
    private static function convertHeicToJpeg(string $heicPath): ?string
    {
        $jpegPath = preg_replace('/\.heic$/i', '.jpg', $heicPath);
        $jpegPath = preg_replace('/\.heif$/i', '.jpg', $jpegPath);

        // Try 1: Imagick PHP extension (preferred, no shell calls)
        if (class_exists('Imagick')) {
            try {
                $img = new \Imagick($heicPath);
                $img->setImageFormat('jpeg');
                $img->setImageCompressionQuality(self::RESIZE_QUALITY);
                $img->writeImage($jpegPath);
                $img->destroy();
                @unlink($heicPath);
                return $jpegPath;
            } catch (\Exception $e) {
                // Fall through to CLI attempt
            }
        }

        // Try 2: CLI convert command (ImageMagick installed on the server)
        $convertBin = self::findConvertBinary();
        if ($convertBin !== null) {
            $cmd = escapeshellarg($convertBin) . ' '
                 . escapeshellarg($heicPath) . ' '
                 . '-quality ' . self::RESIZE_QUALITY . ' '
                 . escapeshellarg($jpegPath) . ' 2>&1';
            exec($cmd, $output, $exitCode);
            if ($exitCode === 0 && is_file($jpegPath)) {
                @unlink($heicPath);
                return $jpegPath;
            }
        }

        return null;
    }

    /**
     * Locate the ImageMagick convert binary.
     * Returns null if not found.
     */
    private static function findConvertBinary(): ?string
    {
        // Common locations
        foreach (['/usr/bin/convert', '/usr/local/bin/convert', '/opt/homebrew/bin/convert'] as $path) {
            if (is_executable($path)) {
                return $path;
            }
        }

        // Try shell which
        $which = @shell_exec('which convert 2>/dev/null');
        if ($which !== null) {
            $which = trim($which);
            if ($which !== '' && is_executable($which)) {
                return $which;
            }
        }

        return null;
    }

    /**
     * Delete a single file by its relative path inside uploads/.
     * Includes path-traversal guard.
     */
    private static function deleteFile(?string $relativePath): void
    {
        if (empty($relativePath)) {
            return;
        }

        $uploadsDir  = self::uploadsDir();
        $uploadsReal = realpath($uploadsDir);
        $fullPath    = $uploadsDir . ltrim($relativePath, '/');
        $realPath    = realpath($fullPath);

        // Security: only delete if the resolved path is inside uploads/
        if ($realPath && $uploadsReal && str_starts_with($realPath, $uploadsReal) && is_file($realPath)) {
            @unlink($realPath);
        }
    }
}
