//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import LibSignalClient
public import UIKit

public struct ValidatedQuotedReply {
    public let quotedReply: TSQuotedMessage
    public let thumbnailDataSource: QuotedReplyAttachmentDataSource?
}

// MARK: -

public protocol QuotedReplyManager {

    func validateAndBuildQuotedReply(
        from quoteProto: SSKProtoDataMessageQuote,
        threadUniqueId: String,
        tx: DBReadTransaction,
    ) throws -> ValidatedQuotedReply

    func buildDraftQuotedReply(
        originalMessage: TSMessage,
        loadNormalizedImage: (CGImageSource, CGFloat) -> CGImage?,
        tx: DBReadTransaction,
    ) -> DraftQuotedReplyModel?

    func buildDraftQuotedReplyForEditing(
        quotedReplyMessage: TSMessage,
        quotedReply: TSQuotedMessage,
        originalMessage: TSMessage?,
        loadNormalizedImage: (CGImageSource, CGFloat) -> CGImage?,
        tx: DBReadTransaction,
    ) -> DraftQuotedReplyModel

    func prepareDraftForSending(
        _ draft: DraftQuotedReplyModel,
    ) async throws -> DraftQuotedReplyModel.ForSending

    func prepareQuotedReplyForSending(
        draft: DraftQuotedReplyModel.ForSending,
        tx: DBReadTransaction,
    ) -> ValidatedQuotedReply

    func buildProtoForSending(
        _ quote: TSQuotedMessage,
        outgoingMessage: TSOutgoingMessage,
        tx: DBReadTransaction,
    ) throws -> SSKProtoDataMessageQuote
}

// MARK: -

class QuotedReplyManagerImpl: QuotedReplyManager {

    private let attachmentStore: AttachmentStore
    private let attachmentValidator: AttachmentContentValidator
    private let db: any DB
    private let tsAccountManager: TSAccountManager

    init(
        attachmentStore: AttachmentStore,
        attachmentValidator: AttachmentContentValidator,
        db: any DB,
        tsAccountManager: TSAccountManager,
    ) {
        self.attachmentStore = attachmentStore
        self.attachmentValidator = attachmentValidator
        self.db = db
        self.tsAccountManager = tsAccountManager
    }

    func validateAndBuildQuotedReply(
        from quoteProto: SSKProtoDataMessageQuote,
        threadUniqueId: String,
        tx: DBReadTransaction,
    ) throws -> ValidatedQuotedReply {
        let timestamp = quoteProto.id
        guard timestamp != 0, SDS.fitsInInt64(timestamp) else {
            throw OWSAssertionError("Quoted message invalid timestamp! \(timestamp)")
        }

        guard
            let quoteAuthor = Aci.parseFrom(
                serviceIdBinary: quoteProto.authorAciBinary,
                serviceIdString: quoteProto.authorAci,
            )
        else {
            throw OWSAssertionError("Quoted message missing or invalid author!")
        }

        let originalMessage = InteractionFinder.findMessage(
            withTimestamp: timestamp,
            threadId: threadUniqueId,
            author: .init(quoteAuthor),
            transaction: tx,
        )
        if let originalMessage {
            // Prefer to generate the quoted content locally if available.
            do {
                return try localQuotedMessage(
                    originalMessage: originalMessage,
                    quoteProto: quoteProto,
                    quoteAuthor: quoteAuthor,
                    tx: tx,
                )
            } catch {
                Logger.warn("Failed to build quote message locally! \(error)")
            }
        }

        // If we couldn't generate the quoted content from local data, we can
        // generate it from the proto.
        return try remoteQuotedMessage(
            quoteProto: quoteProto,
            quoteAuthor: quoteAuthor,
            quoteTimestamp: timestamp,
            tx: tx,
        )
    }

    /// Builds a remote message from the proto payload
    /// NOTE: Quoted messages constructed from proto material may not be representative of the original source content. This
    /// should be flagged to the user. (See: ``QuotedReplyModel.isRemotelySourced``)
    private func remoteQuotedMessage(
        quoteProto: SSKProtoDataMessageQuote,
        quoteAuthor: Aci,
        quoteTimestamp: UInt64,
        tx: DBReadTransaction,
    ) throws -> ValidatedQuotedReply {
        let quoteAuthorAddress = SignalServiceAddress(quoteAuthor)

        // This is untrusted content from other users that may not be well-formed.
        // The GiftBadge type has no content/attachments, so don't read those
        // fields if the type is GiftBadge.
        if
            quoteProto.hasType,
            quoteProto.unwrappedType == .giftBadge
        {
            return ValidatedQuotedReply(
                quotedReply: TSQuotedMessage(
                    timestamp: quoteTimestamp,
                    authorAddress: quoteAuthorAddress,
                    body: nil,
                    bodyRanges: nil,
                    bodySource: .remote,
                    receivedQuotedAttachmentInfo: nil,
                    isGiftBadge: true,
                    isTargetMessageViewOnce: false,
                    isPoll: false,
                ),
                thumbnailDataSource: nil,
            )
        }

        let body = quoteProto.text?.nilIfEmpty
        let bodyRanges = quoteProto.bodyRanges.isEmpty ? nil : MessageBodyRanges(protos: quoteProto.bodyRanges)

        let thumbnailAttachmentInfo: OWSAttachmentInfo?
        let thumbnailDataSource: QuotedReplyAttachmentDataSource?
        if
            // We're only interested in the first attachment
            let quotedAttachment = quoteProto.attachments.first,
            let thumbnailProto = quotedAttachment.thumbnail
        {
            let mimeType: String = quotedAttachment.contentType?.nilIfEmpty
                ?? MimeType.applicationOctetStream.rawValue
            let renderingFlag: AttachmentReference.RenderingFlag = .fromProto(thumbnailProto)

            thumbnailAttachmentInfo = OWSAttachmentInfo(
                originalAttachmentMimeType: mimeType,
                originalAttachmentSourceFilename: quotedAttachment.fileName,
                originalAttachmentRenderingFlag: renderingFlag,
            )
            thumbnailDataSource = .notFoundLocallyAttachment(QuotedReplyAttachmentDataSource.NotFoundLocallyAttachmentSource(
                thumbnailPointerProto: thumbnailProto,
                originalAttachmentMimeType: mimeType,
                originalAttachmentRenderingFlag: renderingFlag,
            ))
        } else if
            let quotedAttachment = quoteProto.attachments.first,
            let mimeType = quotedAttachment.contentType
        {
            thumbnailAttachmentInfo = OWSAttachmentInfo(
                originalAttachmentMimeType: mimeType,
                originalAttachmentSourceFilename: quotedAttachment.fileName,
                originalAttachmentRenderingFlag: nil,
            )
            thumbnailDataSource = nil
        } else {
            thumbnailAttachmentInfo = nil
            thumbnailDataSource = nil
        }

        if body?.nilIfEmpty == nil, thumbnailAttachmentInfo == nil {
            throw OWSAssertionError("Remote quoted message proto missing content!")
        }

        return ValidatedQuotedReply(
            quotedReply: TSQuotedMessage(
                timestamp: quoteTimestamp,
                authorAddress: quoteAuthorAddress,
                body: body,
                bodyRanges: bodyRanges,
                bodySource: .remote,
                receivedQuotedAttachmentInfo: thumbnailAttachmentInfo,
                isGiftBadge: false,
                isTargetMessageViewOnce: false,
                isPoll: false,
            ),
            thumbnailDataSource: thumbnailDataSource,
        )
    }

    /// Builds a quoted message from the original source message
    private func localQuotedMessage(
        originalMessage: TSMessage,
        quoteProto: SSKProtoDataMessageQuote,
        quoteAuthor: Aci,
        tx: DBReadTransaction,
    ) throws -> ValidatedQuotedReply {
        let authorAddress: SignalServiceAddress
        if let incomingOriginal = originalMessage as? TSIncomingMessage {
            authorAddress = incomingOriginal.authorAddress
        } else if originalMessage is TSOutgoingMessage {
            guard
                let localAddress = DependenciesBridge.shared.tsAccountManager.localIdentifiers(
                    tx: tx,
                )?.aciAddress
            else {
                throw NotRegisteredError()
            }
            authorAddress = localAddress
        } else {
            throw OWSAssertionError("Received message of type: \(type(of: originalMessage))")
        }

        if originalMessage.isViewOnceMessage {
            // We construct a quote that does not include any of the quoted message's renderable content.
            return ValidatedQuotedReply(
                quotedReply: TSQuotedMessage(
                    timestamp: originalMessage.timestamp,
                    authorAddress: authorAddress,
                    body: nil,
                    bodyRanges: nil,
                    bodySource: .local,
                    receivedQuotedAttachmentInfo: nil,
                    isGiftBadge: false,
                    isTargetMessageViewOnce: true,
                    isPoll: false,
                ),
                thumbnailDataSource: nil,
            )
        }

        let body: String?
        let bodyRanges: MessageBodyRanges?
        var isGiftBadge: Bool
        var isPoll: Bool

        if originalMessage is OWSPaymentMessage {
            // This really should recalculate the string from payment metadata.
            // But it does not.
            body = quoteProto.text
            bodyRanges = nil
            isGiftBadge = false
            isPoll = false
        } else if let messageBody = originalMessage.body?.nilIfEmpty {
            body = messageBody
            bodyRanges = originalMessage.bodyRanges
            isGiftBadge = false
            isPoll = originalMessage.isPoll
        } else if let contactName = originalMessage.contactShare?.name.displayName.nilIfEmpty {
            // Contact share bodies are special-cased in OWSQuotedReplyModel
            // We need to account for that here.
            body = "👤 " + contactName
            bodyRanges = nil
            isGiftBadge = false
            isPoll = false
        } else if let storyReactionEmoji = originalMessage.storyReactionEmoji?.nilIfEmpty {
            let formatString: String = {
                if authorAddress.isLocalAddress {
                    return OWSLocalizedString(
                        "STORY_REACTION_QUOTE_FORMAT_SECOND_PERSON",
                        comment: "quote text for a reaction to a story by the user (the header on the bubble says \"You\"). Embeds {{reaction emoji}}",
                    )
                } else {
                    return OWSLocalizedString(
                        "STORY_REACTION_QUOTE_FORMAT_THIRD_PERSON",
                        comment: "quote text for a reaction to a story by some other user (the header on the bubble says their name, e.g. \"Bob\"). Embeds {{reaction emoji}}",
                    )
                }
            }()
            body = String.nonPluralLocalizedStringWithFormat(formatString, storyReactionEmoji)
            bodyRanges = nil
            isGiftBadge = false
            isPoll = false
        } else {
            isGiftBadge = originalMessage.giftBadge != nil
            body = nil
            bodyRanges = nil
            isPoll = false
        }

        let thumbnailAttachmentInfo: OWSAttachmentInfo?
        let thumbnailOriginalAttachmentSource: QuotedReplyAttachmentDataSource.OriginalAttachmentSource?
        if
            let (info, attachmentSource) = quotedReplyAttachmentInfo(
                originalMessage: originalMessage,
                quoteProto: quoteProto,
                tx: tx,
            )
        {
            thumbnailAttachmentInfo = info
            thumbnailOriginalAttachmentSource = attachmentSource
        } else {
            thumbnailAttachmentInfo = nil
            thumbnailOriginalAttachmentSource = nil
        }

        if
            body?.nilIfEmpty == nil,
            thumbnailAttachmentInfo == nil,
            !isGiftBadge
        {
            throw OWSAssertionError("Quoted message has no content!")
        }

        return ValidatedQuotedReply(
            quotedReply: TSQuotedMessage(
                timestamp: originalMessage.timestamp,
                authorAddress: authorAddress,
                body: body,
                bodyRanges: bodyRanges,
                bodySource: .local,
                receivedQuotedAttachmentInfo: thumbnailAttachmentInfo,
                isGiftBadge: isGiftBadge,
                // Checked above
                isTargetMessageViewOnce: false,
                isPoll: isPoll,
            ),
            thumbnailDataSource: thumbnailOriginalAttachmentSource.map { .originalAttachment($0) },
        )
    }

    private func quotedReplyAttachmentInfo(
        originalMessage: TSMessage,
        quoteProto: SSKProtoDataMessageQuote,
        tx: DBReadTransaction,
    ) -> (OWSAttachmentInfo, QuotedReplyAttachmentDataSource.OriginalAttachmentSource?)? {
        if quoteProto.attachments.isEmpty {
            // If the quote we got has no attachments, ignore any attachments
            // on the original message.
            return nil
        }

        if
            let originalMessageRowId = originalMessage.sqliteRowId,
            let originalReference = attachmentStore.attachmentToUseInQuote(
                originalMessageRowId: originalMessageRowId,
                tx: tx,
            ),
            let originalAttachment = attachmentStore.fetch(
                id: originalReference.attachmentRowId,
                tx: tx,
            )
        {
            let attachmentInfo = OWSAttachmentInfo(
                originalAttachmentMimeType: originalAttachment.mimeType,
                originalAttachmentSourceFilename: originalReference.sourceFilename,
                originalAttachmentRenderingFlag: originalReference.renderingFlag,
            )

            let source = QuotedReplyAttachmentDataSource.OriginalAttachmentSource(
                id: originalAttachment.id,
                mimeType: originalAttachment.mimeType,
                renderingFlag: originalReference.renderingFlag,
                sourceFilename: originalReference.sourceFilename,
                sourceUnencryptedByteCount: originalReference.sourceUnencryptedByteCount,
                sourceMediaSizePixels: originalReference.sourceMediaSizePixels,
                thumbnailPointerFromSender: quoteProto.attachments.first?.thumbnail,
            )

            return (attachmentInfo, source)
        } else {
            // This could happen if a sender spoofs their quoted message proto.
            // Our quoted message will include no thumbnails.
            owsFailDebug("Sender sent \(quoteProto.attachments.count) quoted attachments. Local copy has none.")
            return nil
        }
    }

    // MARK: - Creating draft

    func buildDraftQuotedReply(
        originalMessage: TSMessage,
        loadNormalizedImage: (CGImageSource, CGFloat) -> CGImage?,
        tx: DBReadTransaction,
    ) -> DraftQuotedReplyModel? {
        if originalMessage is OWSPaymentMessage {
            owsFailDebug("Use dedicated DraftQuotedReplyModel initializer for payment messages")
        }

        let timestamp = originalMessage.timestamp

        let authorAddress: SignalServiceAddress? = {
            if originalMessage is TSOutgoingMessage {
                return tsAccountManager.localIdentifiers(tx: tx)?.aciAddress
            }
            if let incomingMessage = originalMessage as? TSIncomingMessage {
                return incomingMessage.authorAddress
            }
            owsFailDebug("Unexpected message type: \(originalMessage.self)")
            return nil
        }()
        guard let authorAddress, authorAddress.isValid else {
            owsFailDebug("No authorAddress or address is not valid.")
            return nil
        }

        let originalMessageBody: () -> MessageBody? = {
            guard let body = originalMessage.body else {
                return nil
            }
            return MessageBody(text: body, ranges: originalMessage.bodyRanges ?? .empty)
        }

        func createDraftReply(content: DraftQuotedReplyModel.Content) -> DraftQuotedReplyModel {
            return DraftQuotedReplyModel(
                originalMessageTimestamp: timestamp,
                originalMessageAuthorAddress: authorAddress,
                isOriginalMessageAuthorLocalUser: originalMessage is TSOutgoingMessage,
                threadUniqueId: originalMessage.uniqueThreadId,
                content: content,
            )
        }

        func createTextDraftReplyOrNil() -> DraftQuotedReplyModel? {
            if let originalMessageBody = originalMessageBody() {
                return createDraftReply(content: .text(originalMessageBody))
            } else {
                return nil
            }
        }

        if originalMessage.isViewOnceMessage {
            return createDraftReply(content: .viewOnce)
        }

        if let contactShare = originalMessage.contactShare {
            return createDraftReply(content: .contactShare(contactShare))
        }

        if originalMessage.giftBadge != nil {
            return createDraftReply(content: .giftBadge)
        }

        if originalMessage.messageSticker != nil {
            guard
                let originalMessageRowId = originalMessage.sqliteRowId,
                let attachment = attachmentStore.fetchAnyReferencedAttachment(
                    for: .messageSticker(messageRowId: originalMessageRowId),
                    tx: tx,
                ),
                let stickerData = try? attachment.attachment.asStream()?.decryptedRawData()
            else {
                owsFailDebug("Couldn't load sticker data")
                return nil
            }

            // Sticker type metadata isn't reliable, so determine the sticker type by examining the actual sticker data.
            let imageMetadata = DataImageSource(stickerData).imageMetadata()
            switch imageMetadata?.imageFormat {
            case .png, .gif, .webp:
                break
            case let imageFormat:
                owsFailDebug("Invalid sticker data format: \(imageFormat as Optional)")
                return nil
            }

            let dataSource = CGImageSourceCreateWithData(
                stickerData as CFData,
                [kCGImageSourceShouldCache: false] as CFDictionary,
            )
            guard let dataSource else {
                owsFailDebug("couldn't parse sticker")
                return nil
            }

            let maxThumbnailSizePixels: CGFloat = 512
            let thumbnailImage = loadNormalizedImage(dataSource, maxThumbnailSizePixels)
            guard let thumbnailImage else {
                owsFailDebug("couldn't resize sticker")
                return nil
            }

            return createDraftReply(content: .attachment(
                nil,
                attachmentRef: attachment.reference,
                attachment: attachment.attachment,
                thumbnailImage: UIImage(cgImage: thumbnailImage),
            ))
        }

        if
            let originalMessageRowId = originalMessage.sqliteRowId,
            let attachmentRef = attachmentStore.attachmentToUseInQuote(originalMessageRowId: originalMessageRowId, tx: tx),
            let attachment = attachmentStore.fetch(id: attachmentRef.attachmentRowId, tx: tx)
        {
            if
                let stream = attachment.asStream(),
                stream.contentType.isVisualMedia,
                let thumbnailImage = stream.thumbnailImageSync(quality: .small)
            {

                guard
                    let resizedThumbnailImage = thumbnailImage.resized(
                        maxDimensionPoints: AttachmentThumbnailQuality.thumbnailDimensionPointsForQuotedReply,
                    )
                else {
                    owsFailDebug("Couldn't generate thumbnail.")
                    return nil
                }

                return createDraftReply(content: .attachment(
                    originalMessageBody(),
                    attachmentRef: attachmentRef,
                    attachment: stream.attachment,
                    thumbnailImage: resizedThumbnailImage,
                ))
            } else if attachment.mimeType == MimeType.textXSignalPlain.rawValue {
                // If the attachment is "oversize text", try the quote as a reply to text, not as
                // a reply to an attachment.
                if
                    let oversizeTextData = try? attachment.asStream()?.decryptedRawData(),
                    let oversizeText = String(data: oversizeTextData, encoding: .utf8)
                {
                    // We don't need to include the entire text body of the message, just enough
                    // to render a snippet.  OWSMediaUtils.kOversizeTextMessageSizeThresholdBytes
                    // is our limit on how long text should be in protos since they'll be stored in
                    // the database. We apply this constant here for the same reasons.
                    let truncatedText = oversizeText.trimToUtf8ByteCount(OWSMediaUtils.kOversizeTextMessageSizeThresholdBytes)
                    return createDraftReply(content: .text(
                        MessageBody(text: truncatedText, ranges: originalMessage.bodyRanges ?? .empty),
                    ))
                } else {
                    return createTextDraftReplyOrNil()
                }
            } else if MimeTypeUtil.isSupportedVisualMediaMimeType(attachment.mimeType) {
                return createDraftReply(content: .attachment(
                    originalMessageBody(),
                    attachmentRef: attachmentRef,
                    attachment: attachment,
                    thumbnailImage: attachment.blurHash.flatMap(BlurHash.image(for:)),
                ))
            } else {
                let stub = QuotedMessageAttachmentReference.Stub(
                    mimeType: attachment.mimeType,
                    sourceFilename: attachmentRef.sourceFilename,
                    renderingFlag: attachmentRef.renderingFlag,
                )

                return createDraftReply(content: .attachmentStub(
                    originalMessageBody(),
                    stub,
                ))
            }
        }

        if let storyReactionEmoji = originalMessage.storyReactionEmoji?.nilIfEmpty {
            return createDraftReply(content: .storyReactionEmoji(storyReactionEmoji))
        }

        if originalMessage.isPoll {
            guard let body = originalMessage.body else {
                owsFailDebug("Poll message has no question body.")
                return nil
            }
            return createDraftReply(content: .poll(body))
        }

        return createTextDraftReplyOrNil()
    }

    func buildDraftQuotedReplyForEditing(
        quotedReplyMessage: TSMessage,
        quotedReply: TSQuotedMessage,
        originalMessage: TSMessage?,
        loadNormalizedImage: (CGImageSource, CGFloat) -> CGImage?,
        tx: DBReadTransaction,
    ) -> DraftQuotedReplyModel {
        if
            let originalMessage,
            let innerContent = self.buildDraftQuotedReply(
                originalMessage: originalMessage,
                loadNormalizedImage: loadNormalizedImage,
                tx: tx,
            )
        {
            return DraftQuotedReplyModel(
                originalMessageTimestamp: innerContent.originalMessageTimestamp,
                originalMessageAuthorAddress: innerContent.originalMessageAuthorAddress,
                isOriginalMessageAuthorLocalUser: innerContent.isOriginalMessageAuthorLocalUser,
                threadUniqueId: quotedReplyMessage.uniqueThreadId,
                content: .edit(
                    quotedReplyMessage,
                    quotedReply,
                    content: innerContent.content,
                ),
            )
        } else {
            // Couldn't find the message or build contents.
            // If we can't find the original, use the body we have.
            let isOriginalMessageAuthorLocalUser = tsAccountManager.localIdentifiers(tx: tx)?.aciAddress
                .isEqualToAddress(quotedReply.authorAddress) ?? false

            let innerContent: DraftQuotedReplyModel.Content = {
                let messageBody = quotedReply.body.map { MessageBody(text: $0, ranges: quotedReply.bodyRanges ?? .empty) }
                if
                    let quotedMessageAttachmentReference = attachmentStore.quotedAttachmentReference(
                        owningMessage: quotedReplyMessage,
                        tx: tx,
                    )
                {
                    switch quotedMessageAttachmentReference {
                    case .thumbnail(let referencedAttachment):
                        return .attachment(
                            messageBody,
                            attachmentRef: referencedAttachment.reference,
                            attachment: referencedAttachment.attachment,
                            thumbnailImage: referencedAttachment.attachment.asStream()?.thumbnailImageSync(quality: .small),
                        )
                    case .stub(let stub):
                        return .attachmentStub(messageBody, stub)
                    }
                } else if let messageBody {
                    return .text(messageBody)
                } else {
                    return .text(MessageBody(
                        text: OWSLocalizedString(
                            "QUOTED_REPLY_CONTENT_FROM_REMOTE_SOURCE",
                            comment: "Footer label that appears below quoted messages when the quoted content was not derived locally. When the local user doesn't have a copy of the message being quoted, e.g. if it had since been deleted, we instead show the content specified by the sender.",
                        ),
                        ranges: .empty,
                    ))
                }
            }()

            return DraftQuotedReplyModel(
                originalMessageTimestamp: quotedReply.timestampValue?.uint64Value,
                originalMessageAuthorAddress: quotedReply.authorAddress,
                isOriginalMessageAuthorLocalUser: isOriginalMessageAuthorLocalUser,
                threadUniqueId: quotedReplyMessage.uniqueThreadId,
                content: .edit(
                    quotedReplyMessage,
                    quotedReply,
                    content: innerContent,
                ),
            )
        }
    }

    func prepareDraftForSending(
        _ draft: DraftQuotedReplyModel,
    ) async throws -> DraftQuotedReplyModel.ForSending {
        switch draft.content {
        case .edit(_, let tsQuotedMessage, _):
            return DraftQuotedReplyModel.ForSending(
                originalMessageTimestamp: draft.originalMessageTimestamp,
                originalMessageAuthorAddress: draft.originalMessageAuthorAddress,
                originalMessageIsGiftBadge: draft.content.isGiftBadge,
                originalMessageIsViewOnce: draft.content.isViewOnce,
                originalMessageIsPoll: draft.content.isPoll,
                threadUniqueId: draft.threadUniqueId,
                quoteBody: draft.bodyForSending,
                attachment: nil,
                quotedMessageFromEdit: tsQuotedMessage,
            )
        default:
            break
        }

        // Find the original message and any attachment
        let originalAttachmentReference: AttachmentReference?
        let originalAttachment: Attachment?
        (
            originalAttachmentReference,
            originalAttachment,
        ) = db.read { tx in
            guard
                let originalMessageTimestamp = draft.originalMessageTimestamp,
                let originalMessage = InteractionFinder.findMessage(
                    withTimestamp: originalMessageTimestamp,
                    threadId: draft.threadUniqueId,
                    author: draft.originalMessageAuthorAddress,
                    transaction: tx,
                )
            else {
                return (nil, nil)
            }
            let attachmentReference = attachmentStore.attachmentToUseInQuote(
                originalMessageRowId: originalMessage.sqliteRowId!,
                tx: tx,
            )
            let attachment = attachmentStore.fetch(ids: [attachmentReference?.attachmentRowId].compacted(), tx: tx).first
            return (attachmentReference, attachment)
        }

        let quoteAttachment = await { () -> DraftQuotedReplyModel.ForSending.Attachment? in
            guard
                let originalAttachmentReference,
                let originalAttachment
            else {
                return nil
            }
            let isVisualMedia: Bool = {
                if let contentType = originalAttachment.asStream()?.contentType {
                    return contentType.isVisualMedia
                } else {
                    return MimeTypeUtil.isSupportedVisualMediaMimeType(originalAttachment.mimeType)
                }
            }()
            guard isVisualMedia, let originalAttachmentStream = originalAttachment.asStream() else {
                // Just return a stub for non-visual or undownloaded media.
                return .stub(QuotedMessageAttachmentReference.Stub(
                    mimeType: originalAttachment.mimeType,
                    sourceFilename: originalAttachmentReference.sourceFilename,
                    renderingFlag: originalAttachmentReference.renderingFlag,
                ))
            }
            do {
                let dataSource = try await attachmentValidator.prepareQuotedReplyThumbnail(
                    fromOriginalAttachment: originalAttachmentStream,
                    originalReference: originalAttachmentReference,
                )
                return .thumbnail(
                    dataSource,
                    originalAttachmentSourceFilename: originalAttachmentReference.sourceFilename,
                )
            } catch {
                // If we experience errors, just fall back to a stub.
                return .stub(QuotedMessageAttachmentReference.Stub(
                    mimeType: originalAttachment.mimeType,
                    sourceFilename: originalAttachmentReference.sourceFilename,
                    renderingFlag: originalAttachmentReference.renderingFlag,
                ))
            }
        }()

        return DraftQuotedReplyModel.ForSending(
            originalMessageTimestamp: draft.originalMessageTimestamp,
            originalMessageAuthorAddress: draft.originalMessageAuthorAddress,
            originalMessageIsGiftBadge: draft.content.isGiftBadge,
            originalMessageIsViewOnce: draft.content.isViewOnce,
            originalMessageIsPoll: draft.content.isPoll,
            threadUniqueId: draft.threadUniqueId,
            quoteBody: draft.bodyForSending,
            attachment: quoteAttachment,
            quotedMessageFromEdit: nil,
        )
    }

    func prepareQuotedReplyForSending(
        draft: DraftQuotedReplyModel.ForSending,
        tx: DBReadTransaction,
    ) -> ValidatedQuotedReply {
        if let tsQuotedMessage = draft.quotedMessageFromEdit {
            return ValidatedQuotedReply(
                quotedReply: tsQuotedMessage,
                thumbnailDataSource: nil,
            )
        }

        // Find the original message.
        guard
            let originalMessageTimestamp = draft.originalMessageTimestamp,
            let originalMessage = InteractionFinder.findMessage(
                withTimestamp: originalMessageTimestamp,
                threadId: draft.threadUniqueId,
                author: draft.originalMessageAuthorAddress,
                transaction: tx,
            )
        else {
            return ValidatedQuotedReply(
                quotedReply: TSQuotedMessage(
                    timestamp: draft.originalMessageTimestamp ?? 0,
                    authorAddress: draft.originalMessageAuthorAddress,
                    body: OWSLocalizedString(
                        "QUOTED_REPLY_CONTENT_FROM_REMOTE_SOURCE",
                        comment: "Footer label that appears below quoted messages when the quoted content was not derived locally. When the local user doesn't have a copy of the message being quoted, e.g. if it had since been deleted, we instead show the content specified by the sender.",
                    ),
                    bodyRanges: nil,
                    bodySource: .remote,
                    receivedQuotedAttachmentInfo: nil,
                    isGiftBadge: false,
                    isTargetMessageViewOnce: false,
                    isPoll: false,
                ),
                thumbnailDataSource: nil,
            )
        }

        let body = draft.quoteBody

        func buildQuotedMessage(_ attachmentInfo: OWSAttachmentInfo?) -> TSQuotedMessage {
            return TSQuotedMessage(
                timestamp: draft.originalMessageTimestamp.map(NSNumber.init(value:)),
                authorAddress: draft.originalMessageAuthorAddress,
                body: body?.text,
                bodyRanges: body?.ranges,
                quotedAttachmentForSending: attachmentInfo,
                isGiftBadge: draft.originalMessageIsGiftBadge,
                isTargetMessageViewOnce: draft.originalMessageIsViewOnce,
                isPoll: draft.originalMessageIsPoll,
            )
        }

        guard
            let quotedAttachment = draft.attachment,
            !originalMessage.isViewOnceMessage
        else {
            return ValidatedQuotedReply(
                quotedReply: buildQuotedMessage(nil),
                thumbnailDataSource: nil,
            )
        }

        switch quotedAttachment {
        case .stub(let stub):
            let thumbnailAttachmentInfo = OWSAttachmentInfo(
                originalAttachmentMimeType: stub.mimeType ?? MimeType.applicationOctetStream.rawValue,
                originalAttachmentSourceFilename: stub.sourceFilename,
                originalAttachmentRenderingFlag: stub.renderingFlag,
            )

            return ValidatedQuotedReply(
                quotedReply: buildQuotedMessage(thumbnailAttachmentInfo),
                thumbnailDataSource: nil,
            )
        case .thumbnail(let dataSource, let originalAttachmentSourceFilename):
            let thumbnailAttachmentInfo = OWSAttachmentInfo(
                originalAttachmentMimeType: dataSource.originalAttachmentMimeType,
                originalAttachmentSourceFilename: originalAttachmentSourceFilename,
                originalAttachmentRenderingFlag: dataSource.originalAttachmentRenderingFlag,
            )

            return ValidatedQuotedReply(
                quotedReply: buildQuotedMessage(thumbnailAttachmentInfo),
                thumbnailDataSource: dataSource,
            )
        }
    }

    // MARK: - Outgoing proto

    func buildProtoForSending(
        _ quote: TSQuotedMessage,
        outgoingMessage: TSOutgoingMessage,
        tx: DBReadTransaction,
    ) throws -> SSKProtoDataMessageQuote {
        guard let timestamp = quote.timestampValue?.uint64Value else {
            throw OWSAssertionError("Missing timestamp")
        }
        let quoteBuilder = SSKProtoDataMessageQuote.builder(id: timestamp)

        guard let authorAci = quote.authorAddress.aci else {
            throw OWSAssertionError("It should be impossible to quote a message without a UUID")
        }
        quoteBuilder.setAuthorAciBinary(authorAci.serviceIdBinary)

        var hasQuotedText = false
        var hasQuotedAttachment = false
        var hasQuotedGiftBadge = false

        if let body = quote.body?.nilIfEmpty {
            hasQuotedText = true
            quoteBuilder.setText(body)
            if let bodyRanges = quote.bodyRanges {
                quoteBuilder.setBodyRanges(bodyRanges.toProtoBodyRanges(bodyLength: (body as NSString).length))
            }
        }

        if
            let attachmentProto = buildAttachmentProtoForSending(
                outgoingMessage: outgoingMessage,
                tx: tx,
            )
        {
            hasQuotedAttachment = true
            quoteBuilder.setAttachments([attachmentProto])
        }

        if quote.isGiftBadge {
            hasQuotedGiftBadge = true
            quoteBuilder.setType(.giftBadge)
        }

        if quote.isTargetMessageViewOnce {
            if !hasQuotedText {
                quoteBuilder.setText(OWSLocalizedString(
                    "PER_MESSAGE_EXPIRATION_NOT_VIEWABLE",
                    comment: "inbox cell and notification text for an already viewed view-once media message.",
                ))
            }
        }

        guard hasQuotedText || hasQuotedAttachment || hasQuotedGiftBadge else {
            throw OWSAssertionError("Invalid quoted message data.")
        }

        return try quoteBuilder.build()
    }

    private func buildAttachmentProtoForSending(
        outgoingMessage: TSOutgoingMessage,
        tx: DBReadTransaction,
    ) -> SSKProtoDataMessageQuoteQuotedAttachment? {
        guard
            let quotedMessageAttachmentReference = attachmentStore.quotedAttachmentReference(
                owningMessage: outgoingMessage,
                tx: tx,
            )
        else {
            return nil
        }

        let mimeType: String?
        let sourceFilename: String?
        let attachmentProto: SSKProtoAttachmentPointer?
        switch quotedMessageAttachmentReference {
        case .thumbnail(let referencedAttachment):
            mimeType = referencedAttachment.attachment.mimeType
            sourceFilename = referencedAttachment.reference.sourceFilename
            attachmentProto = referencedAttachment.asProtoForSending()
        case .stub(let stub):
            mimeType = stub.mimeType
            sourceFilename = stub.sourceFilename
            attachmentProto = nil
        }

        let builder = SSKProtoDataMessageQuoteQuotedAttachment.builder()
        if let mimeType {
            builder.setContentType(mimeType)
        }
        if let sourceFilename {
            builder.setFileName(sourceFilename)
        }
        if let attachmentProto {
            builder.setThumbnail(attachmentProto)
        }
        return builder.buildInfallibly()
    }
}
