ChatAttachmentService.java

package com.ecommerce.chat.application.service;

import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.ecommerce.chat.application.exception.ChatError;
import com.ecommerce.chat.application.exception.ChatException;
import com.ecommerce.chat.application.model.ChatModels.Actor;
import com.ecommerce.chat.application.model.ChatModels.AttachmentDownloadView;
import com.ecommerce.chat.application.model.ChatModels.AttachmentUploadView;
import com.ecommerce.chat.application.model.ChatModels.AttachmentView;
import com.ecommerce.chat.application.model.ChatModels.CreateAttachmentUploadCommand;
import com.ecommerce.chat.application.port.ChatAttachmentStorage;
import com.ecommerce.chat.infrastructure.persistence.entity.ChatAttachmentEntity;
import com.ecommerce.chat.infrastructure.persistence.entity.ChatAttachmentUploadEntity;
import com.ecommerce.chat.infrastructure.persistence.mapper.ChatAttachmentMapper;
import com.ecommerce.chat.infrastructure.persistence.mapper.ChatAttachmentUploadMapper;
import com.ecommerce.chat.infrastructure.persistence.mapper.ConversationMapper;
import com.ecommerce.chat.infrastructure.persistence.mapper.ConversationMemberMapper;
import com.ecommerce.chat.infrastructure.storage.ChatAttachmentStorageProperties;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;

@Service
public class ChatAttachmentService {

    private static final Logger log = LoggerFactory.getLogger(ChatAttachmentService.class);
    private static final String PENDING = "PENDING";
    private static final String SCAN_PENDING = "SCAN_PENDING";
    private static final String SCANNING = "SCANNING";
    private static final String SCAN_RETRY = "SCAN_RETRY";
    private static final String SCAN_NEEDS_ATTENTION = "SCAN_NEEDS_ATTENTION";
    private static final String INFECTED = "INFECTED";
    private static final String READY = "READY";
    private static final String ATTACHED = "ATTACHED";
    private static final Set<String> IMAGE_TYPES = Set.of(
            "image/jpeg",
            "image/png",
            "image/webp");
    private static final Set<String> FILE_TYPES = Set.of(
            "application/pdf",
            "text/plain");
    private static final Map<String, Set<String>> EXTENSIONS = Map.of(
            "image/jpeg", Set.of("jpg", "jpeg"),
            "image/png", Set.of("png"),
            "image/webp", Set.of("webp"),
            "application/pdf", Set.of("pdf"),
            "text/plain", Set.of("txt"));
    private static final Map<String, String> OBJECT_EXTENSIONS = Map.of(
            "image/jpeg", "jpg",
            "image/png", "png",
            "image/webp", "webp",
            "application/pdf", "pdf",
            "text/plain", "txt");

    private final ConversationMapper conversationMapper;
    private final ConversationMemberMapper memberMapper;
    private final ChatAttachmentUploadMapper uploadMapper;
    private final ChatAttachmentMapper attachmentMapper;
    private final ChatAttachmentStorage storage;
    private final ChatAttachmentStorageProperties properties;
    private final TransactionTemplate transactions;

    public ChatAttachmentService(
            ConversationMapper conversationMapper,
            ConversationMemberMapper memberMapper,
            ChatAttachmentUploadMapper uploadMapper,
            ChatAttachmentMapper attachmentMapper,
            ChatAttachmentStorage storage,
            ChatAttachmentStorageProperties properties,
            TransactionTemplate transactions) {
        this.conversationMapper = conversationMapper;
        this.memberMapper = memberMapper;
        this.uploadMapper = uploadMapper;
        this.attachmentMapper = attachmentMapper;
        this.storage = storage;
        this.properties = properties;
        this.transactions = transactions;
    }

    public AttachmentUploadView createUploadIntent(
            Actor actor,
            Long conversationId,
            CreateAttachmentUploadCommand command) {
        String fileName = normalizeFileName(command.fileName());
        String mimeType = normalizeMimeType(command.mimeType());
        validateDeclaredObject(fileName, mimeType, command.sizeBytes());
        String requestHash = hash(fileName, mimeType, Long.toString(command.sizeBytes()));

        ChatAttachmentUploadEntity upload = transactions.execute(status -> {
            requireConversationMember(conversationId, actor.userId());
            ChatAttachmentUploadEntity existing = uploadMapper.selectByClientUploadId(
                    conversationId,
                    actor.userId(),
                    command.clientUploadId());
            if (existing != null) {
                if (!requestHash.equals(existing.getRequestHash())) {
                    throw new ChatException(ChatError.IDEMPOTENCY_CONFLICT);
                }
                return existing;
            }

            Instant now = uploadMapper.currentTime();
            ChatAttachmentUploadEntity created = new ChatAttachmentUploadEntity();
            created.setId(IdWorker.getId());
            created.setConversationId(conversationId);
            created.setUploaderId(actor.userId());
            created.setClientUploadId(command.clientUploadId());
            created.setRequestHash(requestHash);
            created.setObjectKey(objectKey(
                    conversationId,
                    actor.userId(),
                    created.getId(),
                    mimeType));
            created.setOriginalFilename(fileName);
            created.setRequestedMimeType(mimeType);
            created.setRequestedSizeBytes(command.sizeBytes());
            created.setStatus(PENDING);
            created.setCleanupAttempts(0);
            created.setQuarantineCleanupAttempts(0);
            created.setScanAttempts(0);
            created.setExpiresAt(now.plus(properties.intentTtl()));
            created.setCreatedAt(now);
            created.setUpdatedAt(now);
            uploadMapper.insertIdempotent(created);
            ChatAttachmentUploadEntity persisted = uploadMapper.selectByClientUploadId(
                    conversationId,
                    actor.userId(),
                    command.clientUploadId());
            if (persisted == null || !requestHash.equals(persisted.getRequestHash())) {
                throw new ChatException(ChatError.IDEMPOTENCY_CONFLICT);
            }
            return persisted;
        });

        if (upload == null) {
            throw new IllegalStateException("Attachment upload transaction returned no result");
        }
        if (PENDING.equals(upload.getStatus())
                && !upload.getExpiresAt().isAfter(uploadMapper.currentTime())) {
            throw new ChatException(ChatError.ATTACHMENT_UPLOAD_EXPIRED);
        }
        String uploadUrl = PENDING.equals(upload.getStatus())
                ? storage.createUploadUrl(
                        properties.bucket(),
                        upload.getObjectKey(),
                        properties.uploadExpiry())
                : null;
        return toUploadView(upload, uploadUrl);
    }

    public AttachmentUploadView confirmUpload(Actor actor, Long conversationId, Long uploadId) {
        ChatAttachmentUploadEntity candidate = transactions.execute(status ->
                requireUploadForActor(actor, conversationId, uploadId, false));
        if (candidate == null) {
            throw new IllegalStateException("Attachment confirmation transaction returned no result");
        }
        if (isConfirmedStatus(candidate.getStatus())) {
            return toUploadView(candidate, null);
        }
        if (!PENDING.equals(candidate.getStatus())) {
            throw new ChatException(ChatError.ATTACHMENT_NOT_READY);
        }
        if (!candidate.getExpiresAt().isAfter(uploadMapper.currentTime())) {
            throw new ChatException(ChatError.ATTACHMENT_UPLOAD_EXPIRED);
        }

        ChatAttachmentStorage.StoredObject object = storage.inspect(
                properties.bucket(),
                candidate.getObjectKey(),
                properties.inspectionBytes(),
                properties.maximumSize().toBytes());
        String actualMimeType = normalizeMimeType(object.contentType());
        validateStoredObject(candidate, object, actualMimeType);
        if (object.entityTag() == null || object.entityTag().isBlank()) {
            throw new ChatException(ChatError.ATTACHMENT_OBJECT_MISMATCH);
        }

        String sourceObjectKey = candidate.getObjectKey();
        String sealedObjectKey = sealedObjectKey(candidate, actualMimeType, object.sha256());
        storage.copyIfUnchanged(
                properties.bucket(),
                sourceObjectKey,
                sealedObjectKey,
                object.entityTag());
        ChatAttachmentStorage.StoredObject sealedObject = storage.inspect(
                properties.bucket(),
                sealedObjectKey,
                properties.inspectionBytes(),
                properties.maximumSize().toBytes());
        String sealedMimeType = normalizeMimeType(sealedObject.contentType());
        validateStoredObject(candidate, sealedObject, sealedMimeType);
        if (!actualMimeType.equals(sealedMimeType)
                || object.sizeBytes() != sealedObject.sizeBytes()
                || !object.sha256().equals(sealedObject.sha256())) {
            storage.remove(properties.bucket(), sealedObjectKey);
            throw new ChatException(ChatError.ATTACHMENT_OBJECT_MISMATCH);
        }

        ChatAttachmentUploadEntity confirmed;
        try {
            confirmed = transactions.execute(status -> {
                ChatAttachmentUploadEntity locked = requireUploadForActor(
                        actor,
                        conversationId,
                        uploadId,
                        true);
                if (isConfirmedStatus(locked.getStatus())) {
                    return locked;
                }
                if (!PENDING.equals(locked.getStatus())) {
                    throw new ChatException(ChatError.ATTACHMENT_NOT_READY);
                }
                if (!locked.getExpiresAt().isAfter(uploadMapper.currentTime())) {
                    throw new ChatException(ChatError.ATTACHMENT_UPLOAD_EXPIRED);
                }
                Instant now = uploadMapper.currentTime();
                if (uploadMapper.markScanPending(
                        uploadId,
                        sourceObjectKey,
                        sealedObjectKey,
                        sealedMimeType,
                        sealedObject.sizeBytes(),
                        sealedObject.sha256(),
                        now) != 1) {
                    throw new ChatException(ChatError.ATTACHMENT_NOT_READY);
                }
                locked.setObjectKey(sealedObjectKey);
                locked.setQuarantineObjectKey(sourceObjectKey);
                locked.setQuarantineCleanupAttempts(0);
                locked.setQuarantineCleanupLastError(null);
                locked.setVerifiedMimeType(sealedMimeType);
                locked.setVerifiedSizeBytes(sealedObject.sizeBytes());
                locked.setVerifiedSha256(sealedObject.sha256());
                locked.setStatus(SCAN_PENDING);
                locked.setScanAttempts(0);
                locked.setUpdatedAt(now);
                return locked;
            });
        } catch (RuntimeException exception) {
            removeUnusedSealedObject(uploadId, sealedObjectKey, exception);
            throw exception;
        }
        if (confirmed == null) {
            removeUnusedSealedObject(uploadId, sealedObjectKey, null);
            throw new IllegalStateException("Attachment confirmation transaction returned no result");
        }
        if (!sealedObjectKey.equals(confirmed.getObjectKey())) {
            removeUnusedSealedObject(uploadId, sealedObjectKey, null);
        } else {
            cleanupQuarantineSource(confirmed, sourceObjectKey);
        }
        return toUploadView(confirmed, null);
    }

    private void cleanupQuarantineSource(
            ChatAttachmentUploadEntity upload,
            String sourceObjectKey) {
        int expectedAttempts = upload.getQuarantineCleanupAttempts();
        try {
            storage.remove(properties.bucket(), sourceObjectKey);
            transactions.executeWithoutResult(status ->
                    uploadMapper.markQuarantineCleaned(
                            upload.getId(),
                            sourceObjectKey,
                            expectedAttempts,
                            uploadMapper.currentTime()));
        } catch (RuntimeException exception) {
            recordQuarantineCleanupFailure(
                    upload.getId(),
                    sourceObjectKey,
                    expectedAttempts,
                    exception);
        }
    }

    private void recordQuarantineCleanupFailure(
            Long uploadId,
            String sourceObjectKey,
            int expectedAttempts,
            RuntimeException cleanupFailure) {
        String error = conciseError(cleanupFailure);
        try {
            transactions.executeWithoutResult(status ->
                    uploadMapper.markQuarantineCleanupFailed(
                            uploadId,
                            sourceObjectKey,
                            expectedAttempts,
                            error,
                            uploadMapper.currentTime()));
        } catch (RuntimeException persistenceFailure) {
            cleanupFailure.addSuppressed(persistenceFailure);
        }
        log.warn(
                "Quarantine chat attachment cleanup failed and remains retryable: "
                        + "uploadId={}, objectKey={}, error={}",
                uploadId,
                sourceObjectKey,
                error);
    }

    private void removeUnusedSealedObject(
            Long uploadId,
            String sealedObjectKey,
            RuntimeException confirmationFailure) {
        try {
            storage.remove(properties.bucket(), sealedObjectKey);
        } catch (RuntimeException cleanupFailure) {
            if (confirmationFailure != null) {
                confirmationFailure.addSuppressed(cleanupFailure);
            }
            log.warn(
                    "Unused sealed chat attachment could not be removed: uploadId={}, objectKey={}",
                    uploadId,
                    sealedObjectKey,
                    cleanupFailure);
        }
    }

    private String conciseError(RuntimeException exception) {
        String message = exception.getClass().getSimpleName() + ": " + exception.getMessage();
        return message.length() <= 500 ? message : message.substring(0, 500);
    }

    public AttachmentUploadView getUpload(Actor actor, Long conversationId, Long uploadId) {
        ChatAttachmentUploadEntity upload = transactions.execute(status ->
                requireUploadForActor(actor, conversationId, uploadId, false));
        if (upload == null) {
            throw new IllegalStateException("Attachment upload query returned no result");
        }
        return toUploadView(upload, null);
    }

    public AttachmentDownloadView createDownload(
            Actor actor,
            Long conversationId,
            Long messageId,
            Long attachmentId) {
        ChatAttachmentEntity attachment = transactions.execute(status -> {
            requireConversationMember(conversationId, actor.userId());
            ChatAttachmentEntity selected = attachmentMapper.selectInMessage(
                    conversationId,
                    messageId,
                    attachmentId);
            if (selected == null) {
                throw new ChatException(ChatError.ATTACHMENT_NOT_FOUND);
            }
            return selected;
        });
        if (attachment == null) {
            throw new IllegalStateException("Attachment download transaction returned no result");
        }
        ChatAttachmentStorage.StoredObject object = storage.inspect(
                properties.bucket(),
                attachment.getObjectKey(),
                properties.inspectionBytes(),
                properties.maximumSize().toBytes());
        String actualMimeType = normalizeMimeType(object.contentType());
        if (attachment.getSizeBytes() != object.sizeBytes()
                || !attachment.getMimeType().equals(actualMimeType)
                || !attachment.getSha256().equals(object.sha256())
                || !matchesFileHeader(actualMimeType, object.prefix())) {
            throw new ChatException(ChatError.ATTACHMENT_OBJECT_MISMATCH);
        }
        return new AttachmentDownloadView(
                attachment.getId(),
                storage.createDownloadUrl(
                        properties.bucket(),
                        attachment.getObjectKey(),
                        properties.downloadExpiry()),
                properties.downloadExpiry().toSeconds());
    }

    public List<ChatAttachmentUploadEntity> lockReadyUploads(
            Actor actor,
            Long conversationId,
            String messageType,
            List<Long> uploadIds) {
        List<Long> normalized = uploadIds == null ? List.of() : List.copyOf(uploadIds);
        if ("TEXT".equals(messageType)) {
            if (!normalized.isEmpty()) {
                throw new ChatException(ChatError.INVALID_ATTACHMENT);
            }
            return List.of();
        }
        if (!"IMAGE".equals(messageType) && !"FILE".equals(messageType)) {
            throw new ChatException(ChatError.INVALID_MESSAGE_TYPE);
        }
        if (normalized.isEmpty() || normalized.size() > 5
                || new HashSet<>(normalized).size() != normalized.size()) {
            throw new ChatException(ChatError.INVALID_ATTACHMENT);
        }

        List<ChatAttachmentUploadEntity> uploads = new ArrayList<>(normalized.size());
        for (Long uploadId : normalized) {
            if (uploadId == null || uploadId <= 0) {
                throw new ChatException(ChatError.INVALID_ATTACHMENT);
            }
            ChatAttachmentUploadEntity upload = requireUploadForActor(
                    actor,
                    conversationId,
                    uploadId,
                    true);
            if (ATTACHED.equals(upload.getStatus())) {
                throw new ChatException(ChatError.ATTACHMENT_ALREADY_ATTACHED);
            }
            if (INFECTED.equals(upload.getStatus())) {
                throw new ChatException(ChatError.ATTACHMENT_INFECTED);
            }
            if (!READY.equals(upload.getStatus())) {
                throw new ChatException(ChatError.ATTACHMENT_NOT_READY);
            }
            if ("IMAGE".equals(messageType) && !IMAGE_TYPES.contains(upload.getVerifiedMimeType())) {
                throw new ChatException(ChatError.INVALID_ATTACHMENT);
            }
            uploads.add(upload);
        }
        return uploads;
    }

    public List<AttachmentView> bindToMessage(
            Long messageId,
            List<ChatAttachmentUploadEntity> uploads,
            Instant now) {
        List<AttachmentView> views = new ArrayList<>(uploads.size());
        for (int index = 0; index < uploads.size(); index++) {
            ChatAttachmentUploadEntity upload = uploads.get(index);
            ChatAttachmentEntity attachment = new ChatAttachmentEntity();
            attachment.setId(IdWorker.getId());
            attachment.setMessageId(messageId);
            attachment.setUploadId(upload.getId());
            attachment.setObjectKey(upload.getObjectKey());
            attachment.setOriginalFilename(upload.getOriginalFilename());
            attachment.setMimeType(upload.getVerifiedMimeType());
            attachment.setSizeBytes(upload.getVerifiedSizeBytes());
            attachment.setSha256(upload.getVerifiedSha256());
            attachment.setSortOrder(index);
            attachment.setCreatedAt(now);
            attachmentMapper.insert(attachment);
            if (uploadMapper.markAttached(upload.getId(), messageId, now) != 1) {
                throw new ChatException(ChatError.ATTACHMENT_ALREADY_ATTACHED);
            }
            views.add(toAttachmentView(attachment));
        }
        return List.copyOf(views);
    }

    public List<AttachmentView> attachmentsForMessage(Long messageId) {
        return attachmentMapper.selectByMessageId(messageId).stream()
                .map(this::toAttachmentView)
                .toList();
    }

    public Map<Long, List<AttachmentView>> attachmentsForMessages(List<Long> messageIds) {
        if (messageIds.isEmpty()) {
            return Map.of();
        }
        Map<Long, List<AttachmentView>> grouped = new LinkedHashMap<>();
        for (ChatAttachmentEntity attachment : attachmentMapper.selectByMessageIds(messageIds)) {
            grouped.computeIfAbsent(attachment.getMessageId(), ignored -> new ArrayList<>())
                    .add(toAttachmentView(attachment));
        }
        Map<Long, List<AttachmentView>> immutable = new HashMap<>();
        grouped.forEach((messageId, views) -> immutable.put(messageId, List.copyOf(views)));
        return Map.copyOf(immutable);
    }

    private ChatAttachmentUploadEntity requireUploadForActor(
            Actor actor,
            Long conversationId,
            Long uploadId,
            boolean forUpdate) {
        requireConversationMember(conversationId, actor.userId());
        ChatAttachmentUploadEntity upload = forUpdate
                ? uploadMapper.selectForUpdate(uploadId)
                : uploadMapper.selectById(uploadId);
        if (upload == null
                || !conversationId.equals(upload.getConversationId())
                || !actor.userId().equals(upload.getUploaderId())) {
            throw new ChatException(ChatError.ATTACHMENT_NOT_FOUND);
        }
        return upload;
    }

    private void requireConversationMember(Long conversationId, Long userId) {
        if (conversationMapper.selectById(conversationId) == null) {
            throw new ChatException(ChatError.CONVERSATION_NOT_FOUND);
        }
        if (memberMapper.selectMember(conversationId, userId) == null) {
            throw new ChatException(ChatError.CONVERSATION_ACCESS_DENIED);
        }
    }

    private void validateDeclaredObject(String fileName, String mimeType, long sizeBytes) {
        Set<String> extensions = EXTENSIONS.get(mimeType);
        if (extensions == null || sizeBytes <= 0 || sizeBytes > properties.maximumSize().toBytes()) {
            throw new ChatException(ChatError.INVALID_ATTACHMENT);
        }
        int separator = fileName.lastIndexOf('.');
        String extension = separator < 0
                ? ""
                : fileName.substring(separator + 1).toLowerCase(Locale.ROOT);
        if (!extensions.contains(extension)) {
            throw new ChatException(ChatError.INVALID_ATTACHMENT);
        }
    }

    private void validateStoredObject(
            ChatAttachmentUploadEntity upload,
            ChatAttachmentStorage.StoredObject object,
            String actualMimeType) {
        if (!upload.getRequestedMimeType().equals(actualMimeType)
                || upload.getRequestedSizeBytes() != object.sizeBytes()
                || object.sizeBytes() > properties.maximumSize().toBytes()
                || object.sha256() == null
                || !matchesFileHeader(actualMimeType, object.prefix())) {
            throw new ChatException(ChatError.ATTACHMENT_OBJECT_MISMATCH);
        }
    }

    private boolean matchesFileHeader(String mimeType, byte[] bytes) {
        return switch (mimeType) {
            case "image/jpeg" -> startsWith(bytes, 0xff, 0xd8, 0xff);
            case "image/png" -> startsWith(bytes, 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a);
            case "image/webp" -> bytes.length >= 12
                    && startsWith(bytes, 0x52, 0x49, 0x46, 0x46)
                    && bytes[8] == 0x57
                    && bytes[9] == 0x45
                    && bytes[10] == 0x42
                    && bytes[11] == 0x50;
            case "application/pdf" -> startsWith(bytes, 0x25, 0x50, 0x44, 0x46, 0x2d);
            case "text/plain" -> bytes.length > 0
                    && Arrays.stream(toUnsigned(bytes))
                    .noneMatch(value -> value == 0);
            default -> false;
        };
    }

    private int[] toUnsigned(byte[] bytes) {
        int[] values = new int[bytes.length];
        for (int index = 0; index < bytes.length; index++) {
            values[index] = Byte.toUnsignedInt(bytes[index]);
        }
        return values;
    }

    private boolean startsWith(byte[] bytes, int... prefix) {
        if (bytes.length < prefix.length) {
            return false;
        }
        for (int index = 0; index < prefix.length; index++) {
            if (Byte.toUnsignedInt(bytes[index]) != prefix[index]) {
                return false;
            }
        }
        return true;
    }

    private String normalizeFileName(String value) {
        if (value == null) {
            throw new ChatException(ChatError.INVALID_ATTACHMENT);
        }
        String normalized = value.trim();
        if (normalized.isEmpty()
                || normalized.length() > 255
                || normalized.contains("/")
                || normalized.contains("\\")
                || normalized.chars().anyMatch(Character::isISOControl)) {
            throw new ChatException(ChatError.INVALID_ATTACHMENT);
        }
        return normalized;
    }

    private String normalizeMimeType(String value) {
        if (value == null || value.isBlank()) {
            throw new ChatException(ChatError.INVALID_ATTACHMENT);
        }
        return value.split(";", 2)[0].trim().toLowerCase(Locale.ROOT);
    }

    private String objectKey(Long conversationId, Long userId, Long uploadId, String mimeType) {
        return "quarantine/chat/%s/conversations/%d/users/%d/%d-%s.%s".formatted(
                properties.namespace(),
                conversationId,
                userId,
                uploadId,
                UUID.randomUUID().toString().replace("-", ""),
                OBJECT_EXTENSIONS.get(mimeType));
    }

    private String sealedObjectKey(
            ChatAttachmentUploadEntity upload,
            String mimeType,
            String sha256) {
        return "objects/chat/%s/conversations/%d/users/%d/%d-%s.%s".formatted(
                properties.namespace(),
                upload.getConversationId(),
                upload.getUploaderId(),
                upload.getId(),
                sha256,
                OBJECT_EXTENSIONS.get(mimeType));
    }

    private AttachmentUploadView toUploadView(
            ChatAttachmentUploadEntity upload,
            String uploadUrl) {
        return new AttachmentUploadView(
                upload.getId(),
                upload.getConversationId(),
                upload.getClientUploadId(),
                upload.getOriginalFilename(),
                upload.getRequestedMimeType(),
                upload.getRequestedSizeBytes(),
                upload.getStatus(),
                uploadUrl,
                upload.getExpiresAt(),
                upload.getScanAttempts(),
                upload.getScanEngine(),
                upload.getScanSignature(),
                upload.getScanCompletedAt());
    }

    private boolean isConfirmedStatus(String status) {
        return SCAN_PENDING.equals(status)
                || SCANNING.equals(status)
                || SCAN_RETRY.equals(status)
                || SCAN_NEEDS_ATTENTION.equals(status)
                || INFECTED.equals(status)
                || READY.equals(status)
                || ATTACHED.equals(status);
    }

    private AttachmentView toAttachmentView(ChatAttachmentEntity attachment) {
        return new AttachmentView(
                attachment.getId(),
                attachment.getOriginalFilename(),
                attachment.getMimeType(),
                attachment.getSizeBytes());
    }

    private String hash(String... values) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            String normalized = String.join("\u001f", values);
            return HexFormat.of().formatHex(digest.digest(normalized.getBytes(StandardCharsets.UTF_8)));
        } catch (NoSuchAlgorithmException exception) {
            throw new IllegalStateException("SHA-256 is unavailable", exception);
        }
    }
}