<?php

declare(strict_types=1);

final class MyronFileRepository
{
    private const DEFAULT_UPLOAD_BYTES = 15_000_000;
    private const AUDIO_UPLOAD_BYTES = 128_000_000;
    private const VIDEO_UPLOAD_BYTES = 512_000_000;

    /**
     * @var array<string, int>
     */
    private const IMAGE_RENDITION_WIDTHS = [
        'thumb' => 320,
        'card' => 400,
        'card-lg' => 960,
        'preview' => 1280,
        'hero' => 1920,
        'xl' => 2560,
    ];

    /**
     * @var array<string, string>
     */
    private const ALLOWED_MIME_TYPES = [
        'image/jpeg' => 'jpg',
        'image/png' => 'png',
        'image/gif' => 'gif',
        'image/webp' => 'webp',
        'image/svg+xml' => 'svg',
        'video/mp4' => 'mp4',
        'video/quicktime' => 'mov',
        'video/webm' => 'webm',
        'video/x-m4v' => 'm4v',
        'audio/mpeg' => 'mp3',
        'audio/mp3' => 'mp3',
        'audio/wav' => 'wav',
        'audio/x-wav' => 'wav',
        'audio/mp4' => 'm4a',
        'audio/x-m4a' => 'm4a',
        'audio/aac' => 'aac',
        'audio/ogg' => 'ogg',
        'application/ogg' => 'ogg',
        'application/pdf' => 'pdf',
        'text/vtt' => 'vtt',
        'text/webvtt' => 'vtt',
        'text/plain' => 'txt',
        'text/csv' => 'csv',
        'application/csv' => 'csv',
        'text/x-csv' => 'csv',
        'application/x-subrip' => 'srt',
        'application/zip' => 'zip',
        'application/msword' => 'doc',
        'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
        'application/vnd.ms-excel' => 'xls',
        'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
    ];

    /**
     * @var array<string, list<string>>
     */
    private const MEDIA_CLASS_EXTENSIONS = [
        'image' => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'],
        'video' => ['mp4', 'mov', 'webm', 'm4v'],
        'audio' => ['mp3', 'wav', 'm4a', 'aac', 'ogg'],
        'document' => ['pdf', 'txt', 'vtt', 'srt', 'csv', 'doc', 'docx', 'xls', 'xlsx', 'zip'],
    ];

    /**
     * @var list<string>
     */
    private const ACCESSIBILITY_ATTACHMENT_EXTENSIONS = ['vtt', 'srt', 'txt'];

    /**
     * @var list<string>
     */
    private const ACCESSIBILITY_ATTACHMENT_MIME_TYPES = ['text/plain', 'text/vtt', 'text/webvtt', 'application/x-subrip'];

    public function __construct(
        private readonly PDO $pdo,
    ) {
    }

    /**
     * @return list<array<string, mixed>>
     */
    public function listFiles(): array
    {
        $statement = $this->pdo->query(
            'SELECT
                files.*,
                users.display_name AS uploaded_by_display_name
             FROM files
             LEFT JOIN users ON users.id = files.uploaded_by_user_id
             ORDER BY files.created_at DESC, files.original_filename ASC'
        );

        $files = [];
        foreach ($statement->fetchAll() as $row) {
            if (!is_array($row)) {
                continue;
            }

            $files[] = $this->decorateFileRow($row);
        }

        return $files;
    }

    /**
     * @return list<array<string, mixed>>
     */
    public function listFilesByMediaClass(string $mediaClass): array
    {
        $normalized = $this->normalizeMediaClass($mediaClass);

        return array_values(array_filter(
            $this->listFiles(),
            fn (array $file): bool => $this->mediaClassForFile($file) === $normalized
        ));
    }

    /**
     * @return array<string, int>
     */
    public function countFilesByMediaClass(): array
    {
        $counts = [
            'image' => 0,
            'video' => 0,
            'audio' => 0,
            'document' => 0,
        ];

        $statement = $this->pdo->query('SELECT mime_type, file_extension, COUNT(*) AS file_count FROM files GROUP BY mime_type, file_extension');
        foreach ($statement->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
            $class = $this->mediaClassForMimeAndExtension(
                (string) ($row['mime_type'] ?? ''),
                (string) ($row['file_extension'] ?? '')
            );
            $counts[$class] = ($counts[$class] ?? 0) + (int) ($row['file_count'] ?? 0);
        }

        return $counts;
    }

    public function getFileById(string $fileId): ?array
    {
        $statement = $this->pdo->prepare(
            'SELECT
                files.*,
                users.display_name AS uploaded_by_display_name
             FROM files
             LEFT JOIN users ON users.id = files.uploaded_by_user_id
             WHERE files.id = :file_id
             LIMIT 1'
        );
        $statement->execute([':file_id' => $fileId]);
        $row = $statement->fetch();

        return is_array($row) ? $this->decorateFileRow($row) : null;
    }

    /**
     * @param array<string, mixed> $uploadedFile
     */
    public function uploadFile(array $uploadedFile, string $uploadedByUserId, array $metadata = [], bool $metadataRequired = false): array
    {
        $uploadedByUserId = trim($uploadedByUserId);
        if ($uploadedByUserId === '') {
            throw new RuntimeException('File uploads require a valid uploader.');
        }

        $error = (int) ($uploadedFile['error'] ?? UPLOAD_ERR_NO_FILE);
        if ($error !== UPLOAD_ERR_OK) {
            throw new RuntimeException(match ($error) {
                UPLOAD_ERR_NO_FILE => 'Choose a file to upload.',
                UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'The uploaded file exceeds the size limit.',
                default => 'File upload failed.',
            });
        }

        $tmpName = trim((string) ($uploadedFile['tmp_name'] ?? ''));
        if ($tmpName === '' || !is_file($tmpName)) {
            throw new RuntimeException('Uploaded file payload is invalid.');
        }

        $originalFilename = $this->sanitizeOriginalFilename((string) ($uploadedFile['name'] ?? 'upload.bin'));
        $originalExtension = strtolower((string) pathinfo($originalFilename, PATHINFO_EXTENSION));
        $fileSize = (int) filesize($tmpName);
        if ($fileSize <= 0) {
            throw new RuntimeException('Uploaded file is empty.');
        }
        $mimeType = $this->detectMimeType($tmpName);
        if (!isset(self::ALLOWED_MIME_TYPES[$mimeType])) {
            throw new RuntimeException('That file type is not allowed for the first files slice.');
        }
        $extension = $this->extensionForUpload($mimeType, $originalExtension);
        $mediaClass = $this->mediaClassForMimeAndExtension($mimeType, $extension);
        $requestedClass = $this->normalizeOptionalMediaClass((string) ($metadata['asset_class'] ?? ''));
        if ($requestedClass !== '' && $requestedClass !== $mediaClass) {
            throw new RuntimeException('The selected file does not match the expected ' . $requestedClass . ' asset type.');
        }
        $effectiveLimit = $this->effectiveUploadLimitForMediaClass($mediaClass);
        if ($fileSize > $effectiveLimit) {
            throw new RuntimeException(
                'Uploaded ' . $mediaClass . ' files must be ' . myronFormatFileSize($effectiveLimit) . ' or smaller on this server.'
            );
        }
        $metadata = $this->normalizeUploadMetadata($metadata, $metadataRequired);

        $fileId = myronGenerateId('file');
        $storedFilename = $fileId . '.' . $extension;
        $storagePath = 'uploads/' . $storedFilename;
        $absoluteDestination = myronUploadsDir() . DIRECTORY_SEPARATOR . $storedFilename;

        myronEnsureDirectory(dirname($absoluteDestination));
        $this->moveIntoStorage($tmpName, $absoluteDestination);

        [$width, $height] = $this->detectImageDimensions($absoluteDestination, $mimeType);
        $this->generateWebpSidecar($absoluteDestination, $mimeType);
        $now = myronNow();

        $statement = $this->pdo->prepare(
            'INSERT INTO files (
                id,
                original_filename,
                stored_filename,
                storage_path,
                title,
                description,
                alt_text,
                caption,
                mime_type,
                file_extension,
                file_size_bytes,
                image_width,
                image_height,
                uploaded_by_user_id,
                created_at,
                updated_at
             ) VALUES (
                :id,
                :original_filename,
                :stored_filename,
                :storage_path,
                :title,
                :description,
                :alt_text,
                :caption,
                :mime_type,
                :file_extension,
                :file_size_bytes,
                :image_width,
                :image_height,
                :uploaded_by_user_id,
                :created_at,
                :updated_at
             )'
        );
        $statement->execute([
            ':id' => $fileId,
            ':original_filename' => $originalFilename,
            ':stored_filename' => $storedFilename,
            ':storage_path' => $storagePath,
            ':title' => $metadata['title'] !== '' ? $metadata['title'] : $originalFilename,
            ':description' => $metadata['description'],
            ':alt_text' => $metadata['alt_text'],
            ':caption' => $metadata['caption'],
            ':mime_type' => $mimeType,
            ':file_extension' => $extension,
            ':file_size_bytes' => $fileSize,
            ':image_width' => $width,
            ':image_height' => $height,
            ':uploaded_by_user_id' => $uploadedByUserId,
            ':created_at' => $now,
            ':updated_at' => $now,
        ]);

        myronPublicMediaRenditionService()->generateForFile([
            'id' => $fileId,
            'stored_filename' => $storedFilename,
            'mime_type' => $mimeType,
            'file_extension' => $extension,
            'image_width' => $width,
            'image_height' => $height,
        ], $absoluteDestination, self::IMAGE_RENDITION_WIDTHS);

        $file = $this->getFileById($fileId);
        if (!is_array($file)) {
            throw new RuntimeException('Uploaded file could not be loaded after save.');
        }

        return $file;
    }

    /**
     * @param array<string, mixed> $file
     * @return array{path:string,mime:string,variant:string,width:int|null,height:int|null}|null
     */
    public function resolveImageRendition(array $file, string $rendition): ?array
    {
        $preset = strtolower(trim($rendition));
        if (!isset(self::IMAGE_RENDITION_WIDTHS[$preset])) {
            throw new InvalidArgumentException('Unsupported image rendition.');
        }

        $mimeType = strtolower(trim((string) ($file['mime_type'] ?? '')));
        if (!in_array($mimeType, ['image/jpeg', 'image/png', 'image/webp'], true)) {
            return null;
        }

        $storedFilename = basename(trim((string) ($file['stored_filename'] ?? '')));
        if ($storedFilename === '') {
            return null;
        }

        $originalPath = myronUploadsDir() . DIRECTORY_SEPARATOR . $storedFilename;
        if (!is_file($originalPath)) {
            return null;
        }

        $originalWidth = (int) ($file['image_width'] ?? 0);
        $originalHeight = (int) ($file['image_height'] ?? 0);
        if ($originalWidth <= 0 || $originalHeight <= 0) {
            [$detectedWidth, $detectedHeight] = $this->detectImageDimensions($originalPath, $mimeType);
            $originalWidth = (int) ($detectedWidth ?? 0);
            $originalHeight = (int) ($detectedHeight ?? 0);
        }
        if ($originalWidth <= 0 || $originalHeight <= 0) {
            return null;
        }

        $targetWidth = self::IMAGE_RENDITION_WIDTHS[$preset];
        if ($originalWidth <= $targetWidth) {
            return [
                'path' => $originalPath,
                'mime' => $mimeType,
                'variant' => 'rendition-' . $preset . '-original',
                'width' => $originalWidth,
                'height' => $originalHeight,
            ];
        }

        if (!$this->imageResizeSupportAvailable($mimeType)) {
            return null;
        }

        $targetHeight = max(1, (int) round(($targetWidth / $originalWidth) * $originalHeight));
        $extension = strtolower(trim((string) ($file['file_extension'] ?? pathinfo($storedFilename, PATHINFO_EXTENSION))));
        if ($extension === 'jpeg') {
            $extension = 'jpg';
        }
        if (!in_array($extension, ['jpg', 'png', 'webp'], true)) {
            $extension = self::ALLOWED_MIME_TYPES[$mimeType] ?? '';
        }
        if ($extension === '') {
            return null;
        }

        $renditionDir = $this->renditionDirectoryForFile((string) ($file['id'] ?? ''));
        myronEnsureDirectory($renditionDir);
        $stem = pathinfo($storedFilename, PATHINFO_FILENAME);
        $targetPath = $renditionDir . DIRECTORY_SEPARATOR . $stem . '-' . $preset . '-' . $targetWidth . 'w.' . $extension;
        if (!is_file($targetPath)) {
            $this->generateImageResize($originalPath, $targetPath, $mimeType, $targetWidth, $targetHeight);
            $this->generateWebpSidecar($targetPath, $mimeType);
        }

        if (!is_file($targetPath)) {
            return null;
        }

        return [
            'path' => $targetPath,
            'mime' => $mimeType,
            'variant' => 'rendition-' . $preset,
            'width' => $targetWidth,
            'height' => $targetHeight,
        ];
    }

    /**
     * @return array{policyBytes:int,serverBytes:int,effectiveBytes:int,policyLabel:string,serverLabel:string,effectiveLabel:string}
     */
    public function uploadPolicyForMediaClass(string $mediaClass): array
    {
        $normalized = $this->normalizeMediaClass($mediaClass);
        $policyBytes = $this->policyUploadLimitForMediaClass($normalized);
        $serverBytes = $this->serverUploadLimitBytes();
        $effectiveBytes = $serverBytes > 0 ? min($policyBytes, $serverBytes) : $policyBytes;

        return [
            'policyBytes' => $policyBytes,
            'serverBytes' => $serverBytes,
            'effectiveBytes' => $effectiveBytes,
            'policyLabel' => myronFormatFileSize($policyBytes),
            'serverLabel' => $serverBytes > 0 ? myronFormatFileSize($serverBytes) : 'unreported',
            'effectiveLabel' => myronFormatFileSize($effectiveBytes),
        ];
    }

    public function effectiveUploadLimitForMediaClass(string $mediaClass): int
    {
        return $this->uploadPolicyForMediaClass($mediaClass)['effectiveBytes'];
    }

    /**
     * @return list<string>
     */
    public function allowedExtensionsForMediaClass(string $mediaClass): array
    {
        return self::MEDIA_CLASS_EXTENSIONS[$this->normalizeMediaClass($mediaClass)] ?? [];
    }

    public function acceptAttributeForMediaClass(string $mediaClass): string
    {
        $extensions = $this->allowedExtensionsForMediaClass($mediaClass);

        return implode(',', array_map(static fn (string $extension): string => '.' . $extension, $extensions));
    }

    /**
     * @return list<string>
     */
    public function allowedAccessibilityAttachmentExtensions(): array
    {
        return self::ACCESSIBILITY_ATTACHMENT_EXTENSIONS;
    }

    public function acceptAttributeForAccessibilityAttachments(): string
    {
        return implode(',', array_map(static fn (string $extension): string => '.' . $extension, self::ACCESSIBILITY_ATTACHMENT_EXTENSIONS));
    }

    /**
     * @param array<string, mixed> $file
     */
    public function isAccessibilityAttachmentFile(array $file): bool
    {
        $extension = strtolower(trim((string) ($file['file_extension'] ?? '')));
        $mimeType = strtolower(trim((string) ($file['mime_type'] ?? '')));

        return in_array($extension, self::ACCESSIBILITY_ATTACHMENT_EXTENSIONS, true)
            && in_array($mimeType, self::ACCESSIBILITY_ATTACHMENT_MIME_TYPES, true);
    }

    /**
     * @param array<string, mixed> $input
     */
    public function updateMetadata(string $fileId, array $input): void
    {
        $file = $this->getFileById($fileId);
        if (!is_array($file)) {
            throw new RuntimeException('The selected file could not be found.');
        }

        $statement = $this->pdo->prepare(
            'UPDATE files
             SET title = :title,
                 description = :description,
                 alt_text = :alt_text,
                 caption = :caption,
                 updated_at = :updated_at
             WHERE id = :file_id'
        );
        $statement->execute([
            ':title' => trim((string) ($input['title'] ?? $file['title'] ?? $file['original_filename'])),
            ':description' => trim((string) ($input['description'] ?? '')),
            ':alt_text' => trim((string) ($input['alt_text'] ?? '')),
            ':caption' => trim((string) ($input['caption'] ?? '')),
            ':updated_at' => myronNow(),
            ':file_id' => $fileId,
        ]);
    }

    /**
     * @param array<string, mixed> $uploadedFile
     * @param array<string, mixed> $metadata
     * @return array<string, mixed>
     */
    public function replaceFilePayload(string $fileId, array $uploadedFile, array $metadata = []): array
    {
        $fileId = trim($fileId);
        if ($fileId === '') {
            throw new InvalidArgumentException('fileId is required.');
        }

        $file = $this->getFileById($fileId);
        if (!is_array($file)) {
            throw new RuntimeException('The selected file could not be found.');
        }

        $error = (int) ($uploadedFile['error'] ?? UPLOAD_ERR_NO_FILE);
        if ($error !== UPLOAD_ERR_OK) {
            throw new RuntimeException(match ($error) {
                UPLOAD_ERR_NO_FILE => 'Choose a replacement file.',
                UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'The replacement file exceeds the size limit.',
                default => 'Replacement file upload failed.',
            });
        }

        $tmpName = trim((string) ($uploadedFile['tmp_name'] ?? ''));
        if ($tmpName === '' || !is_file($tmpName)) {
            throw new RuntimeException('Replacement file payload is invalid.');
        }

        $originalFilename = $this->sanitizeOriginalFilename((string) ($uploadedFile['name'] ?? 'replacement.bin'));
        $originalExtension = strtolower((string) pathinfo($originalFilename, PATHINFO_EXTENSION));
        $fileSize = (int) filesize($tmpName);
        if ($fileSize <= 0) {
            throw new RuntimeException('Replacement file is empty.');
        }
        $mimeType = $this->detectMimeType($tmpName);
        if (!isset(self::ALLOWED_MIME_TYPES[$mimeType])) {
            throw new RuntimeException('That replacement file type is not allowed.');
        }
        $extension = $this->extensionForUpload($mimeType, $originalExtension);
        $newMediaClass = $this->mediaClassForMimeAndExtension($mimeType, $extension);
        $existingMediaClass = $this->mediaClassForFile($file);
        if ($newMediaClass !== $existingMediaClass) {
            throw new RuntimeException('Replacement files must stay in the same media class as the existing asset.');
        }
        $effectiveLimit = $this->effectiveUploadLimitForMediaClass($newMediaClass);
        if ($fileSize > $effectiveLimit) {
            throw new RuntimeException(
                'Replacement ' . $newMediaClass . ' files must be ' . myronFormatFileSize($effectiveLimit) . ' or smaller on this server.'
            );
        }

        $uploadsDir = myronUploadsDir();
        myronEnsureDirectory($uploadsDir);

        $oldStoredFilename = basename(trim((string) ($file['stored_filename'] ?? '')));
        $oldPath = $oldStoredFilename !== '' ? $uploadsDir . DIRECTORY_SEPARATOR . $oldStoredFilename : '';
        $newStoredFilename = $fileId . '.' . $extension;
        $newPath = $uploadsDir . DIRECTORY_SEPARATOR . $newStoredFilename;
        $stagingPath = $uploadsDir . DIRECTORY_SEPARATOR . '.replace-' . $fileId . '-' . bin2hex(random_bytes(4)) . '.' . $extension;
        $backupPath = '';
        $oldBackedUp = false;
        $newMoved = false;

        $this->moveIntoStorage($tmpName, $stagingPath);
        [$width, $height] = $this->detectImageDimensions($stagingPath, $mimeType);

        try {
            if ($oldPath !== '' && is_file($oldPath) && $oldPath === $newPath) {
                $backupPath = $oldPath . '.bak-' . bin2hex(random_bytes(4));
                if (!@rename($oldPath, $backupPath)) {
                    throw new RuntimeException('Failed to stage the existing file for replacement.');
                }
                $oldBackedUp = true;
            }

            $this->deleteDerivedFilesForFile($fileId, $oldPath);

            if (is_file($newPath) && $newPath !== $oldPath && !@unlink($newPath)) {
                throw new RuntimeException('Failed to clear the replacement destination.');
            }
            if (!@rename($stagingPath, $newPath)) {
                throw new RuntimeException('Failed to store replacement file.');
            }
            $newMoved = true;
            $this->generateWebpSidecar($newPath, $mimeType);

            $normalizedMetadata = $this->normalizeOptionalReplacementMetadata($metadata, $file);
            $now = myronNow();
            $statement = $this->pdo->prepare(
                'UPDATE files
                 SET original_filename = :original_filename,
                     stored_filename = :stored_filename,
                     storage_path = :storage_path,
                     title = :title,
                     description = :description,
                     alt_text = :alt_text,
                     caption = :caption,
                     mime_type = :mime_type,
                     file_extension = :file_extension,
                     file_size_bytes = :file_size_bytes,
                     image_width = :image_width,
                     image_height = :image_height,
                     updated_at = :updated_at
                 WHERE id = :file_id'
            );
            $statement->execute([
                ':original_filename' => $originalFilename,
                ':stored_filename' => $newStoredFilename,
                ':storage_path' => 'uploads/' . $newStoredFilename,
                ':title' => $normalizedMetadata['title'],
                ':description' => $normalizedMetadata['description'],
                ':alt_text' => $normalizedMetadata['alt_text'],
                ':caption' => $normalizedMetadata['caption'],
                ':mime_type' => $mimeType,
                ':file_extension' => $extension,
                ':file_size_bytes' => $fileSize,
                ':image_width' => $width,
                ':image_height' => $height,
                ':updated_at' => $now,
                ':file_id' => $fileId,
            ]);

            if ($oldPath !== '' && $oldPath !== $newPath && is_file($oldPath)) {
                @unlink($oldPath);
            }
            if ($oldBackedUp && is_file($backupPath)) {
                @unlink($backupPath);
            }

            myronPublicMediaRenditionService()->generateForFile([
                'id' => $fileId,
                'stored_filename' => $newStoredFilename,
                'mime_type' => $mimeType,
                'file_extension' => $extension,
                'image_width' => $width,
                'image_height' => $height,
            ], $newPath, self::IMAGE_RENDITION_WIDTHS);
        } catch (Throwable $e) {
            if (is_file($stagingPath)) {
                @unlink($stagingPath);
            }
            if ($newMoved && is_file($newPath)) {
                @unlink($newPath);
            }
            if ($oldBackedUp && is_file($backupPath)) {
                @rename($backupPath, $oldPath);
            }
            throw $e;
        }

        $updated = $this->getFileById($fileId);
        if (!is_array($updated)) {
            throw new RuntimeException('Replacement file could not be loaded after save.');
        }

        return $updated;
    }

    public function deleteFile(string $fileId): void
    {
        $file = $this->getFileById($fileId);
        if (!is_array($file)) {
            throw new RuntimeException('The selected file could not be found.');
        }

        $absolutePath = myronUploadsDir() . DIRECTORY_SEPARATOR . (string) $file['stored_filename'];
        if (is_file($absolutePath) && !unlink($absolutePath)) {
            throw new RuntimeException('Failed to delete the stored file from disk.');
        }
        $this->deleteDerivedFilesForFile($fileId, $absolutePath);

        $statement = $this->pdo->prepare('DELETE FROM files WHERE id = :file_id');
        $statement->execute([':file_id' => $fileId]);
    }

    /**
     * @return array{recordsDeleted:int, filesDeleted:int, missingFiles:int}
     */
    public function purgeAllFiles(): array
    {
        $statement = $this->pdo->query('SELECT stored_filename FROM files');
        $rows = $statement ? $statement->fetchAll() : [];
        $storedFilenames = [];
        foreach (is_array($rows) ? $rows : [] as $row) {
            if (!is_array($row)) {
                continue;
            }

            $filename = basename(trim((string) ($row['stored_filename'] ?? '')));
            if ($filename !== '') {
                $storedFilenames[$filename] = true;
            }
        }

        $uploadsDir = myronUploadsDir();
        $filesDeleted = 0;
        $missingFiles = 0;
        foreach (array_keys($storedFilenames) as $filename) {
            $path = $uploadsDir . DIRECTORY_SEPARATOR . $filename;
            if (!is_file($path)) {
                $missingFiles++;
                continue;
            }
            if (!unlink($path)) {
                throw new RuntimeException('Failed to delete the stored file from disk: ' . $filename);
            }
            $filesDeleted++;
            $this->deleteDerivedFilesForFile(pathinfo($filename, PATHINFO_FILENAME), $path);
        }

        if (is_dir($uploadsDir)) {
            foreach (scandir($uploadsDir) ?: [] as $item) {
                if ($item === '.' || $item === '..') {
                    continue;
                }

                $path = $uploadsDir . DIRECTORY_SEPARATOR . $item;
                if (!is_file($path)) {
                    continue;
                }

                if (!unlink($path)) {
                    throw new RuntimeException('Failed to delete orphaned upload from disk: ' . $item);
                }
                $filesDeleted++;
            }
        }

        $recordsDeleted = (int) $this->pdo->query('SELECT COUNT(*) FROM files')->fetchColumn();
        $this->pdo->exec('DELETE FROM files');

        return [
            'recordsDeleted' => $recordsDeleted,
            'filesDeleted' => $filesDeleted,
            'missingFiles' => $missingFiles,
        ];
    }

    /**
     * @param array<string, mixed> $row
     * @return array<string, mixed>
     */
    private function decorateFileRow(array $row): array
    {
        $row['file_url'] = myronBuildFileStreamUrl((string) ($row['id'] ?? ''));
        $row['download_url'] = myronBuildFileStreamUrl((string) ($row['id'] ?? ''), true);
        $row['file_revision'] = myronFileRevision($row);
        $row['revisioned_file_url'] = myronBuildFileRevisionedStreamUrl($row);
        $row['image_rendition_urls'] = $this->imageRenditionUrlsForRow($row);
        $row['file_size_label'] = myronFormatFileSize((int) ($row['file_size_bytes'] ?? 0));
        $row['dimensions_label'] = $this->dimensionsLabel($row);
        $row['is_image'] = str_starts_with((string) ($row['mime_type'] ?? ''), 'image/');
        $row['is_video'] = str_starts_with((string) ($row['mime_type'] ?? ''), 'video/');
        $row['is_audio'] = str_starts_with((string) ($row['mime_type'] ?? ''), 'audio/')
            || in_array(strtolower((string) ($row['file_extension'] ?? '')), ['mp3', 'wav', 'm4a', 'aac', 'ogg'], true);
        $row['is_pdf'] = (string) ($row['mime_type'] ?? '') === 'application/pdf';
        $row['media_class'] = $this->mediaClassForFile($row);
        $row['variants'] = $this->variantManifestForRow($row);
        if (trim((string) ($row['uploaded_by_display_name'] ?? '')) === '') {
            $row['uploaded_by_display_name'] = 'Unknown uploader';
            $row['uploaded_by_missing'] = true;
        } else {
            $row['uploaded_by_missing'] = false;
        }

        return $row;
    }

    /**
     * @param array<string, mixed> $row
     * @return list<array{format:string,mime:string,size:int,url:string,accept_header:string}>
     */
    private function variantManifestForRow(array $row): array
    {
        $storedFilename = trim((string) ($row['stored_filename'] ?? ''));
        if ($storedFilename === '') {
            return [];
        }

        $sidecarPath = myronUploadsDir() . DIRECTORY_SEPARATOR . $storedFilename . '.webp';
        if (!is_file($sidecarPath)) {
            return [];
        }

        return [[
            'format'        => 'webp',
            'mime'          => 'image/webp',
            'size'          => (int) filesize($sidecarPath),
            'url'           => myronBuildFileStreamUrl((string) ($row['id'] ?? '')),
            'accept_header' => 'image/webp',
        ]];
    }

    /**
     * @param array<string, mixed> $file
     */
    public function mediaClassForFile(array $file): string
    {
        $mimeType = strtolower((string) ($file['mime_type'] ?? ''));
        $extension = strtolower((string) ($file['file_extension'] ?? ''));

        return $this->mediaClassForMimeAndExtension($mimeType, $extension);
    }

    private function sanitizeOriginalFilename(string $filename): string
    {
        $trimmed = trim($filename);
        $basename = basename($trimmed === '' ? 'upload.bin' : $trimmed);
        $sanitized = preg_replace('/[^A-Za-z0-9._-]+/', '-', $basename) ?? 'upload.bin';
        $sanitized = trim($sanitized, '-');

        return $sanitized === '' ? 'upload.bin' : $sanitized;
    }

    /**
     * @param array<string, mixed> $metadata
     * @return array{title:string,description:string,alt_text:string,caption:string}
     */
    private function normalizeUploadMetadata(array $metadata, bool $required): array
    {
        $normalized = [
            'title' => trim((string) ($metadata['title'] ?? '')),
            'description' => trim((string) ($metadata['description'] ?? '')),
            'alt_text' => trim((string) ($metadata['alt_text'] ?? '')),
            'caption' => trim((string) ($metadata['caption'] ?? '')),
        ];

        if ($required) {
            foreach (['alt_text' => 'Alt text', 'description' => 'Description', 'caption' => 'Caption'] as $key => $label) {
                if ($normalized[$key] === '') {
                    throw new RuntimeException($label . ' is required for asset uploads.');
                }
            }
        }

        return $normalized;
    }

    /**
     * @param array<string, mixed> $metadata
     * @param array<string, mixed> $existingFile
     * @return array{title:string,description:string,alt_text:string,caption:string}
     */
    private function normalizeOptionalReplacementMetadata(array $metadata, array $existingFile): array
    {
        $normalized = [];
        foreach (['title', 'description', 'alt_text', 'caption'] as $key) {
            if (array_key_exists($key, $metadata)) {
                $normalized[$key] = trim((string) $metadata[$key]);
                continue;
            }
            $normalized[$key] = trim((string) ($existingFile[$key] ?? ''));
        }

        return [
            'title' => $normalized['title'] !== '' ? $normalized['title'] : trim((string) ($existingFile['original_filename'] ?? '')),
            'description' => $normalized['description'],
            'alt_text' => $normalized['alt_text'],
            'caption' => $normalized['caption'],
        ];
    }

    private function detectMimeType(string $path): string
    {
        $finfo = new finfo(FILEINFO_MIME_TYPE);
        $mimeType = (string) $finfo->file($path);

        return $mimeType === '' ? 'application/octet-stream' : $mimeType;
    }

    private function extensionForUpload(string $mimeType, string $originalExtension): string
    {
        $mappedExtension = self::ALLOWED_MIME_TYPES[$mimeType] ?? '';
        if (
            $mimeType === 'application/zip'
            && in_array($originalExtension, ['docx', 'xlsx'], true)
        ) {
            return $originalExtension;
        }
        if (
            $mimeType === 'text/plain'
            && in_array($originalExtension, self::ACCESSIBILITY_ATTACHMENT_EXTENSIONS, true)
        ) {
            return $originalExtension;
        }

        return $mappedExtension;
    }

    private function mediaClassForMimeAndExtension(string $mimeType, string $extension): string
    {
        $normalizedMime = strtolower(trim($mimeType));
        $normalizedExtension = strtolower(trim($extension));
        if (str_starts_with($normalizedMime, 'image/') || in_array($normalizedExtension, self::MEDIA_CLASS_EXTENSIONS['image'], true)) {
            return 'image';
        }
        if (str_starts_with($normalizedMime, 'video/') || in_array($normalizedExtension, self::MEDIA_CLASS_EXTENSIONS['video'], true)) {
            return 'video';
        }
        if (
            str_starts_with($normalizedMime, 'audio/')
            || $normalizedMime === 'application/ogg'
            || in_array($normalizedExtension, self::MEDIA_CLASS_EXTENSIONS['audio'], true)
        ) {
            return 'audio';
        }

        return 'document';
    }

    private function normalizeOptionalMediaClass(string $mediaClass): string
    {
        $trimmed = trim($mediaClass);
        if ($trimmed === '') {
            return '';
        }

        return $this->normalizeMediaClass($trimmed);
    }

    private function normalizeMediaClass(string $mediaClass): string
    {
        $normalized = strtolower(trim($mediaClass));
        $normalized = rtrim($normalized, 's');
        if (!isset(self::MEDIA_CLASS_EXTENSIONS[$normalized])) {
            throw new RuntimeException('Unsupported media asset type: ' . $mediaClass);
        }

        return $normalized;
    }

    private function policyUploadLimitForMediaClass(string $mediaClass): int
    {
        return match ($this->normalizeMediaClass($mediaClass)) {
            'video' => self::VIDEO_UPLOAD_BYTES,
            'audio' => self::AUDIO_UPLOAD_BYTES,
            default => self::DEFAULT_UPLOAD_BYTES,
        };
    }

    private function serverUploadLimitBytes(): int
    {
        $uploadLimit = $this->parseIniBytes((string) ini_get('upload_max_filesize'));
        $postLimit = $this->parseIniBytes((string) ini_get('post_max_size'));
        $limits = array_values(array_filter([$uploadLimit, $postLimit], static fn (int $value): bool => $value > 0));
        if ($limits === []) {
            return 0;
        }

        return min($limits);
    }

    private function parseIniBytes(string $value): int
    {
        $trimmed = trim($value);
        if ($trimmed === '') {
            return 0;
        }
        if ($trimmed === '-1') {
            return 0;
        }

        $unit = strtolower(substr($trimmed, -1));
        $number = (float) $trimmed;
        $bytes = match ($unit) {
            'g' => $number * 1024 * 1024 * 1024,
            'm' => $number * 1024 * 1024,
            'k' => $number * 1024,
            default => $number,
        };

        return (int) max(0, floor($bytes));
    }

    /**
     * @return array{0: int|null, 1: int|null}
     */
    private function detectImageDimensions(string $absolutePath, string $mimeType): array
    {
        if (!str_starts_with($mimeType, 'image/')) {
            return [null, null];
        }

        $dimensions = @getimagesize($absolutePath);
        if (!is_array($dimensions) || !isset($dimensions[0], $dimensions[1])) {
            return [null, null];
        }

        return [(int) $dimensions[0], (int) $dimensions[1]];
    }

    private function generateWebpSidecar(string $absolutePath, string $mimeType): void
    {
        if (!in_array($mimeType, ['image/png', 'image/jpeg'], true)) {
            return;
        }

        if (!function_exists('imagewebp') || !function_exists('imagecreatefrompng') || !function_exists('imagecreatefromjpeg')) {
            return;
        }

        $sidecarPath = $absolutePath . '.webp';

        try {
            $image = $mimeType === 'image/png'
                ? @imagecreatefrompng($absolutePath)
                : @imagecreatefromjpeg($absolutePath);
            if (!$image) {
                return;
            }
            if ($mimeType === 'image/png') {
                @imagepalettetotruecolor($image);
                @imagealphablending($image, false);
                @imagesavealpha($image, true);
            }
            $ok = @imagewebp($image, $sidecarPath, 82);
            unset($image);
            if (!$ok && is_file($sidecarPath)) {
                @unlink($sidecarPath);
            }
        } catch (\Throwable $e) {
            if (is_file($sidecarPath)) {
                @unlink($sidecarPath);
            }
            error_log('WebP sidecar generation failed for ' . basename($absolutePath) . ': ' . $e->getMessage());
        }
    }

    private function imageResizeSupportAvailable(string $mimeType): bool
    {
        if (!function_exists('imagecreatetruecolor') || !function_exists('imagecopyresampled')) {
            return false;
        }

        return match ($mimeType) {
            'image/png' => function_exists('imagecreatefrompng') && function_exists('imagepng'),
            'image/jpeg' => function_exists('imagecreatefromjpeg') && function_exists('imagejpeg'),
            'image/webp' => function_exists('imagecreatefromwebp') && function_exists('imagewebp'),
            default => false,
        };
    }

    private function generateImageResize(string $sourcePath, string $targetPath, string $mimeType, int $targetWidth, int $targetHeight): void
    {
        try {
            $source = match ($mimeType) {
                'image/png' => @imagecreatefrompng($sourcePath),
                'image/jpeg' => @imagecreatefromjpeg($sourcePath),
                'image/webp' => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($sourcePath) : false,
                default => false,
            };
            if (!$source) {
                return;
            }

            $canvas = imagecreatetruecolor($targetWidth, $targetHeight);
            if (!$canvas) {
                unset($source);
                return;
            }

            if (in_array($mimeType, ['image/png', 'image/webp'], true)) {
                imagealphablending($canvas, false);
                imagesavealpha($canvas, true);
                $transparent = imagecolorallocatealpha($canvas, 0, 0, 0, 127);
                if ($transparent !== false) {
                    imagefilledrectangle($canvas, 0, 0, $targetWidth, $targetHeight, $transparent);
                }
            }

            $sourceWidth = imagesx($source);
            $sourceHeight = imagesy($source);
            if ($sourceWidth <= 0 || $sourceHeight <= 0) {
                unset($source, $canvas);
                return;
            }

            $ok = imagecopyresampled($canvas, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $sourceWidth, $sourceHeight);
            if (!$ok) {
                unset($source, $canvas);
                return;
            }

            myronEnsureDirectory(dirname($targetPath));
            $written = match ($mimeType) {
                'image/png' => imagepng($canvas, $targetPath, 6),
                'image/jpeg' => imagejpeg($canvas, $targetPath, 82),
                'image/webp' => function_exists('imagewebp') ? imagewebp($canvas, $targetPath, 82) : false,
                default => false,
            };

            unset($source, $canvas);
            if (!$written && is_file($targetPath)) {
                @unlink($targetPath);
            }
        } catch (Throwable $e) {
            if (is_file($targetPath)) {
                @unlink($targetPath);
            }
            error_log('Image resize generation failed for ' . basename($sourcePath) . ': ' . $e->getMessage());
        }
    }

    /**
     * @param array<string, mixed> $row
     * @return array<string, string>
     */
    private function imageRenditionUrlsForRow(array $row): array
    {
        $mimeType = strtolower((string) ($row['mime_type'] ?? ''));
        if (!in_array($mimeType, ['image/jpeg', 'image/png', 'image/webp'], true)) {
            return [];
        }

        $urls = [];
        $staticUrls = myronPublicMediaRenditionService()->staticRenditionUrlsForFile($row, self::IMAGE_RENDITION_WIDTHS);
        foreach (array_keys(self::IMAGE_RENDITION_WIDTHS) as $preset) {
            $urls[$preset] = (string) ($staticUrls[$preset] ?? myronBuildFileRevisionedStreamUrl($row, $preset));
        }

        return $urls;
    }

    private function renditionDirectoryForFile(string $fileId): string
    {
        $safeId = preg_replace('/[^A-Za-z0-9_-]+/', '', trim($fileId)) ?: 'unknown';

        return myronUploadsDir() . DIRECTORY_SEPARATOR . 'renditions' . DIRECTORY_SEPARATOR . $safeId;
    }

    private function deleteDerivedFilesForFile(string $fileId, string $absolutePath = ''): void
    {
        myronPublicMediaRenditionService()->deleteForFile($fileId);

        if ($absolutePath !== '') {
            $sidecarPath = $absolutePath . '.webp';
            if (is_file($sidecarPath)) {
                @unlink($sidecarPath);
            }
        }

        $renditionDir = $this->renditionDirectoryForFile($fileId);
        if (!is_dir($renditionDir)) {
            return;
        }

        foreach (scandir($renditionDir) ?: [] as $item) {
            if ($item === '.' || $item === '..') {
                continue;
            }
            $path = $renditionDir . DIRECTORY_SEPARATOR . $item;
            if (is_file($path)) {
                @unlink($path);
            }
        }
        @rmdir($renditionDir);
        $parent = dirname($renditionDir);
        if (is_dir($parent) && count(array_diff(scandir($parent) ?: [], ['.', '..'])) === 0) {
            @rmdir($parent);
        }
    }

    private function moveIntoStorage(string $tmpName, string $absoluteDestination): void
    {
        if (is_uploaded_file($tmpName)) {
            if (!move_uploaded_file($tmpName, $absoluteDestination)) {
                throw new RuntimeException('Failed to store uploaded file.');
            }

            return;
        }

        if (@rename($tmpName, $absoluteDestination)) {
            return;
        }

        if (!@copy($tmpName, $absoluteDestination)) {
            throw new RuntimeException('Failed to store uploaded file.');
        }

        @unlink($tmpName);
    }

    /**
     * @param array<string, mixed> $row
     */
    private function dimensionsLabel(array $row): string
    {
        $width = (int) ($row['image_width'] ?? 0);
        $height = (int) ($row['image_height'] ?? 0);
        if ($width <= 0 || $height <= 0) {
            return '—';
        }

        return $width . ' × ' . $height;
    }
}
