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

import Foundation
import LibSignalClient

extension BackupArchive {

    /// Represents message content "types" as they are represented in iOS code, after
    /// being mapped from their representation in the backup proto. For example, normal
    /// text messages and quoted replies are a single "type" in the proto, but have separate
    /// class structures in the iOS code.
    ///
    /// This object will be passed back into the ``BackupArchiveTSMessageContentsArchiver`` class
    /// after the TSMessage has been created, so that downstream objects that require the TSMessage exist
    /// can be created afterwards. Anything needed for that step, but not needed to create the TSMessage,
    /// should be made a fileprivate variable in these structs.
    enum RestoredMessageContents {
        struct Payment {
            enum Status {
                case success(BackupProto_PaymentNotification.TransactionDetails.Transaction.Status)
                case failure(BackupProto_PaymentNotification.TransactionDetails.FailedTransaction.FailureReason)
            }

            let amount: String?
            let fee: String?
            let note: String?

            fileprivate let status: Status
            fileprivate let payment: BackupProto_PaymentNotification.TransactionDetails.Transaction?
        }

        struct Text {
            struct RestoredMessageBody: ValidatedInlineMessageBody {
                enum OversizeText {
                    // The exporter presumably hadn't downloaded the attachment
                    // at export time, so all we have is a pointer.
                    case attachmentPointer(BackupProto_FilePointer)
                    // Any time the exporter has downloaded the oversize text
                    // attachment, they inline the text in the backup proto.
                    // We will unfold it back into a restored attachment stream.
                    case inlined(String)
                }

                // This is the body we put on the message
                let inlinedBody: MessageBody
                fileprivate let oversizeText: OversizeText?

                init(inlinedBody: MessageBody, oversizeText: OversizeText?) {
                    self.inlinedBody = inlinedBody
                    self.oversizeText = oversizeText
                }
            }

            let body: RestoredMessageBody?
            let quotedMessage: TSQuotedMessage?
            let linkPreview: OWSLinkPreview?
            let isVoiceMessage: Bool

            fileprivate let reactions: [BackupProto_Reaction]
            fileprivate let bodyAttachments: [BackupProto_MessageAttachment]
            fileprivate let quotedMessageThumbnail: BackupProto_MessageAttachment?
            fileprivate let linkPreviewImage: BackupProto_FilePointer?
        }

        /// Note: not a "Contact" in the Signal sense (not a Recipient or SignalAccount), just a message
        /// that includes contact info taken from system contacts; the user must interact with it to do
        /// anything, such as adding the shared contact info to a new system contact.
        struct ContactShare {
            let contact: OWSContact

            fileprivate let avatarAttachment: BackupProto_FilePointer?
            fileprivate let reactions: [BackupProto_Reaction]
        }

        struct StickerMessage {
            let sticker: MessageSticker

            fileprivate let attachment: BackupProto_FilePointer
            fileprivate let reactions: [BackupProto_Reaction]
        }

        struct GiftBadge {
            let giftBadge: OWSGiftBadge
        }

        struct ViewOnceMessage {
            enum State {
                case complete
                case unviewed(BackupProto_MessageAttachment)
            }

            let state: State

            fileprivate let reactions: [BackupProto_Reaction]
        }

        struct StoryReply {
            enum ReplyType {
                struct TextReply {
                    let body: Text.RestoredMessageBody
                }

                case textReply(TextReply)
                case emoji(String)
            }

            let replyType: ReplyType
            fileprivate let reactions: [BackupProto_Reaction]
        }

        struct Poll {
            let poll: BackupsPollData
            let question: Text.RestoredMessageBody

            fileprivate let reactions: [BackupProto_Reaction]
        }

        case archivedPayment(Payment)
        case remoteDeleteTombstone
        case text(Text)
        case contactShare(ContactShare)
        case stickerMessage(StickerMessage)
        case giftBadge(GiftBadge)
        case viewOnceMessage(ViewOnceMessage)
        /// Note: only includes 1:1 story replies, not group story replies.
        case storyReply(StoryReply)
        case poll(Poll)
        case adminDeleteTombstone(RecipientId)
    }
}

class BackupArchiveTSMessageContentsArchiver: BackupArchiveProtoStreamWriter {

    typealias ChatItemType = BackupArchive.InteractionArchiveDetails.ChatItemType

    typealias ArchiveInteractionResult = BackupArchive.ArchiveInteractionResult
    typealias RestoreInteractionResult = BackupArchive.RestoreInteractionResult

    private typealias ArchiveFrameError = BackupArchive.ArchiveFrameError
    private typealias RestoreFrameError = BackupArchive.RestoreFrameError

    private let interactionStore: BackupArchiveInteractionStore
    private let archivedPaymentStore: ArchivedPaymentStore
    private let attachmentsArchiver: BackupArchiveMessageAttachmentArchiver
    private let attachmentStore: AttachmentStore
    private let contactAttachmentArchiver: BackupArchiveContactAttachmentArchiver
    private let oversizeTextArchiver: BackupArchiveInlinedOversizeTextArchiver
    private let reactionArchiver: BackupArchiveReactionArchiver
    private let pollArchiver: BackupArchivePollArchiver
    private let pinnedMessageManager: PinnedMessageManager
    private let adminDeleteManager: AdminDeleteManager

    init(
        interactionStore: BackupArchiveInteractionStore,
        archivedPaymentStore: ArchivedPaymentStore,
        attachmentsArchiver: BackupArchiveMessageAttachmentArchiver,
        attachmentStore: AttachmentStore,
        oversizeTextArchiver: BackupArchiveInlinedOversizeTextArchiver,
        reactionArchiver: BackupArchiveReactionArchiver,
        pollArchiver: BackupArchivePollArchiver,
        pinnedMessageManager: PinnedMessageManager,
        adminDeleteManager: AdminDeleteManager,
    ) {
        self.interactionStore = interactionStore
        self.archivedPaymentStore = archivedPaymentStore
        self.attachmentsArchiver = attachmentsArchiver
        self.attachmentStore = attachmentStore
        self.contactAttachmentArchiver = BackupArchiveContactAttachmentArchiver(attachmentsArchiver: attachmentsArchiver)
        self.oversizeTextArchiver = oversizeTextArchiver
        self.reactionArchiver = reactionArchiver
        self.pollArchiver = pollArchiver
        self.pinnedMessageManager = pinnedMessageManager
        self.adminDeleteManager = adminDeleteManager
    }

    // MARK: - Archiving

    private struct MessageOwnedReferencedAttachments {
        let body: [ReferencedAttachment]
        let oversizeText: ReferencedAttachment?
        let quotedReply: ReferencedAttachment?
        let linkPreview: ReferencedAttachment?
        let contactAvatar: ReferencedAttachment?
        let sticker: ReferencedAttachment?
    }

    func archiveMessageContents(
        _ message: TSMessage,
        context: BackupArchive.ChatArchivingContext,
    ) -> ArchiveInteractionResult<ChatItemType> {
        guard let messageRowId = message.sqliteRowId else {
            return .completeFailure(.fatalArchiveError(
                .fetchedInteractionMissingRowId,
            ))
        }

        let messageOwnedReferencedAttachments: MessageOwnedReferencedAttachments = {
            let referencedAttachments = attachmentStore.fetchReferencedAttachmentsOwnedByMessage(
                messageRowId: messageRowId,
                tx: context.tx,
            ).filter {
                // There was a bug that resulted in invalid quoted-reply attachments being created
                // with the voiceMessage rendering flag. Filter them out.
                if
                    case .quotedReplyAttachment = $0.reference.owner.id,
                    $0.reference.renderingFlag == .voiceMessage
                {
                    return false
                }
                return true
            }

            let grouped = Dictionary(grouping: referencedAttachments, by: \.reference.owner.id)

            return MessageOwnedReferencedAttachments(
                body: grouped[.messageBodyAttachment(messageRowId: messageRowId)] ?? [],
                oversizeText: grouped[.messageOversizeText(messageRowId: messageRowId)]?.first,
                quotedReply: grouped[.quotedReplyAttachment(messageRowId: messageRowId)]?.first,
                linkPreview: grouped[.messageLinkPreview(messageRowId: messageRowId)]?.first,
                contactAvatar: grouped[.messageContactAvatar(messageRowId: messageRowId)]?.first,
                sticker: grouped[.messageSticker(messageRowId: messageRowId)]?.first,
            )
        }()

        if let paymentMessage = message as? OWSPaymentMessage {
            return archivePaymentMessageContents(
                paymentMessage,
                uniqueInteractionId: message.uniqueInteractionId,
                context: context.recipientContext,
            )
        } else if let archivedPayment = message as? OWSArchivedPaymentMessage {
            return archivePaymentArchiveContents(
                archivedPayment,
                uniqueInteractionId: message.uniqueInteractionId,
                context: context.recipientContext,
            )
        } else if message.wasRemotelyDeleted {
            if
                let adminDeleteAuthor = adminDeleteManager.adminDeleteAuthor(
                    interactionId: message.sqliteRowId!,
                    tx: context.tx,
                )
            {
                return archiveAdminDeleteTombstone(
                    message,
                    adminDeleteAuthor: adminDeleteAuthor,
                    context: context.recipientContext,
                )
            }
            return archiveRemoteDeleteTombstone(
                message,
                context: context.recipientContext,
            )
        } else if let contactShare = message.contactShare {
            return archiveContactShareMessageContents(
                message,
                contactShare: contactShare,
                contactAvatarReferencedAttachment: messageOwnedReferencedAttachments.contactAvatar,
                context: context.recipientContext,
            )
        } else if let messageSticker = message.messageSticker {
            return archiveStickerMessageContents(
                message,
                messageSticker: messageSticker,
                stickerReferencedAttachment: messageOwnedReferencedAttachments.sticker,
                context: context.recipientContext,
            )
        } else if let giftBadge = message.giftBadge {
            return archiveGiftBadge(
                giftBadge,
                context: context.recipientContext,
            )
        } else if message.isViewOnceMessage {
            return archiveViewOnceMessage(
                message,
                bodyReferencedAttachments: messageOwnedReferencedAttachments.body,
                context: context,
            )
        } else if message.isStoryReply, !message.isGroupStoryReply {
            return archiveDirectStoryReplyMessage(
                message,
                oversizeTextReferencedAttachment: messageOwnedReferencedAttachments.oversizeText,
                interactionUniqueId: message.uniqueInteractionId,
                context: context,
            )
        } else if message.isPoll {
            return pollArchiver.archivePoll(
                message,
                messageRowId: messageRowId,
                interactionUniqueId: message.uniqueInteractionId,
                context: context,
            )
        } else {
            return archiveStandardMessageContents(
                message,
                messageOwnedReferencedAttachments: messageOwnedReferencedAttachments,
                context: context.recipientContext,
            )
        }
    }

    // MARK: -

    private func archivePaymentArchiveContents(
        _ archivedPaymentMessage: OWSArchivedPaymentMessage,
        uniqueInteractionId: BackupArchive.InteractionUniqueId,
        context: BackupArchive.RecipientArchivingContext,
    ) -> BackupArchive.ArchiveInteractionResult<ChatItemType> {
        let historyItem = archivedPaymentStore.fetch(
            for: archivedPaymentMessage,
            interactionUniqueId: uniqueInteractionId.value,
            tx: context.tx,
        )
        guard let historyItem else {
            return .messageFailure([.archiveFrameError(.missingPaymentInformation)])
        }

        var paymentNotificationProto = BackupProto_PaymentNotification()
        if let amount = archivedPaymentMessage.archivedPaymentInfo.amount {
            paymentNotificationProto.amountMob = amount
        }
        if let fee = archivedPaymentMessage.archivedPaymentInfo.fee {
            paymentNotificationProto.feeMob = fee
        }
        if let note = archivedPaymentMessage.archivedPaymentInfo.note {
            paymentNotificationProto.note = note
        }
        paymentNotificationProto.transactionDetails = historyItem.toTransactionDetailsProto()

        return .success(.paymentNotification(paymentNotificationProto))
    }

    private func archivePaymentMessageContents(
        _ message: OWSPaymentMessage,
        uniqueInteractionId: BackupArchive.InteractionUniqueId,
        context: BackupArchive.RecipientArchivingContext,
    ) -> BackupArchive.ArchiveInteractionResult<ChatItemType> {
        guard
            let paymentNotification = message.paymentNotification,
            let model = PaymentFinder.paymentModels(
                forMcReceiptData: paymentNotification.mcReceiptData,
                transaction: context.tx,
            ).first
        else {
            return .messageFailure([.archiveFrameError(.missingPaymentInformation)])
        }

        var paymentNotificationProto = BackupProto_PaymentNotification()
        if
            let amount = model.paymentAmount?.picoMob,
            let formattedAmount = PaymentsFormat.formatForArchive(picoMob: amount)
        {
            paymentNotificationProto.amountMob = formattedAmount
        }
        if
            let fee = model.mobileCoin?.feeAmount?.picoMob,
            let formattedFee = PaymentsFormat.formatForArchive(picoMob: fee)
        {
            paymentNotificationProto.feeMob = formattedFee
        }
        if let memoMessage = paymentNotification.memoMessage {
            paymentNotificationProto.note = memoMessage
        }
        paymentNotificationProto.transactionDetails = model.asArchivedPayment().toTransactionDetailsProto()

        return .success(.paymentNotification(paymentNotificationProto))
    }

    // MARK: -

    private func archiveRemoteDeleteTombstone(
        _ remoteDeleteTombstone: TSMessage,
        context: BackupArchive.RecipientArchivingContext,
    ) -> ArchiveInteractionResult<ChatItemType> {
        let remoteDeletedMessage = BackupProto_RemoteDeletedMessage()
        return .success(.remoteDeletedMessage(remoteDeletedMessage))
    }

    // MARK: -

    private func archiveAdminDeleteTombstone(
        _ adminDeleteTombstone: TSMessage,
        adminDeleteAuthor: Aci,
        context: BackupArchive.RecipientArchivingContext,
    ) -> ArchiveInteractionResult<ChatItemType> {
        var adminDeletedMessage = BackupProto_AdminDeletedMessage()
        let contact = BackupArchive.ContactAddress(aci: adminDeleteAuthor)
        guard let authorId = context[.contact(contact)] else {
            return .messageFailure([.archiveFrameError(.referencedRecipientIdMissing(.contact(contact)))])
        }

        adminDeletedMessage.adminID = authorId.value
        return .success(.adminDeletedMessage(adminDeletedMessage))
    }

    // MARK: -

    private func archiveStandardMessageContents(
        _ message: TSMessage,
        messageOwnedReferencedAttachments: MessageOwnedReferencedAttachments,
        context: BackupArchive.RecipientArchivingContext,
    ) -> ArchiveInteractionResult<ChatItemType> {
        var standardMessage = BackupProto_StandardMessage()
        var partialErrors = [ArchiveFrameError]()

        // Every "StandardMessage" must have either a body or body attachments;
        // if neither is set this stays false and we fail the message.
        var hasPrimaryContent = false

        if let messageBody = message.body?.nilIfEmpty {
            hasPrimaryContent = true

            let oversizeTextResult = oversizeTextArchiver.archiveMessageBody(
                text: messageBody,
                oversizeTextReferencedAttachment: messageOwnedReferencedAttachments.oversizeText,
                messageId: message.uniqueInteractionId,
                context: context,
            )

            let archivedBody: BackupArchive.ArchivedMessageBody
            switch oversizeTextResult.bubbleUp(ChatItemType.self, partialErrors: &partialErrors) {
            case .continue(let _archivedBody):
                archivedBody = _archivedBody
            case .bubbleUpError(let errorResult):
                return errorResult
            }

            let text: BackupProto_Text
            let textResult = archiveText(
                MessageBody(text: archivedBody.inlinedText, ranges: message.bodyRanges ?? .empty),
                interactionUniqueId: message.uniqueInteractionId,
            )
            switch textResult.bubbleUp(ChatItemType.self, partialErrors: &partialErrors) {
            case .continue(let value):
                text = value
            case .bubbleUpError(let errorResult):
                return errorResult
            }
            standardMessage.text = text
            if let oversizedTextPointer = archivedBody.oversizedTextPointer {
                standardMessage.longText = oversizedTextPointer
            }
        }

        let bodyAttachmentProtos: [BackupProto_MessageAttachment] = attachmentsArchiver.archiveBodyAttachments(
            referencedAttachments: messageOwnedReferencedAttachments.body,
            context: context,
        )
        if !bodyAttachmentProtos.isEmpty {
            hasPrimaryContent = true
            standardMessage.attachments = bodyAttachmentProtos
        }

        guard hasPrimaryContent else {
            // If we got this far without a body or body attachments,
            // this message is invalid and should be dropped.
            // We would hard-error here, but we know these exist in the wild
            // and don't want to hard error any user that has them when we can
            // just skip.
            return .skippableInteraction(.emptyBodyMessage)
        }

        if let quotedMessage = message.quotedMessage {
            let quoteResult = archiveQuote(
                quotedMessage,
                quotedReplyReferencedAttachment: messageOwnedReferencedAttachments.quotedReply,
                interactionUniqueId: message.uniqueInteractionId,
                context: context,
            )
            switch quoteResult.bubbleUp(ChatItemType.self, partialErrors: &partialErrors) {
            case .continue(let quote):
                quote.map { standardMessage.quote = $0 }
            case .bubbleUpError(let errorResult):
                return errorResult
            }
        }

        if let linkPreview = message.linkPreview {
            let linkPreviewResult = self.archiveLinkPreview(
                linkPreview,
                linkPreviewReferencedAttachment: messageOwnedReferencedAttachments.linkPreview,
                messageBody: standardMessage.text.body,
                interactionUniqueId: message.uniqueInteractionId,
                context: context,
            )
            switch linkPreviewResult.bubbleUp(ChatItemType.self, partialErrors: &partialErrors) {
            case .continue(let linkPreviewProto):
                standardMessage.linkPreview = [linkPreviewProto].compacted()
            case .bubbleUpError(let errorResult):
                return errorResult
            }
        }

        let reactions: [BackupProto_Reaction]
        let reactionsResult = reactionArchiver.archiveReactions(
            message,
            context: context,
        )
        switch reactionsResult.bubbleUp(ChatItemType.self, partialErrors: &partialErrors) {
        case .continue(let values):
            reactions = values
        case .bubbleUpError(let errorResult):
            return errorResult
        }
        standardMessage.reactions = reactions

        if partialErrors.isEmpty {
            return .success(.standardMessage(standardMessage))
        } else {
            return .partialFailure(.standardMessage(standardMessage), partialErrors)
        }
    }

    private func archiveText(
        _ messageBody: MessageBody,
        interactionUniqueId: BackupArchive.InteractionUniqueId,
    ) -> ArchiveInteractionResult<BackupProto_Text> {
        var text = BackupProto_Text()
        text.body = messageBody.text

        for bodyRangeParam in messageBody.ranges.toProtoBodyRanges() {
            var bodyRange = BackupProto_BodyRange()
            bodyRange.start = bodyRangeParam.start
            bodyRange.length = bodyRangeParam.length

            if
                let mentionAci = Aci.parseFrom(
                    serviceIdBinary: bodyRangeParam.mentionAciBinary,
                    serviceIdString: bodyRangeParam.mentionAci,
                )
            {
                bodyRange.associatedValue = .mentionAci(
                    mentionAci.serviceIdBinary,
                )
            } else if let style = bodyRangeParam.style {
                let backupProtoStyle: BackupProto_BodyRange.Style = {
                    switch style {
                    case .none: return .none
                    case .bold: return .bold
                    case .italic: return .italic
                    case .spoiler: return .spoiler
                    case .strikethrough: return .strikethrough
                    case .monospace: return .monospace
                    }
                }()

                bodyRange.associatedValue = .style(backupProtoStyle)
            }

            text.bodyRanges.append(bodyRange)
        }

        return .success(text)
    }

    private func archiveQuote(
        _ quotedMessage: TSQuotedMessage,
        quotedReplyReferencedAttachment: ReferencedAttachment?,
        interactionUniqueId: BackupArchive.InteractionUniqueId,
        context: BackupArchive.RecipientArchivingContext,
    ) -> ArchiveInteractionResult<BackupProto_Quote?> {
        var partialErrors = [ArchiveFrameError]()

        guard let authorAddress = quotedMessage.authorAddress.asSingleServiceIdBackupAddress() else {
            // Fail the whole message if we fail archiving a quote.
            return .messageFailure([.archiveFrameError(.invalidQuoteAuthor)])
        }
        guard let authorId = context[.contact(authorAddress)] else {
            // Fail the whole message if we fail archiving a quote.
            return .messageFailure([.archiveFrameError(.referencedRecipientIdMissing(.contact(authorAddress)))])
        }

        var quote = BackupProto_Quote()
        quote.authorID = authorId.value

        let targetSentTimestamp: UInt64? = {
            switch quotedMessage.bodySource {
            case .local, .unknown:
                return quotedMessage.timestampValue?.uint64Value
            case .remote, .story:
                return nil
            @unknown default:
                return nil
            }
        }()
        // The proto's targetSentTimestamp is an optional field
        // and should be unset (not 0) if the target message could
        // not be found at the time the quote was received.
        BackupArchive.Timestamps.setTimestampIfValid(
            from: targetSentTimestamp,
            \.self,
            on: &quote,
            \.targetSentTimestamp,
            allowZero: false,
        )

        var didArchiveText = false
        var didArchiveAttachments = false

        if let body = quotedMessage.body?.nilIfEmpty {
            let textResult = archiveText(
                MessageBody(text: body, ranges: quotedMessage.bodyRanges ?? .empty),
                interactionUniqueId: interactionUniqueId,
            )
            let text: BackupProto_Text
            switch textResult.bubbleUp(Optional<BackupProto_Quote>.self, partialErrors: &partialErrors) {
            case .continue(let value):
                text = value
            case .bubbleUpError(let errorResult):
                return errorResult
            }

            let quoteText = { () -> BackupProto_Text? in
                var quoteText = BackupProto_Text()
                // We do not allow oversize text in quotes; truncate if some historical bug
                // cause quotes to contain more than the usual oversize text threshold.
                let trimmedQuoteText = text.body.trimToUtf8ByteCount(OWSMediaUtils.kOversizeTextMessageSizeThresholdBytes)
                // If, after trimming the quote text, we end up with an empty string,
                // skip setting a quote text entirely
                if let quoteTextBody = trimmedQuoteText.nilIfEmpty {
                    quoteText.body = quoteTextBody
                    quoteText.bodyRanges = text.bodyRanges
                    return quoteText
                } else {
                    return nil
                }
            }()
            if let quoteText {
                quote.text = quoteText
                didArchiveText = true
            }
        }

        if let attachmentInfo = quotedMessage.attachmentInfo() {
            let quoteAttachmentProto = archiveQuoteAttachment(
                attachmentInfo: attachmentInfo,
                quotedReplyReferencedAttachment: quotedReplyReferencedAttachment,
                context: context,
            )
            quote.attachments = [quoteAttachmentProto]
            didArchiveAttachments = true
        }

        if quotedMessage.isGiftBadge {
            quote.type = .giftBadge
        } else if quotedMessage.isTargetMessageViewOnce {
            quote.type = .viewOnce
        } else if quotedMessage.isPoll {
            quote.type = .poll
        } else {
            guard didArchiveText || didArchiveAttachments else {
                // NORMAL-type quotes must have either text or attachments, lest
                // they be rejected by the validator.
                partialErrors.append(.archiveFrameError(.quoteTypeNormalMissingTextAndAttachments))

                return .partialFailure(nil, partialErrors)
            }

            quote.type = .normal
        }

        if partialErrors.isEmpty {
            return .success(quote)
        } else {
            return .partialFailure(quote, partialErrors)
        }
    }

    private func archiveQuoteAttachment(
        attachmentInfo: OWSAttachmentInfo,
        quotedReplyReferencedAttachment: ReferencedAttachment?,
        context: BackupArchive.ArchivingContext,
    ) -> BackupProto_Quote.QuotedAttachment {
        var proto = BackupProto_Quote.QuotedAttachment()
        if let mimeType = attachmentInfo.originalAttachmentMimeType {
            proto.contentType = mimeType
        }
        if let sourceFilename = attachmentInfo.originalAttachmentSourceFilename {
            proto.fileName = sourceFilename
        }

        if let quotedReplyReferencedAttachment {
            proto.thumbnail = attachmentsArchiver.archiveQuotedReplyThumbnailAttachment(
                referencedAttachment: quotedReplyReferencedAttachment,
                context: context,
            )
        }

        return proto
    }

    private func archiveLinkPreview(
        _ linkPreview: OWSLinkPreview,
        linkPreviewReferencedAttachment: ReferencedAttachment?,
        messageBody: String,
        interactionUniqueId: BackupArchive.InteractionUniqueId,
        context: BackupArchive.RecipientArchivingContext,
    ) -> ArchiveInteractionResult<BackupProto_LinkPreview?> {
        var partialErrors = [ArchiveFrameError]()

        guard let url = linkPreview.urlString else {
            // If we have no url, consider this a partial failure. The message
            // without the link preview is still valid, so just don't set a link preview
            // by returning nil.
            partialErrors.append(.archiveFrameError(.linkPreviewMissingUrl))
            return .partialFailure(nil, partialErrors)
        }

        guard messageBody.contains(url) else {
            partialErrors.append(.archiveFrameError(.linkPreviewUrlNotInBody))
            return .partialFailure(nil, partialErrors)
        }

        var proto = BackupProto_LinkPreview()
        proto.url = url
        linkPreview.title.map { proto.title = $0 }
        linkPreview.previewDescription.map { proto.description_p = $0 }

        // Link preview dates could be arbitrarily old, and .ows_millisecondsSince1970
        // crashes if date.timeIntervalSince1970 is negative.
        if
            let date = linkPreview.date,
            date.timeIntervalSince1970 >= 0
        {
            BackupArchive.Timestamps.setTimestampIfValid(
                from: date,
                \.ows_millisecondsSince1970,
                on: &proto,
                \.date,
                allowZero: true,
            )
        }

        if let linkPreviewReferencedAttachment {
            proto.image = attachmentsArchiver.archiveLinkPreviewAttachment(
                referencedAttachment: linkPreviewReferencedAttachment,
                context: context,
            )
        }

        if partialErrors.isEmpty {
            return .success(proto)
        } else {
            return .partialFailure(proto, partialErrors)
        }
    }

    // MARK: -

    private func archiveContactShareMessageContents(
        _ message: TSMessage,
        contactShare: OWSContact,
        contactAvatarReferencedAttachment: ReferencedAttachment?,
        context: BackupArchive.RecipientArchivingContext,
    ) -> ArchiveInteractionResult<ChatItemType> {
        var partialErrors = [ArchiveFrameError]()

        var proto = BackupProto_ContactMessage()

        let contactResult = contactAttachmentArchiver.archiveContact(
            contactShare,
            contactAvatarReferencedAttachment: contactAvatarReferencedAttachment,
            uniqueInteractionId: message.uniqueInteractionId,
            context: context,
        )
        switch contactResult.bubbleUp(ChatItemType.self, partialErrors: &partialErrors) {
        case .continue(let contactProto):
            proto.contact = contactProto
        case .bubbleUpError(let errorResult):
            return errorResult
        }

        let reactions: [BackupProto_Reaction]
        let reactionsResult = reactionArchiver.archiveReactions(
            message,
            context: context,
        )
        switch reactionsResult.bubbleUp(ChatItemType.self, partialErrors: &partialErrors) {
        case .continue(let values):
            reactions = values
        case .bubbleUpError(let errorResult):
            return errorResult
        }
        proto.reactions = reactions

        if partialErrors.isEmpty {
            return .success(.contactMessage(proto))
        } else {
            return .partialFailure(.contactMessage(proto), partialErrors)
        }
    }

    private func archiveStickerMessageContents(
        _ message: TSMessage,
        messageSticker: MessageSticker,
        stickerReferencedAttachment: ReferencedAttachment?,
        context: BackupArchive.RecipientArchivingContext,
    ) -> ArchiveInteractionResult<ChatItemType> {
        guard let stickerReferencedAttachment else {
            // We can't have a sticker without an attachment.
            return .messageFailure([.archiveFrameError(.stickerMessageMissingStickerAttachment)])
        }

        var partialErrors = [ArchiveFrameError]()

        var proto = BackupProto_StickerMessage()

        var stickerProto = BackupProto_Sticker()
        stickerProto.packID = messageSticker.packId
        stickerProto.packKey = messageSticker.packKey
        stickerProto.stickerID = messageSticker.stickerId
        messageSticker.emoji.map { stickerProto.emoji = $0 }

        stickerProto.data = attachmentsArchiver.archiveStickerAttachment(
            referencedAttachment: stickerReferencedAttachment,
            context: context,
        )

        proto.sticker = stickerProto

        let reactions: [BackupProto_Reaction]
        let reactionsResult = reactionArchiver.archiveReactions(
            message,
            context: context,
        )
        switch reactionsResult.bubbleUp(ChatItemType.self, partialErrors: &partialErrors) {
        case .continue(let values):
            reactions = values
        case .bubbleUpError(let errorResult):
            return errorResult
        }
        proto.reactions = reactions

        if partialErrors.isEmpty {
            return .success(.stickerMessage(proto))
        } else {
            return .partialFailure(.stickerMessage(proto), partialErrors)
        }
    }

    // MARK: -

    private func archiveGiftBadge(
        _ giftBadge: OWSGiftBadge,
        context: BackupArchive.RecipientArchivingContext,
    ) -> ArchiveInteractionResult<ChatItemType> {
        var giftBadgeProto = BackupProto_GiftBadge()

        if let redemptionCredential = giftBadge.redemptionCredential {
            giftBadgeProto.receiptCredentialPresentation = redemptionCredential
            giftBadgeProto.state = { () -> BackupProto_GiftBadge.State in
                switch giftBadge.redemptionState {
                case .pending: return .unopened
                case .redeemed: return .redeemed
                case .opened: return .opened
                }
            }()
        } else {
            giftBadgeProto.receiptCredentialPresentation = Data()
            giftBadgeProto.state = .failed
        }

        return .success(.giftBadge(giftBadgeProto))
    }

    // MARK: -

    private func archiveViewOnceMessage(
        _ message: TSMessage,
        bodyReferencedAttachments: [ReferencedAttachment],
        context: BackupArchive.ChatArchivingContext,
    ) -> ArchiveInteractionResult<ChatItemType> {
        var partialErrors = [ArchiveFrameError]()

        var proto = BackupProto_ViewOnceMessage()

        if
            !context.includedContentFilter.shouldTombstoneViewOnce,
            !message.isViewOnceComplete
        {
            let bodyAttachmentProtos: [BackupProto_MessageAttachment] = attachmentsArchiver.archiveBodyAttachments(
                referencedAttachments: bodyReferencedAttachments,
                context: context,
            )

            guard
                bodyAttachmentProtos.count == 1,
                let first = bodyAttachmentProtos.first
            else {
                return .messageFailure(partialErrors + [.archiveFrameError(
                    .unviewedViewOnceMessageUnexpectedAttachmentCount(bodyAttachmentProtos.count),
                )])
            }

            proto.attachment = first
        }

        let reactions: [BackupProto_Reaction]
        let reactionsResult = reactionArchiver.archiveReactions(
            message,
            context: context.recipientContext,
        )
        switch reactionsResult.bubbleUp(ChatItemType.self, partialErrors: &partialErrors) {
        case .continue(let values):
            reactions = values
        case .bubbleUpError(let errorResult):
            return errorResult
        }
        proto.reactions = reactions

        if partialErrors.isEmpty {
            return .success(.viewOnceMessage(proto))
        } else {
            return .partialFailure(.viewOnceMessage(proto), partialErrors)
        }
    }

    // MARK: -

    /// Note this only covers 1:1 story replies which are rendered in-chat;
    /// group story replies are rendered in the story UI and are not backed
    /// up since stories are not backed up.
    private func archiveDirectStoryReplyMessage(
        _ message: TSMessage,
        oversizeTextReferencedAttachment: ReferencedAttachment?,
        interactionUniqueId: BackupArchive.InteractionUniqueId,
        context: BackupArchive.ChatArchivingContext,
    ) -> ArchiveInteractionResult<ChatItemType> {
        guard
            let chatId = context[message.uniqueThreadIdentifier],
            let threadInfo = context[chatId]
        else {
            return .messageFailure([.archiveFrameError(.referencedThreadIdMissing(message.uniqueThreadIdentifier))])
        }

        switch threadInfo {
        case .groupThread:
            return .messageFailure([.archiveFrameError(.storyReplyInGroupThread)])
        case .noteToSelfThread:
            // See comment on skippable update enum case.
            return .skippableInteraction(.directStoryReplyInNoteToSelf)
        case .contactThread:
            break
        }

        guard !message.isGroupStoryReply else {
            return .messageFailure([.archiveFrameError(.storyReplyInGroupThread)])
        }

        var partialErrors = [ArchiveFrameError]()

        var proto = BackupProto_DirectStoryReplyMessage()

        // We don't put the story author aci on the proto; it can be inferred
        // since you can't 1:1 reply to your own stories.
        // If this is an outgoing reply, it must be to a story from the contact
        // in the contact thread containing it.
        // If this an incoming reply, it must be a story from the local user.

        if let emoji = message.storyReactionEmoji {
            guard !emoji.isEmpty else {
                return .messageFailure([.archiveFrameError(.storyReplyEmptyContents)])
            }
            proto.reply = .emoji(emoji)
        } else if let body = message.body {
            guard !body.isEmpty else {
                return .messageFailure([.archiveFrameError(.storyReplyEmptyContents)])
            }

            let oversizeTextResult = oversizeTextArchiver.archiveMessageBody(
                text: body,
                oversizeTextReferencedAttachment: oversizeTextReferencedAttachment,
                messageId: message.uniqueInteractionId,
                context: context,
            )

            let archivedBody: BackupArchive.ArchivedMessageBody
            switch oversizeTextResult.bubbleUp(ChatItemType.self, partialErrors: &partialErrors) {
            case .continue(let _archivedBody):
                archivedBody = _archivedBody
            case .bubbleUpError(let errorResult):
                return errorResult
            }

            let textResult = archiveText(
                MessageBody(text: archivedBody.inlinedText, ranges: message.bodyRanges ?? .empty),
                interactionUniqueId: interactionUniqueId,
            )
            let text: BackupProto_Text
            switch textResult.bubbleUp(ChatItemType.self, partialErrors: &partialErrors) {
            case .continue(let value):
                text = value
            case .bubbleUpError(let errorResult):
                return errorResult
            }
            var textReply = BackupProto_DirectStoryReplyMessage.TextReply()
            textReply.text = text
            if let oversizedTextPointer = archivedBody.oversizedTextPointer {
                textReply.longText = oversizedTextPointer
            }

            proto.reply = .textReply(textReply)
        }

        let reactions: [BackupProto_Reaction]
        let reactionsResult = reactionArchiver.archiveReactions(
            message,
            context: context.recipientContext,
        )
        switch reactionsResult.bubbleUp(ChatItemType.self, partialErrors: &partialErrors) {
        case .continue(let values):
            reactions = values
        case .bubbleUpError(let errorResult):
            return errorResult
        }
        proto.reactions = reactions

        if partialErrors.isEmpty {
            return .success(.directStoryReplyMessage(proto))
        } else {
            return .partialFailure(.directStoryReplyMessage(proto), partialErrors)
        }
    }

    // MARK: - Restoring

    /// Parses the proto structure of message contents into
    /// into ``BackupArchive.RestoredMessageContents``, which map more directly
    /// to the ``TSMessage`` values in our database.
    ///
    /// Does NOT create the ``TSMessage``; callers are expected to utilize the
    /// restored contents to construct and insert the message.
    ///
    /// Callers MUST call ``restoreDownstreamObjects`` after creating and
    /// inserting the ``TSMessage``.
    func restoreContents(
        _ chatItemType: ChatItemType,
        chatThread: BackupArchive.ChatThread,
        context: BackupArchive.ChatItemRestoringContext,
    ) -> RestoreInteractionResult<BackupArchive.RestoredMessageContents> {
        switch chatItemType {
        case .paymentNotification(let paymentNotification):
            return restorePaymentNotification(
                paymentNotification,
                thread: chatThread,
                context: context,
            )
        case .remoteDeletedMessage(let remoteDeletedMessage):
            return restoreRemoteDeleteTombstone(
                remoteDeletedMessage,
                chatThread: chatThread,
                context: context,
            )
        case .adminDeletedMessage(let adminDeletedMessage):
            return restoreAdminDeleteTombstone(deleteAuthorId: BackupArchive.RecipientId(value: adminDeletedMessage.adminID))
        case .standardMessage(let standardMessage):
            return restoreStandardMessage(
                standardMessage,
                chatThread: chatThread,
                context: context,
            )
        case .contactMessage(let contactMessage):
            return restoreContactMessage(
                contactMessage,
                chatThread: chatThread,
                context: context,
            )
        case .stickerMessage(let stickerMessage):
            return restoreStickerMessage(
                stickerMessage,
                chatThread: chatThread,
                context: context,
            )
        case .giftBadge(let giftBadge):
            return restoreGiftBadge(
                giftBadge,
                context: context,
            )
        case .viewOnceMessage(let viewOnceMessage):
            return restoreViewOnceMessage(
                viewOnceMessage,
                chatThread: chatThread,
                context: context,
            )
        case .directStoryReplyMessage(let storyReply):
            return restoreDirectStoryReplyMessage(
                storyReply,
                chatThread: chatThread,
                context: context,
            )
        case .poll(let poll):
            return restorePollMessage(
                poll,
                chatThread: chatThread,
                context: context,
            )
        case .updateMessage:
            return .messageFailure([.restoreFrameError(
                .developerError(OWSAssertionError("Chat update has no contents to restore!")),
            )])
        }
    }

    /// After a caller creates a ``TSMessage`` from the results of ``restoreContents``, they MUST call this method
    /// to create and insert all "downstream" objects: those that reference the ``TSMessage`` and require it for their own creation.
    ///
    /// This method will create and insert all necessary objects (e.g. reactions).
    func restoreDownstreamObjects(
        message: TSMessage,
        thread: BackupArchive.ChatThread,
        pinDetails: BackupProto_ChatItem.PinDetails?,
        restoredContents: BackupArchive.RestoredMessageContents,
        context: BackupArchive.ChatItemRestoringContext,
    ) -> RestoreInteractionResult<Void> {
        guard let messageRowId = message.sqliteRowId else {
            return .messageFailure([.restoreFrameError(.databaseModelMissingRowId(modelClass: type(of: message)))])
        }

        var downstreamObjectResults = [RestoreInteractionResult<Void>]()
        switch restoredContents {
        case .archivedPayment(let archivedPayment):
            downstreamObjectResults.append(restoreArchivedPaymentContents(
                archivedPayment,
                thread: thread,
                message: message,
                context: context,
            ))
        case .text(let text):
            downstreamObjectResults.append(reactionArchiver.restoreReactions(
                text.reactions,
                message: message,
                context: context.recipientContext,
            ))
            if let oversizeText = text.body?.oversizeText {
                downstreamObjectResults.append(oversizeTextArchiver.restoreOversizeText(
                    oversizeText,
                    messageRowId: messageRowId,
                    message: message,
                    thread: thread,
                    context: context,
                ))
            }
            if text.bodyAttachments.isEmpty.negated {
                downstreamObjectResults.append(attachmentsArchiver.restoreBodyAttachments(
                    text.bodyAttachments,
                    messageRowId: messageRowId,
                    message: message,
                    thread: thread,
                    context: context,
                ))
            }
            if let quotedMessageThumbnail = text.quotedMessageThumbnail {
                downstreamObjectResults.append(attachmentsArchiver.restoreQuotedReplyThumbnailAttachment(
                    quotedMessageThumbnail,
                    messageRowId: messageRowId,
                    message: message,
                    thread: thread,
                    context: context,
                ))
            }
            if let linkPreviewImage = text.linkPreviewImage {
                downstreamObjectResults.append(attachmentsArchiver.restoreLinkPreviewAttachment(
                    linkPreviewImage,
                    messageRowId: messageRowId,
                    message: message,
                    thread: thread,
                    context: context,
                ))
            }
        case .contactShare(let contactShare):
            downstreamObjectResults.append(reactionArchiver.restoreReactions(
                contactShare.reactions,
                message: message,
                context: context.recipientContext,
            ))
            if let avatarAttachment = contactShare.avatarAttachment {
                downstreamObjectResults.append(attachmentsArchiver.restoreContactAvatarAttachment(
                    avatarAttachment,
                    messageRowId: messageRowId,
                    message: message,
                    thread: thread,
                    context: context,
                ))
            }
        case .stickerMessage(let stickerMessage):
            downstreamObjectResults.append(reactionArchiver.restoreReactions(
                stickerMessage.reactions,
                message: message,
                context: context.recipientContext,
            ))
            downstreamObjectResults.append(attachmentsArchiver.restoreStickerAttachment(
                stickerMessage.attachment,
                stickerPackId: stickerMessage.sticker.packId,
                stickerId: stickerMessage.sticker.stickerId,
                messageRowId: messageRowId,
                message: message,
                thread: thread,
                context: context,
            ))
        case .viewOnceMessage(let viewOnceMessage):
            downstreamObjectResults.append(reactionArchiver.restoreReactions(
                viewOnceMessage.reactions,
                message: message,
                context: context.recipientContext,
            ))
            switch viewOnceMessage.state {
            case .unviewed(let attachment):
                downstreamObjectResults.append(attachmentsArchiver.restoreBodyAttachments(
                    [attachment],
                    messageRowId: messageRowId,
                    message: message,
                    thread: thread,
                    context: context,
                ))
            case .complete:
                break
            }
        case .storyReply(let storyReply):
            downstreamObjectResults.append(reactionArchiver.restoreReactions(
                storyReply.reactions,
                message: message,
                context: context.recipientContext,
            ))

            switch storyReply.replyType {
            case .textReply(let textReply):
                if let oversizeText = textReply.body.oversizeText {
                    downstreamObjectResults.append(oversizeTextArchiver.restoreOversizeText(
                        oversizeText,
                        messageRowId: messageRowId,
                        message: message,
                        thread: thread,
                        context: context,
                    ))
                }
            case .emoji:
                break
            }
        case .poll(let poll):
            downstreamObjectResults.append(reactionArchiver.restoreReactions(
                poll.reactions,
                message: message,
                context: context.recipientContext,
            ))

            downstreamObjectResults.append(
                pollArchiver.restorePoll(
                    poll,
                    message: message,
                    context: context.recipientContext,
                ),
            )
        case .adminDeleteTombstone(let deleteAuthor):
            downstreamObjectResults.append(
                restoreAdminDeleteContents(
                    deleteAuthorId: deleteAuthor,
                    interactionId: messageRowId,
                    context: context.recipientContext,
                ),
            )
        case .remoteDeleteTombstone, .giftBadge:
            // Nothing downstream to restore.
            break
        }

        if let pinDetails {
            downstreamObjectResults.append(
                restorePinMessage(
                    pinDetails: pinDetails,
                    message: message,
                    chatThread: thread,
                    context: context,
                ),
            )
        }

        return downstreamObjectResults.reduce(.success(()), {
            $0.combine($1)
        })
    }

    // MARK: -

    private func restoreAdminDeleteContents(
        deleteAuthorId: BackupArchive.RecipientId,
        interactionId: Int64,
        context: BackupArchive.RecipientRestoringContext,
    ) -> BackupArchive.RestoreInteractionResult<Void> {
        var signalRecipientId: SignalRecipient.RowId?
        switch context[deleteAuthorId] {
        case .localAddress:
            signalRecipientId = context.localSignalRecipientRowId
        case .contact:
            signalRecipientId = context.recipientDbRowId(forBackupRecipientId: deleteAuthorId)
        default:
            return .messageFailure([.restoreFrameError(.invalidProtoData(.adminDeleteAuthorNotContact))])
        }

        guard let signalRecipientId else {
            return .messageFailure([.restoreFrameError(.invalidProtoData(.recipientIdNotFound(deleteAuthorId)))])
        }

        adminDeleteManager.insertAdminDeleteForSignalRecipient(
            signalRecipientId,
            interactionId: interactionId,
            tx: context.tx,
        )
        return .success(())
    }

    // MARK: -

    private func restoreArchivedPaymentContents(
        _ transaction: BackupArchive.RestoredMessageContents.Payment,
        thread: BackupArchive.ChatThread,
        message: TSMessage,
        context: BackupArchive.RestoringContext,
    ) -> BackupArchive.RestoreInteractionResult<Void> {
        let senderOrRecipientAci: Aci? = {
            switch thread.threadType {
            case .contact(let thread):
                // Payments only supported for 1:1 chats
                return thread.contactAddress.aci
            case .groupV2:
                return nil
            }
        }()
        guard let senderOrRecipientAci else {
            return .messageFailure([.restoreFrameError(.invalidProtoData(.paymentNotificationInGroup))])
        }

        let direction: ArchivedPayment.Direction
        switch message {
        case message as TSIncomingMessage:
            direction = .incoming
        case message as TSOutgoingMessage:
            direction = .outgoing
        default:
            return .messageFailure([.restoreFrameError(
                .developerError(OWSAssertionError("Invalid message type passed in for paymentRestore")),
            )])
        }
        let archivedPayment = ArchivedPayment.fromBackup(
            transaction,
            senderOrRecipientAci: senderOrRecipientAci,
            direction: direction,
            interactionUniqueId: message.uniqueId,
        )
        archivedPaymentStore.insert(archivedPayment, tx: context.tx)
        return .success(())
    }

    private func restorePaymentNotification(
        _ paymentNotification: BackupProto_PaymentNotification,
        thread: BackupArchive.ChatThread,
        context: BackupArchive.ChatItemRestoringContext,
    ) -> RestoreInteractionResult<BackupArchive.RestoredMessageContents> {
        let status: BackupArchive.RestoredMessageContents.Payment.Status
        let paymentTransaction: BackupProto_PaymentNotification.TransactionDetails.Transaction?
        if
            paymentNotification.hasTransactionDetails,
            let paymentDetails = paymentNotification.transactionDetails.payment
        {
            switch paymentDetails {
            case .failedTransaction(let failedTransaction):
                status = .failure(failedTransaction.reason)
                paymentTransaction = nil
            case .transaction(let payment):
                status = .success(payment.status)
                paymentTransaction = payment
            }
        } else {
            // Default to 'success' if there is no included information
            status = .success(.successful)
            paymentTransaction = nil
        }

        return .success(.archivedPayment(BackupArchive.RestoredMessageContents.Payment(
            amount: paymentNotification.hasAmountMob ? paymentNotification.amountMob : nil,
            fee: paymentNotification.hasFeeMob ? paymentNotification.feeMob : nil,
            note: paymentNotification.hasNote ? paymentNotification.note : nil,
            status: status,
            payment: paymentTransaction,
        )))
    }

    // MARK: -

    private func restoreRemoteDeleteTombstone(
        _ remoteDeleteTombstone: BackupProto_RemoteDeletedMessage,
        chatThread: BackupArchive.ChatThread,
        context: BackupArchive.ChatItemRestoringContext,
    ) -> RestoreInteractionResult<BackupArchive.RestoredMessageContents> {
        return .success(.remoteDeleteTombstone)
    }

    // MARK: -

    private func restoreAdminDeleteTombstone(deleteAuthorId: BackupArchive.RecipientId) -> RestoreInteractionResult<BackupArchive.RestoredMessageContents> {
        return .success(.adminDeleteTombstone(deleteAuthorId))
    }

    // MARK: -

    private func restoreStandardMessage(
        _ standardMessage: BackupProto_StandardMessage,
        chatThread: BackupArchive.ChatThread,
        context: BackupArchive.ChatItemRestoringContext,
    ) -> RestoreInteractionResult<BackupArchive.RestoredMessageContents> {
        var partialErrors = [RestoreFrameError]()

        let quotedMessage: TSQuotedMessage?
        let quotedMessageThumbnail: BackupProto_MessageAttachment?
        if standardMessage.hasQuote {
            switch self
                .restoreQuote(
                    standardMessage.quote,
                    thread: chatThread,
                    context: context,
                )
                .bubbleUp(
                    BackupArchive.RestoredMessageContents.self,
                    partialErrors: &partialErrors,
                )
            {
            case .continue(let component):
                quotedMessage = component.0
                quotedMessageThumbnail = component.1
            case .bubbleUpError(let error):
                return error
            }
        } else {
            quotedMessage = nil
            quotedMessageThumbnail = nil
        }

        let linkPreview: OWSLinkPreview?
        let linkPreviewAttachment: BackupProto_FilePointer?
        if let linkPreviewProto = standardMessage.linkPreview.first {
            switch self
                .restoreLinkPreview(
                    linkPreviewProto,
                    standardMessage: standardMessage,
                    context: context,
                )
                .bubbleUp(
                    BackupArchive.RestoredMessageContents.self,
                    partialErrors: &partialErrors,
                )
            {
            case .continue(let component):
                if let component {
                    linkPreview = component.0
                    linkPreviewAttachment = component.1
                } else {
                    linkPreview = nil
                    linkPreviewAttachment = nil
                }
            case .bubbleUpError(let error):
                return error
            }
        } else {
            linkPreview = nil
            linkPreviewAttachment = nil
        }

        let oversizeTextAttachment: BackupProto_FilePointer?
        if standardMessage.hasLongText {
            oversizeTextAttachment = standardMessage.longText
        } else {
            oversizeTextAttachment = nil
        }

        if standardMessage.text.body.isEmpty, standardMessage.attachments.isEmpty {
            return .messageFailure([.restoreFrameError(.invalidProtoData(.emptyStandardMessage))])
        }
        let text = standardMessage.text

        let isVoiceMessage: Bool
        if
            standardMessage.attachments.count == 1,
            standardMessage.attachments.first?.flag == .voiceMessage
        {
            isVoiceMessage = true
        } else {
            isVoiceMessage = false
        }

        let messageBodyResult = restoreMessageBody(
            text,
            oversizeTextAttachment: oversizeTextAttachment,
        )
        switch messageBodyResult {
        case .success(let body):
            let contents = BackupArchive.RestoredMessageContents.text(.init(
                body: body,
                quotedMessage: quotedMessage,
                linkPreview: linkPreview,
                isVoiceMessage: isVoiceMessage,
                reactions: standardMessage.reactions,
                bodyAttachments: standardMessage.attachments,
                quotedMessageThumbnail: quotedMessageThumbnail,
                linkPreviewImage: linkPreviewAttachment,
            ))
            if partialErrors.isEmpty {
                return .success(contents)
            } else {
                return .partialRestore(contents, partialErrors)
            }
        case .partialRestore(let body, let messageBodyErrors):
            return .partialRestore(
                .text(.init(
                    body: body,
                    quotedMessage: quotedMessage,
                    linkPreview: linkPreview,
                    isVoiceMessage: isVoiceMessage,
                    reactions: standardMessage.reactions,
                    bodyAttachments: standardMessage.attachments,
                    quotedMessageThumbnail: quotedMessageThumbnail,
                    linkPreviewImage: linkPreviewAttachment,
                )),
                partialErrors + messageBodyErrors,
            )
        case .unrecognizedEnum(let error):
            return .unrecognizedEnum(error)
        case .messageFailure(let messageBodyErrors):
            return .messageFailure(partialErrors + messageBodyErrors)
        }
    }

    typealias RestoredMessageBody = BackupArchive.RestoredMessageContents.Text.RestoredMessageBody

    private func restoreMessageBody(
        _ text: BackupProto_Text,
        oversizeTextAttachment: BackupProto_FilePointer?,
    ) -> RestoreInteractionResult<RestoredMessageBody?> {
        guard text.body.isEmpty.negated else {
            return .success(nil)
        }
        return restoreMessageBody(
            text: text.body,
            bodyRangeProtos: text.bodyRanges,
            oversizeTextAttachment: oversizeTextAttachment,
        )
    }

    private func restoreMessageBody(
        text: String,
        bodyRangeProtos: [BackupProto_BodyRange],
        oversizeTextAttachment: BackupProto_FilePointer?,
    ) -> RestoreInteractionResult<RestoredMessageBody?> {
        var partialErrors = [RestoreFrameError]()
        var bodyMentions = [NSRange: Aci]()
        var bodyStyles = [NSRangedValue<MessageBodyRanges.SingleStyle>]()
        for bodyRange in bodyRangeProtos {
            let bodyRangeStart = bodyRange.start
            let bodyRangeLength = bodyRange.length

            let range = NSRange(location: Int(bodyRangeStart), length: Int(bodyRangeLength))
            switch bodyRange.associatedValue {
            case .mentionAci(let aciData):
                guard let mentionAci = try? Aci.parseFrom(serviceIdBinary: aciData) else {
                    partialErrors.append(.restoreFrameError(
                        .invalidProtoData(.invalidAci(protoClass: BackupProto_BodyRange.self)),
                    ))
                    continue
                }
                bodyMentions[range] = mentionAci
            case .style(let protoBodyRangeStyle):
                let swiftStyle: MessageBodyRanges.SingleStyle
                switch protoBodyRangeStyle {
                case .none, .UNRECOGNIZED:
                    continue
                case .bold:
                    swiftStyle = .bold
                case .italic:
                    swiftStyle = .italic
                case .monospace:
                    swiftStyle = .monospace
                case .spoiler:
                    swiftStyle = .spoiler
                case .strikethrough:
                    swiftStyle = .strikethrough
                }
                bodyStyles.append(.init(swiftStyle, range: range))
            case nil:
                partialErrors.append(.restoreFrameError(
                    .invalidProtoData(.invalidAci(protoClass: BackupProto_BodyRange.self)),
                ))
                continue
            }
        }
        let bodyRanges = MessageBodyRanges(mentions: bodyMentions, styles: bodyStyles)

        let restoredBody: RestoredMessageBody?
        switch oversizeTextArchiver.restoreMessageBody(
            text,
            bodyRanges: bodyRanges,
            oversizeTextAttachment: oversizeTextAttachment,
        ).bubbleUp(Optional<RestoredMessageBody>.self, partialErrors: &partialErrors) {
        case .continue(let component):
            restoredBody = component
        case .bubbleUpError(let error):
            return error
        }

        if partialErrors.isEmpty {
            return .success(restoredBody)
        } else {
            // We still get text, albeit without any mentions or styles, if
            // we have these failures. So count as a partial restore, not
            // complete failure.
            return .partialRestore(restoredBody, partialErrors)
        }
    }

    private func restoreQuote(
        _ quote: BackupProto_Quote,
        thread: BackupArchive.ChatThread,
        context: BackupArchive.ChatItemRestoringContext,
    ) -> RestoreInteractionResult<(TSQuotedMessage, BackupProto_MessageAttachment?)> {
        let authorAddress: BackupArchive.InteropAddress
        switch context.recipientContext[quote.authorRecipientId] {
        case .none:
            return .messageFailure([.restoreFrameError(
                .invalidProtoData(.recipientIdNotFound(quote.authorRecipientId)),
            )])
        case .localAddress:
            authorAddress = context.recipientContext.localIdentifiers.aciAddress
        case .group, .distributionList, .releaseNotesChannel, .callLink:
            // Groups and distritibution lists cannot be an authors of a message!
            return .messageFailure([.restoreFrameError(.invalidProtoData(.incomingMessageNotFromAciOrE164))])
        case .contact(let contactAddress):
            guard contactAddress.aci != nil || contactAddress.e164 != nil else {
                return .messageFailure([.restoreFrameError(.invalidProtoData(.incomingMessageNotFromAciOrE164))])
            }
            authorAddress = contactAddress.asInteropAddress()
        }

        var partialErrors = [RestoreFrameError]()

        let targetMessageTimestamp: NSNumber?
        let bodySource: TSQuotedMessageContentSource
        if
            quote.hasTargetSentTimestamp,
            quote.targetSentTimestamp > 0,
            SDS.fitsInInt64(quote.targetSentTimestamp)
        {
            targetMessageTimestamp = NSNumber(value: quote.targetSentTimestamp)
            // non-nil timestamp means the client that created the backup had
            // the target message at receive time (local state was .local)
            bodySource = .local
        } else {
            targetMessageTimestamp = nil
            // nil timestamp means the client that created the backup did not have
            // the target message at receive time (local state was .remote)
            bodySource = .remote
        }

        let quoteBody: MessageBody?
        if quote.hasText {
            switch self
                .restoreMessageBody(
                    text: quote.text.body,
                    bodyRangeProtos: quote.text.bodyRanges,
                    // Quotes don't support oversize text
                    oversizeTextAttachment: nil,
                )
                .bubbleUp(
                    (TSQuotedMessage, BackupProto_MessageAttachment?).self,
                    partialErrors: &partialErrors,
                )
            {
            case .continue(let component):
                // We drop oversize text for quotes, if any.
                if component?.oversizeText != nil {
                    partialErrors.append(.restoreFrameError(.invalidProtoData(.quotedMessageOversizeText)))
                }
                quoteBody = component?.inlinedBody
            case .bubbleUpError(let error):
                return error
            }
        } else {
            quoteBody = nil
        }

        let isGiftBadge: Bool
        let isTargetMessageViewOnce: Bool
        let isPoll: Bool
        switch quote.type {
        case .UNRECOGNIZED, .unknown, .normal:
            isGiftBadge = false
            isTargetMessageViewOnce = false
            isPoll = false
        case .viewOnce:
            isGiftBadge = false
            isTargetMessageViewOnce = true
            isPoll = false
        case .giftBadge:
            isGiftBadge = true
            isTargetMessageViewOnce = false
            isPoll = false
        case .poll:
            isGiftBadge = false
            isTargetMessageViewOnce = false
            isPoll = true
        }

        let quotedAttachmentThumbnail: BackupProto_MessageAttachment?
        let quotedAttachmentInfo: OWSAttachmentInfo?
        if let quotedAttachmentProto = quote.attachments.first {
            if quotedAttachmentProto.hasThumbnail {
                quotedAttachmentThumbnail = quotedAttachmentProto.thumbnail
            } else {
                quotedAttachmentThumbnail = nil
            }

            let mimeType = quotedAttachmentProto.contentType.nilIfEmpty ?? MimeType.applicationOctetStream.rawValue
            let sourceFilename = quotedAttachmentProto.fileName.nilIfEmpty
            let renderingFlag = quotedAttachmentThumbnail?.flag.asAttachmentFlag
            quotedAttachmentInfo = OWSAttachmentInfo(
                originalAttachmentMimeType: mimeType,
                originalAttachmentSourceFilename: sourceFilename,
                originalAttachmentRenderingFlag: renderingFlag,
            )
        } else {
            quotedAttachmentInfo = nil
            quotedAttachmentThumbnail = nil
        }

        if
            quoteBody == nil,
            quotedAttachmentInfo == nil,
            !isGiftBadge,
            !isTargetMessageViewOnce
        {
            partialErrors.append(.restoreFrameError(.invalidProtoData(.quotedMessageEmptyContent)))
        }

        let quotedMessage = TSQuotedMessage(
            fromBackupWithTargetMessageTimestamp: targetMessageTimestamp,
            authorAddress: authorAddress,
            body: quoteBody?.text,
            bodyRanges: quoteBody?.ranges,
            bodySource: bodySource,
            quotedAttachmentInfo: quotedAttachmentInfo,
            isGiftBadge: isGiftBadge,
            isTargetMessageViewOnce: isTargetMessageViewOnce,
            isPoll: isPoll,
        )

        if partialErrors.isEmpty {
            return .success((quotedMessage, quotedAttachmentThumbnail))
        } else {
            return .partialRestore((quotedMessage, quotedAttachmentThumbnail), partialErrors)
        }
    }

    private func restoreLinkPreview(
        _ linkPreviewProto: BackupProto_LinkPreview,
        standardMessage: BackupProto_StandardMessage,
        context: BackupArchive.RestoringContext,
    ) -> RestoreInteractionResult<(OWSLinkPreview, BackupProto_FilePointer?)?> {
        guard let url = linkPreviewProto.url.nilIfEmpty else {
            return .partialRestore(nil, [.restoreFrameError(.invalidProtoData(.linkPreviewEmptyUrl))])
        }
        guard standardMessage.text.body.contains(url) else {
            return .partialRestore(nil, [.restoreFrameError(.invalidProtoData(.linkPreviewUrlNotInBody))])
        }
        let date: Date?
        if linkPreviewProto.hasDate {
            date = .init(millisecondsSince1970: linkPreviewProto.date)
        } else {
            date = nil
        }

        let metadata = OWSLinkPreview.Metadata(
            urlString: url,
            title: linkPreviewProto.title.nilIfEmpty,
            previewDescription: linkPreviewProto.description_p.nilIfEmpty,
            date: date,
        )

        if linkPreviewProto.hasImage {
            let linkPreview = OWSLinkPreview(
                metadata: metadata,
            )
            return .success((linkPreview, linkPreviewProto.image))
        } else {
            let linkPreview = OWSLinkPreview(
                metadata: metadata,
            )
            return .success((linkPreview, nil))
        }
    }

    // MARK: -

    private func restoreContactMessage(
        _ contactMessage: BackupProto_ContactMessage,
        chatThread: BackupArchive.ChatThread,
        context: BackupArchive.ChatItemRestoringContext,
    ) -> RestoreInteractionResult<BackupArchive.RestoredMessageContents> {
        var partialErrors = [RestoreFrameError]()

        guard contactMessage.hasContact else {
            return .messageFailure([.restoreFrameError(.invalidProtoData(.contactMessageMissingContactAttachment))])
        }
        let contactAttachment = contactMessage.contact

        let contactResult = contactAttachmentArchiver.restoreContact(
            contactAttachment,
        )
        let contact: OWSContact
        switch contactResult
            .bubbleUp(
                BackupArchive.RestoredMessageContents.self,
                partialErrors: &partialErrors,
            )
        {
        case .continue(let component):
            contact = component
        case .bubbleUpError(let error):
            return error
        }

        let avatar: BackupProto_FilePointer?
        if contactAttachment.hasAvatar {
            avatar = contactAttachment.avatar
        } else {
            avatar = nil
        }

        let contents = BackupArchive.RestoredMessageContents.contactShare(.init(
            contact: contact,
            avatarAttachment: avatar,
            reactions: contactMessage.reactions,
        ))
        if partialErrors.isEmpty {
            return .success(contents)
        } else {
            return .partialRestore(contents, partialErrors)
        }
    }

    // MARK: -

    private func restoreStickerMessage(
        _ stickerMessage: BackupProto_StickerMessage,
        chatThread: BackupArchive.ChatThread,
        context: BackupArchive.ChatItemRestoringContext,
    ) -> RestoreInteractionResult<BackupArchive.RestoredMessageContents> {
        let stickerProto = stickerMessage.sticker
        let messageSticker = MessageSticker(
            info: .init(
                packId: stickerProto.packID,
                packKey: stickerProto.packKey,
                stickerId: stickerProto.stickerID,
            ),
            emoji: stickerProto.emoji.nilIfEmpty,
        )

        return .success(.stickerMessage(.init(
            sticker: messageSticker,
            attachment: stickerProto.data,
            reactions: stickerMessage.reactions,
        )))
    }

    // MARK: -

    private func restoreGiftBadge(
        _ giftBadgeProto: BackupProto_GiftBadge,
        context: BackupArchive.ChatItemRestoringContext,
    ) -> RestoreInteractionResult<BackupArchive.RestoredMessageContents> {
        let giftBadge: OWSGiftBadge
        switch giftBadgeProto.state {
        case .unopened, .UNRECOGNIZED:
            giftBadge = .restoreFromBackup(
                receiptCredentialPresentation: giftBadgeProto.receiptCredentialPresentation,
                redemptionState: .pending,
            )
        case .opened:
            giftBadge = .restoreFromBackup(
                receiptCredentialPresentation: giftBadgeProto.receiptCredentialPresentation,
                redemptionState: .opened,
            )
        case .redeemed:
            giftBadge = .restoreFromBackup(
                receiptCredentialPresentation: giftBadgeProto.receiptCredentialPresentation,
                redemptionState: .redeemed,
            )
        case .failed:
            /// Passing `receiptCredentialPresentation: nil` will make this a
            /// non-functional gift badge in practice. At the time of writing
            /// iOS doesn't have a "failed" gift badge state, so we'll use this
            /// instead.
            giftBadge = .restoreFromBackup(
                receiptCredentialPresentation: nil,
                redemptionState: .pending,
            )
        }

        return .success(.giftBadge(BackupArchive.RestoredMessageContents.GiftBadge(
            giftBadge: giftBadge,
        )))
    }

    // MARK: -

    private func restoreViewOnceMessage(
        _ viewOnceMessage: BackupProto_ViewOnceMessage,
        chatThread: BackupArchive.ChatThread,
        context: BackupArchive.ChatItemRestoringContext,
    ) -> RestoreInteractionResult<BackupArchive.RestoredMessageContents> {
        let state: BackupArchive.RestoredMessageContents.ViewOnceMessage.State
        if viewOnceMessage.hasAttachment {
            state = .unviewed(viewOnceMessage.attachment)
        } else {
            state = .complete
        }
        return .success(.viewOnceMessage(.init(
            state: state,
            reactions: viewOnceMessage.reactions,
        )))
    }

    // MARK: -

    /// Note this only covers 1:1 story replies which are rendered in-chat;
    /// group story replies are rendered in the story UI and are not backed
    /// up since stories are not backed up.
    private func restoreDirectStoryReplyMessage(
        _ storyReply: BackupProto_DirectStoryReplyMessage,
        chatThread: BackupArchive.ChatThread,
        context: BackupArchive.ChatItemRestoringContext,
    ) -> RestoreInteractionResult<BackupArchive.RestoredMessageContents> {
        var partialErrors = [RestoreFrameError]()

        let replyType: BackupArchive.RestoredMessageContents.StoryReply.ReplyType

        switch storyReply.reply {
        case .textReply(let textReply):
            let oversizeTextAttachment: BackupProto_FilePointer? = if textReply.hasLongText {
                textReply.longText
            } else {
                nil
            }

            let messageBody: RestoredMessageBody?
            switch self
                .restoreMessageBody(
                    textReply.text,
                    oversizeTextAttachment: oversizeTextAttachment,
                )
                .bubbleUp(
                    BackupArchive.RestoredMessageContents.self,
                    partialErrors: &partialErrors,
                )
            {
            case .continue(let component):
                messageBody = component
            case .bubbleUpError(let error):
                return error
            }

            if let messageBody {
                replyType = .textReply(.init(
                    body: messageBody,
                ))
            } else {
                let restoreErrorType: RestoreFrameError.ErrorType
                if oversizeTextAttachment != nil {
                    restoreErrorType = .invalidProtoData(.directStoryReplyMessageEmptyWithLongText)
                } else {
                    restoreErrorType = .invalidProtoData(.directStoryReplyMessageEmpty)
                }

                return .messageFailure([.restoreFrameError(restoreErrorType)] + partialErrors)
            }
        case .emoji(let string):
            replyType = .emoji(string)
        case .none:
            return .unrecognizedEnum(BackupArchive.UnrecognizedEnumError(
                enumType: BackupProto_DirectStoryReplyMessage.OneOf_Reply.self,
            ))
        }

        return .success(.storyReply(.init(
            replyType: replyType,
            reactions: storyReply.reactions,
        )))
    }

    // MARK: -

    typealias BackupsPollVote = BackupsPollData.BackupsPollOption.BackupsPollVote
    typealias BackupsPollOption = BackupsPollData.BackupsPollOption

    /// Polls
    private func restorePollMessage(
        _ poll: BackupProto_Poll,
        chatThread: BackupArchive.ChatThread,
        context: BackupArchive.ChatItemRestoringContext,
    ) -> RestoreInteractionResult<BackupArchive.RestoredMessageContents> {
        var partialErrors = [RestoreFrameError]()

        var options: [BackupsPollData.BackupsPollOption] = []
        for optionProto in poll.options {
            var votes: [BackupsPollVote] = []
            for voteProto in optionProto.votes {
                var voteAuthorId: SignalRecipient.RowId?
                let recipientId = BackupArchive.RecipientId(value: voteProto.voterID)
                switch context.recipientContext[recipientId] {
                case .localAddress:
                    voteAuthorId = context.recipientContext.localSignalRecipientRowId
                case .contact:
                    voteAuthorId = context.recipientContext.recipientDbRowId(forBackupRecipientId: recipientId)
                default:
                    partialErrors += [.restoreFrameError(.invalidProtoData(.pollVoteAuthorNotContact))]
                }

                guard let voteAuthorId else {
                    partialErrors += [.restoreFrameError(.invalidProtoData(.recipientIdNotFound(recipientId)))]
                    continue
                }

                guard voteProto.voteCount <= Int32.max else {
                    partialErrors += [.restoreFrameError(.invalidProtoData(.invalidPollVoteCount))]
                    continue
                }

                votes.append(BackupsPollVote(voteAuthorId: voteAuthorId, voteCount: Int32(voteProto.voteCount)))
            }
            options.append(BackupsPollOption(text: optionProto.option, votes: votes))
        }

        let pollData = BackupsPollData(
            question: poll.question,
            allowMultiple: poll.allowMultiple,
            isEnded: poll.hasEnded_p,
            options: options,
        )

        var pollQuestion: RestoredMessageBody

        switch self
            .restoreMessageBody(
                text: poll.question,
                bodyRangeProtos: [],
                oversizeTextAttachment: nil,
            )
            .bubbleUp(
                BackupArchive.RestoredMessageContents.self,
                partialErrors: &partialErrors,
            )
        {
        case .continue(let component):
            guard let component else {
                return .messageFailure([.restoreFrameError(.invalidProtoData(.pollQuestionEmpty))] + partialErrors)
            }
            pollQuestion = component
        case .bubbleUpError(let error):
            return error
        }

        let poll = BackupArchive.RestoredMessageContents.Poll(
            poll: pollData,
            question: pollQuestion,
            reactions: poll.reactions,
        )

        if partialErrors.isEmpty {
            return .success(
                .poll(poll),
            )
        } else {
            return .partialRestore(
                .poll(poll),
                partialErrors,
            )
        }
    }

    // MARK: -

    private func restorePinMessage(
        pinDetails: BackupProto_ChatItem.PinDetails,
        message: TSMessage,
        chatThread: BackupArchive.ChatThread,
        context: BackupArchive.ChatItemRestoringContext,
    ) -> BackupArchive.RestoreInteractionResult<Void> {

        let threadId: Int64?
        switch chatThread.threadType {
        case .contact(let contactThread):
            threadId = contactThread.sqliteRowId
        case .groupV2(let groupThread):
            threadId = groupThread.sqliteRowId
        }

        guard let threadId else {
            return .messageFailure([.restoreFrameError(.databaseModelMissingRowId(modelClass: TSThread.self))])
        }

        var expiresAtTimestamp: UInt64?
        switch pinDetails.pinExpiry {
        case .pinExpiresAtTimestamp(let timestamp):
            guard BackupArchive.Timestamps.isValid(timestamp) else {
                return .partialRestore((), [.restoreFrameError(.invalidProtoData(.chatItemInvalidDateSent))])
            }
            expiresAtTimestamp = timestamp
        case .pinNeverExpires, .none:
            break
        }

        guard BackupArchive.Timestamps.isValid(pinDetails.pinnedAtTimestamp) else {
            return .partialRestore((), [.restoreFrameError(.invalidProtoData(.chatItemInvalidDateSent))])
        }

        let details = PinMessageDetails(pinnedAtTimestamp: pinDetails.pinnedAtTimestamp, expiresAtTimestamp: expiresAtTimestamp)

        let applyPinMessageResult = pinnedMessageManager.applyPinMessageFromBackup(
            message: message,
            threadId: threadId,
            pinDetails: details,
            tx: context.tx,
        )

        switch applyPinMessageResult {
        case .success:
            return .success(())
        case .unrecognizedEnum(let error):
            return .unrecognizedEnum(error)
        case .partialRestore(let errors):
            return .partialRestore((), errors)
        case .failure(let error):
            return .messageFailure(error)
        }
    }
}

// MARK: -

private extension ArchivedPayment {
    static func fromBackup(
        _ backup: BackupArchive.RestoredMessageContents.Payment,
        senderOrRecipientAci: Aci,
        direction: Direction,
        interactionUniqueId: String?,
    ) -> ArchivedPayment {
        var archivedPayment: ArchivedPayment
        switch backup.status {
        case .failure(let reason):
            archivedPayment = ArchivedPayment(
                amount: nil,
                fee: nil,
                note: nil,
                mobileCoinIdentification: nil,
                status: .error,
                failureReason: reason.asFailureType(),
                direction: direction,
                timestamp: nil,
                blockIndex: nil,
                blockTimestamp: nil,
                transaction: nil,
                receipt: nil,
                senderOrRecipientAci: senderOrRecipientAci,
                interactionUniqueId: interactionUniqueId,
            )
        case .success(let status):
            let payment = backup.payment
            let transactionIdentifier = payment?.mobileCoinIdentification.nilIfEmpty.map {
                TransactionIdentifier(publicKey: $0.publicKey, keyImages: $0.keyImages)
            }

            archivedPayment = ArchivedPayment(
                amount: backup.amount,
                fee: backup.fee,
                note: backup.note,
                mobileCoinIdentification: transactionIdentifier,
                status: status.asStatusType(),
                failureReason: .none,
                direction: direction,
                timestamp: payment?.timestamp,
                blockIndex: payment?.blockIndex,
                blockTimestamp: payment?.blockTimestamp,
                transaction: payment?.transaction.nilIfEmpty,
                receipt: payment?.receipt,
                senderOrRecipientAci: senderOrRecipientAci,
                interactionUniqueId: interactionUniqueId,
            )
        }
        return archivedPayment
    }
}

private extension BackupProto_PaymentNotification.TransactionDetails.FailedTransaction.FailureReason {
    func asFailureType() -> ArchivedPayment.FailureReason {
        switch self {
        case .UNRECOGNIZED, .generic: return .genericFailure
        case .network: return .networkFailure
        case .insufficientFunds: return .insufficientFundsFailure
        }
    }
}

private extension BackupProto_PaymentNotification.TransactionDetails.Transaction.Status {
    func asStatusType() -> ArchivedPayment.Status {
        switch self {
        case .UNRECOGNIZED, .initial: return .initial
        case .submitted: return .submitted
        case .successful: return .successful
        }
    }
}

private extension BackupProto_PaymentNotification.TransactionDetails.MobileCoinTxoIdentification {
    var nilIfEmpty: Self? {
        (publicKey.isEmpty && keyImages.isEmpty) ? nil : self
    }
}
