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

import MobileCoin
import SignalServiceKit
import SignalUI

public enum CVAttachment: Equatable {
    case stream(ReferencedTSResourceStream)
    case pointer(ReferencedTSResourcePointer, transitTierDownloadState: AttachmentDownloadState)

    public var attachment: ReferencedTSResource {
        switch self {
        case .stream(let stream):
            return stream
        case .pointer(let pointer, _):
            return pointer
        }
    }

    public var attachmentStream: TSResourceStream? {
        switch self {
        case .stream(let stream):
            return stream.attachmentStream
        case .pointer:
            return nil
        }
    }

    public var attachmentPointer: TSResourcePointer? {
        switch self {
        case .stream:
            return nil
        case .pointer(let pointer, _):
            return pointer.attachmentPointer
        }
    }

    public static func from(_ attachment: ReferencedTSResource, tx: SDSAnyReadTransaction) -> CVAttachment? {
        if let stream = attachment.asReferencedStream {
            return .stream(stream)
        } else if let pointer = attachment.asReferencedPointer {
            return .pointer(pointer, transitTierDownloadState: pointer.attachmentPointer.downloadState(tx: tx.asV2Read))
        } else {
            return nil
        }
    }

    public static func == (lhs: CVAttachment, rhs: CVAttachment) -> Bool {
        switch (lhs, rhs) {
        case (.stream(let lhsStream), .stream(let rhsStream)):
            return lhsStream.attachment.resourceId == rhsStream.attachment.resourceId
                && lhsStream.reference.hasSameOwner(as: rhsStream.reference)
        case (.pointer(let lhsPointer, let lhsState), .pointer(let rhsPointer, let rhsState)):
            return lhsPointer.attachment.resourceId == rhsPointer.attachment.resourceId
                && lhsPointer.reference.hasSameOwner(as: rhsPointer.reference)
                && lhsState == rhsState
        case (.stream, .pointer), (.pointer, .stream):
            return false
        }
    }
}

public class CVComponentState: Equatable, Dependencies {
    let messageCellType: CVMessageCellType

    struct SenderName: Equatable {
        let senderName: NSAttributedString
        let senderNameColor: UIColor
    }
    let senderName: SenderName?

    struct SenderAvatar: Equatable {
        let avatarDataSource: ConversationAvatarDataSource
    }
    let senderAvatar: SenderAvatar?

    enum BodyText: Equatable {
        case bodyText(displayableText: DisplayableText)

        // TODO: Should we have oversizeTextFailed?
        case oversizeTextDownloading

        // We use the "body text" component to
        // render the "remotely deleted" indicator.
        case remotelyDeleted

        var displayableText: DisplayableText? {
            if case .bodyText(let displayableText) = self {
                return displayableText
            }
            return nil
        }

        func textValue(isTextExpanded: Bool) -> CVTextValue? {
            switch self {
            case .bodyText(let displayableText):
                return displayableText.textValue(isTextExpanded: isTextExpanded)
            default:
                return nil
            }
        }

        var jumbomojiCount: UInt? {
            switch self {
            case .bodyText(let displayableText):
                return displayableText.jumbomojiCount
            default:
                return nil
            }
        }

        var isJumbomojiMessage: Bool {
            guard let jumbomojiCount = jumbomojiCount else {
                return false
            }
            return jumbomojiCount > 0
        }
    }
    let bodyText: BodyText?

    struct BodyMedia: Equatable {
        let items: [CVMediaAlbumItem]
        let mediaAlbumHasFailedAttachment: Bool
        let mediaAlbumHasPendingAttachment: Bool
    }
    let bodyMedia: BodyMedia?

    struct GenericAttachment: Equatable {
        let attachment: CVAttachment

        var attachmentStream: TSResourceStream? {
            attachment.attachmentStream
        }

        var attachmentPointer: TSResourcePointer? {
            attachment.attachmentPointer
        }
    }
    let genericAttachment: GenericAttachment?

    public struct PaymentAttachment: Equatable, Dependencies {
        let notification: TSPaymentNotification
        let model: TSPaymentModel?
        let otherUserShortName: String

        var status: TSPaymentState? { model?.paymentState }
    }
    var paymentAttachment: PaymentAttachment?

    // It's not practical to reload the audio cell every time
    // playback state or progress changes.  Therefore we only
    // capture the stable state related to the audio. Dynamic
    // state is obtained from CVAudioPlayer.
    //
    // NOTE: We use the existing AudioAttachment entity,
    //       not a custom struct.
    let audioAttachment: AudioAttachment?

    struct ViewOnce: Equatable {
        let viewOnceState: ViewOnceState
    }
    let viewOnce: ViewOnce?

    let hasRenderableContent: Bool

    struct QuotedReply: Equatable {
        let viewState: QuotedMessageView.State

        var quotedReplyModel: QuotedReplyModel { viewState.quotedReplyModel }
    }
    let quotedReply: QuotedReply?

    enum Sticker: Equatable {
        case available(
            stickerMetadata: any StickerMetadata,
            attachmentStream: ReferencedTSResourceStream
        )
        case downloading(attachmentPointer: ReferencedTSResourcePointer)
        case failedOrPending(
            attachmentPointer: ReferencedTSResourcePointer,
            transitTierDownloadState: AttachmentDownloadState
        )

        public var stickerMetadata: (any StickerMetadata)? {
            switch self {
            case .available(let stickerMetadata, _):
                return stickerMetadata
            case .downloading, .failedOrPending:
                return nil
            }
        }
        public var attachmentStream: ReferencedTSResourceStream? {
            switch self {
            case .available(_, let attachmentStream):
                return attachmentStream
            case .downloading:
                return nil
            case .failedOrPending:
                return nil
            }
        }
        public var attachmentPointer: ReferencedTSResourcePointer? {
            switch self {
            case .available:
                return nil
            case .downloading(let attachmentPointer):
                return attachmentPointer
            case .failedOrPending(let attachmentPointer, _):
                return attachmentPointer
            }
        }

        static func == (lhs: CVComponentState.Sticker, rhs: CVComponentState.Sticker) -> Bool {
            switch (lhs, rhs) {
            case let (.available(lhsData, lhsStream), .available(rhsData, rhsStream)):
                return lhsData.stickerInfo.asKey() == rhsData.stickerInfo.asKey()
                    && lhsStream.attachment.resourceId == rhsStream.attachment.resourceId
                    && lhsStream.reference.hasSameOwner(as: rhsStream.reference)
            case let (.downloading(lhsPointer), .downloading(rhsPointer)):
                return lhsPointer.attachment.resourceId == rhsPointer.attachment.resourceId
                    && lhsPointer.reference.hasSameOwner(as: rhsPointer.reference)
            case let (.failedOrPending(lhsPointer, lhsState), .failedOrPending(rhsPointer, rhsState)):
                return lhsPointer.attachment.resourceId == rhsPointer.attachment.resourceId
                    && lhsPointer.reference.hasSameOwner(as: rhsPointer.reference)
                    && lhsState == rhsState
            case (.available, _), (.downloading, _), (.failedOrPending, _):
                return false
            }
        }
    }
    let sticker: Sticker?

    struct ContactShare: Equatable {
        let state: CVContactShareView.State
    }
    let contactShare: ContactShare?

    struct LinkPreview: Equatable {
        // TODO: convert OWSLinkPreview to Swift?
        let linkPreview: OWSLinkPreview
        let linkPreviewAttachment: TSResource?
        let state: LinkPreviewState

        // MARK: - Equatable

        public static func == (lhs: LinkPreview, rhs: LinkPreview) -> Bool {
            return lhs.state === rhs.state
        }
    }
    let linkPreview: LinkPreview?

    struct GiftBadge: Equatable {
        let messageUniqueId: String
        let otherUserShortName: String
        let cachedBadge: CachedBadge
        let expirationDate: Date
        let redemptionState: OWSGiftBadgeRedemptionState
    }
    let giftBadge: GiftBadge?

    struct SystemMessage: Equatable {
        typealias ReferencedUser = CVTextLabel.ReferencedUserItem

        let title: NSAttributedString
        let titleColor: UIColor
        let titleSelectionBackgroundColor: UIColor
        let action: CVMessageAction?

        /// Represents users whose names appear in the title. Only applies to
        /// system messages in group threads.
        let namesInTitle: [ReferencedUser]

        init(
            title: NSAttributedString,
            titleColor: UIColor,
            titleSelectionBackgroundColor: UIColor,
            action: CVMessageAction?
        ) {
            let mutableTitle = NSMutableAttributedString(attributedString: title)
            mutableTitle.removeAttribute(
                .addressOfName,
                range: NSRange(location: 0, length: mutableTitle.length)
            )
            self.title = NSAttributedString(attributedString: mutableTitle)

            self.titleColor = titleColor
            self.titleSelectionBackgroundColor = titleSelectionBackgroundColor
            self.action = action

            self.namesInTitle = {
                // Extract the addresses for names in the string. These are only
                // stored for system messages in group threads.

                var referencedUsers = [ReferencedUser]()

                title.enumerateAddressesOfNames { address, range, _ in
                    if let address = address {
                        referencedUsers.append(ReferencedUser(
                            address: address,
                            range: range
                        ))
                    }
                }

                return referencedUsers
            }()
        }
    }
    let systemMessage: SystemMessage?

    struct DateHeader: Equatable {
    }
    let dateHeader: DateHeader?

    struct UnreadIndicator: Equatable {
    }
    let unreadIndicator: UnreadIndicator?

    struct Reactions: Equatable {
        let reactionState: InteractionReactionState
        let viewState: CVReactionCountsView.State
    }
    let reactions: Reactions?

    struct TypingIndicator: Equatable {
        let address: SignalServiceAddress
        let avatarDataSource: ConversationAvatarDataSource?
    }
    let typingIndicator: TypingIndicator?

    struct ThreadDetails: Equatable {
        enum MutualGroupsTapAction: Equatable {
            case showContactSafetyTip
            case showGroupSafetyTip
        }

        let avatarDataSource: ConversationAvatarDataSource?
        let isAvatarBlurred: Bool
        let titleText: String
        let shouldShowVerifiedBadge: Bool
        let bioText: String?
        let detailsText: String?
        let mutualGroupsText: NSAttributedString?
        let mutualGroupsTapAction: MutualGroupsTapAction?
        let groupDescriptionText: String?
    }
    let threadDetails: ThreadDetails?

    typealias UnknownThreadWarning = CVComponentState.SystemMessage
    let unknownThreadWarning: UnknownThreadWarning?

    typealias DefaultDisappearingMessageTimer = CVComponentState.SystemMessage
    let defaultDisappearingMessageTimer: DefaultDisappearingMessageTimer?

    struct BottomButtons: Equatable {
        let actions: [CVMessageAction]
    }
    let bottomButtons: BottomButtons?

    struct FailedOrPendingDownloads: Equatable {
        let attachmentPointers: [TSResourcePointer]

        static func == (lhs: CVComponentState.FailedOrPendingDownloads, rhs: CVComponentState.FailedOrPendingDownloads) -> Bool {
            return lhs.attachmentPointers.map(\.resourceId) == rhs.attachmentPointers.map(\.resourceId)
        }
    }
    let failedOrPendingDownloads: FailedOrPendingDownloads?

    struct SendFailureBadge: Equatable {
        let color: UIColor
    }
    let sendFailureBadge: SendFailureBadge?

    let messageHasBodyAttachments: Bool

    fileprivate init(messageCellType: CVMessageCellType,
                     senderName: SenderName?,
                     senderAvatar: SenderAvatar?,
                     bodyText: BodyText?,
                     bodyMedia: BodyMedia?,
                     genericAttachment: GenericAttachment?,
                     paymentAttachment: PaymentAttachment?,
                     audioAttachment: AudioAttachment?,
                     viewOnce: ViewOnce?,
                     quotedReply: QuotedReply?,
                     sticker: Sticker?,
                     contactShare: ContactShare?,
                     linkPreview: LinkPreview?,
                     giftBadge: GiftBadge?,
                     systemMessage: SystemMessage?,
                     dateHeader: DateHeader?,
                     unreadIndicator: UnreadIndicator?,
                     reactions: Reactions?,
                     typingIndicator: TypingIndicator?,
                     threadDetails: ThreadDetails?,
                     unknownThreadWarning: UnknownThreadWarning?,
                     defaultDisappearingMessageTimer: DefaultDisappearingMessageTimer?,
                     bottomButtons: BottomButtons?,
                     failedOrPendingDownloads: FailedOrPendingDownloads?,
                     sendFailureBadge: SendFailureBadge?,
                     messageHasBodyAttachments: Bool,
                     hasRenderableContent: Bool) {

        self.messageCellType = messageCellType
        self.senderName = senderName
        self.senderAvatar = senderAvatar
        self.bodyText = bodyText
        self.bodyMedia = bodyMedia
        self.genericAttachment = genericAttachment
        self.paymentAttachment = paymentAttachment
        self.audioAttachment = audioAttachment
        self.viewOnce = viewOnce
        self.quotedReply = quotedReply
        self.sticker = sticker
        self.contactShare = contactShare
        self.linkPreview = linkPreview
        self.giftBadge = giftBadge
        self.systemMessage = systemMessage
        self.dateHeader = dateHeader
        self.unreadIndicator = unreadIndicator
        self.reactions = reactions
        self.typingIndicator = typingIndicator
        self.threadDetails = threadDetails
        self.unknownThreadWarning = unknownThreadWarning
        self.defaultDisappearingMessageTimer = defaultDisappearingMessageTimer
        self.bottomButtons = bottomButtons
        self.failedOrPendingDownloads = failedOrPendingDownloads
        self.sendFailureBadge = sendFailureBadge
        self.messageHasBodyAttachments = messageHasBodyAttachments
        self.hasRenderableContent = hasRenderableContent
    }

    // MARK: - Equatable

    public static func == (lhs: CVComponentState, rhs: CVComponentState) -> Bool {
        return (lhs.messageCellType == rhs.messageCellType &&
                    lhs.senderName == rhs.senderName &&
                    lhs.senderAvatar == rhs.senderAvatar &&
                    lhs.bodyText == rhs.bodyText &&
                    lhs.bodyMedia == rhs.bodyMedia &&
                    lhs.genericAttachment == rhs.genericAttachment &&
                    lhs.paymentAttachment == rhs.paymentAttachment &&
                    lhs.audioAttachment == rhs.audioAttachment &&
                    lhs.viewOnce == rhs.viewOnce &&
                    lhs.quotedReply == rhs.quotedReply &&
                    lhs.sticker == rhs.sticker &&
                    lhs.contactShare == rhs.contactShare &&
                    lhs.linkPreview == rhs.linkPreview &&
                    lhs.giftBadge == rhs.giftBadge &&
                    lhs.systemMessage == rhs.systemMessage &&
                    lhs.dateHeader == rhs.dateHeader &&
                    lhs.unreadIndicator == rhs.unreadIndicator &&
                    lhs.reactions == rhs.reactions &&
                    lhs.typingIndicator == rhs.typingIndicator &&
                    lhs.threadDetails == rhs.threadDetails &&
                    lhs.unknownThreadWarning == rhs.unknownThreadWarning &&
                    lhs.defaultDisappearingMessageTimer == rhs.defaultDisappearingMessageTimer &&
                    lhs.bottomButtons == rhs.bottomButtons &&
                    lhs.failedOrPendingDownloads == rhs.failedOrPendingDownloads &&
                    lhs.sendFailureBadge == rhs.sendFailureBadge)
    }

    // MARK: - Building

    fileprivate struct Builder: CVItemBuilding {
        typealias SenderName = CVComponentState.SenderName
        typealias SenderAvatar = CVComponentState.SenderAvatar
        typealias BodyText = CVComponentState.BodyText
        typealias BodyMedia = CVComponentState.BodyMedia
        typealias GenericAttachment = CVComponentState.GenericAttachment
        typealias PaymentAttachment = CVComponentState.PaymentAttachment
        typealias ViewOnce = CVComponentState.ViewOnce
        typealias QuotedReply = CVComponentState.QuotedReply
        typealias Sticker = CVComponentState.Sticker
        typealias SystemMessage = CVComponentState.SystemMessage
        typealias ContactShare = CVComponentState.ContactShare
        typealias Reactions = CVComponentState.Reactions
        typealias LinkPreview = CVComponentState.LinkPreview
        typealias GiftBadge = CVComponentState.GiftBadge
        typealias DateHeader = CVComponentState.DateHeader
        typealias UnreadIndicator = CVComponentState.UnreadIndicator
        typealias TypingIndicator = CVComponentState.TypingIndicator
        typealias ThreadDetails = CVComponentState.ThreadDetails
        typealias UnknownThreadWarning = CVComponentState.UnknownThreadWarning
        typealias DefaultDisappearingMessageTimer = CVComponentState.DefaultDisappearingMessageTimer
        typealias FailedOrPendingDownloads = CVComponentState.FailedOrPendingDownloads
        typealias BottomButtons = CVComponentState.BottomButtons
        typealias SendFailureBadge = CVComponentState.SendFailureBadge

        let interaction: TSInteraction
        let itemBuildingContext: CVItemBuildingContext

        var revealedSpoilerIdsSnapshot: Set<StyleIdType> {
            return itemBuildingContext.viewStateSnapshot.spoilerReveal[.fromInteraction(interaction)] ?? Set()
        }

        var senderName: SenderName?
        var senderAvatar: SenderAvatar?
        var bodyText: BodyText?
        var bodyMedia: BodyMedia?
        var genericAttachment: GenericAttachment?
        var paymentAttachment: PaymentAttachment?
        var audioAttachment: AudioAttachment?
        var viewOnce: ViewOnce?
        var quotedReply: QuotedReply?
        var sticker: Sticker?
        var systemMessage: SystemMessage?
        var contactShare: ContactShare?
        var linkPreview: LinkPreview?
        var giftBadge: GiftBadge?
        var dateHeader: DateHeader?
        var unreadIndicator: UnreadIndicator?
        var typingIndicator: TypingIndicator?
        var threadDetails: ThreadDetails?
        var unknownThreadWarning: UnknownThreadWarning?
        var defaultDisappearingMessageTimer: DefaultDisappearingMessageTimer?
        var reactions: Reactions?
        var failedOrPendingDownloads: FailedOrPendingDownloads?
        var sendFailureBadge: SendFailureBadge?
        var messageHasBodyAttachments: Bool
        var hasRenderableContent: Bool

        var bottomButtonsActions = [CVMessageAction]()

        init(interaction: TSInteraction, itemBuildingContext: CVItemBuildingContext) {
            self.interaction = interaction
            self.messageHasBodyAttachments = (interaction as? TSMessage)?.hasBodyAttachments(transaction: itemBuildingContext.transaction) ?? false
            self.hasRenderableContent = (interaction as? TSMessage)?.hasRenderableContent(tx: itemBuildingContext.transaction) ?? false
            self.itemBuildingContext = itemBuildingContext
        }

        mutating func build() -> CVComponentState {
            var bottomButtons: BottomButtons?
            if !bottomButtonsActions.isEmpty {
                bottomButtons = BottomButtons(actions: bottomButtonsActions)
            }

            return CVComponentState(messageCellType: messageCellType,
                                    senderName: senderName,
                                    senderAvatar: senderAvatar,
                                    bodyText: bodyText,
                                    bodyMedia: bodyMedia,
                                    genericAttachment: genericAttachment,
                                    paymentAttachment: paymentAttachment,
                                    audioAttachment: audioAttachment,
                                    viewOnce: viewOnce,
                                    quotedReply: quotedReply,
                                    sticker: sticker,
                                    contactShare: contactShare,
                                    linkPreview: linkPreview,
                                    giftBadge: giftBadge,
                                    systemMessage: systemMessage,
                                    dateHeader: dateHeader,
                                    unreadIndicator: unreadIndicator,
                                    reactions: reactions,
                                    typingIndicator: typingIndicator,
                                    threadDetails: threadDetails,
                                    unknownThreadWarning: unknownThreadWarning,
                                    defaultDisappearingMessageTimer: defaultDisappearingMessageTimer,
                                    bottomButtons: bottomButtons,
                                    failedOrPendingDownloads: failedOrPendingDownloads,
                                    sendFailureBadge: sendFailureBadge,
                                    messageHasBodyAttachments: messageHasBodyAttachments,
                                    hasRenderableContent: hasRenderableContent)
        }

        // MARK: -

        lazy var isIncoming: Bool = {
            interaction as? TSIncomingMessage != nil
        }()

        lazy var isOutgoing: Bool = {
            interaction as? TSOutgoingMessage != nil
        }()

        lazy var messageCellType: CVMessageCellType = {
            if dateHeader != nil {
                return .dateHeader
            }
            if unreadIndicator != nil {
                return .unreadIndicator
            }
            if typingIndicator != nil {
                return .typingIndicator
            }
            if threadDetails != nil {
                return .threadDetails
            }
            if unknownThreadWarning != nil {
                return .unknownThreadWarning
            }
            if defaultDisappearingMessageTimer != nil {
                return .defaultDisappearingMessageTimer
            }
            if systemMessage != nil {
                return .systemMessage
            }
            if let sticker = self.sticker {
                return .stickerMessage
            }
            if viewOnce != nil {
                return .viewOnce
            }
            if contactShare != nil {
                return .contactShare
            }
            if audioAttachment != nil {
                return .audio
            }
            if genericAttachment != nil {
                return .genericAttachment
            }
            if paymentAttachment != nil {
                return .paymentAttachment
            }
            if giftBadge != nil {
                return .giftBadge
            }
            if bodyMedia != nil {
                return .bodyMedia
            }
            if bodyText != nil {
                return .textOnlyMessage
            }
            if quotedReply != nil {
                return .quoteOnlyMessage
            }

            owsFailDebug("Unknown state.")
            return .unknown
        }()
    }

    // MARK: - Convenience

    lazy var isSticker: Bool = {
        sticker != nil
    }()

    lazy var activeComponentStateKeys: Set<CVComponentKey> = {

        var result = Set<CVComponentKey>()

        if senderName != nil {
            result.insert(.senderName)
        }
        if senderAvatar != nil {
            result.insert(.senderAvatar)
        }
        if bodyText != nil {
            result.insert(.bodyText)
        }
        if bodyMedia != nil {
            result.insert(.bodyMedia)
        }
        if genericAttachment != nil {
            result.insert(.genericAttachment)
        }
        if paymentAttachment != nil {
            result.insert(.paymentAttachment)
        }
        if audioAttachment != nil {
            result.insert(.audioAttachment)
        }
        if viewOnce != nil {
            result.insert(.viewOnce)
        }
        if quotedReply != nil {
            result.insert(.quotedReply)
        }
        if sticker != nil {
            result.insert(.sticker)
        }
        if contactShare != nil {
            result.insert(.contactShare)
        }
        if linkPreview != nil {
            result.insert(.linkPreview)
        }
        if giftBadge != nil {
            result.insert(.giftBadge)
        }
        if systemMessage != nil {
            result.insert(.systemMessage)
        }
        if dateHeader != nil {
            result.insert(.dateHeader)
        }
        if unreadIndicator != nil {
            result.insert(.unreadIndicator)
        }
        if reactions != nil {
            result.insert(.reactions)
        }
        if typingIndicator != nil {
            result.insert(.typingIndicator)
        }
        if threadDetails != nil {
            result.insert(.threadDetails)
        }
        if unknownThreadWarning != nil {
            result.insert(.unknownThreadWarning)
        }
        if defaultDisappearingMessageTimer != nil {
            result.insert(.defaultDisappearingMessageTimer)
        }
        if bottomButtons != nil {
            result.insert(.bottomButtons)
        }
        if failedOrPendingDownloads != nil {
            result.insert(.failedOrPendingDownloads)
        }
        if sendFailureBadge != nil {
            result.insert(.sendFailureBadge)
        }
        return result
    }()

    lazy var isTextOnlyMessage: Bool = {
        let validKeys: [CVComponentKey] = [
            .senderName,
            .senderAvatar,
            .bodyText,
            .footer,
            .reactions,
            .sendFailureBadge
        ]
        return activeComponentStateKeys.isSubset(of: Set(validKeys))
    }()

    lazy var isBorderlessJumbomojiMessage: Bool = {
        isTextOnlyMessage && (bodyText?.isJumbomojiMessage == true)
    }()

    lazy var isBodyMediaOnlyMessage: Bool = {
        let validKeys: [CVComponentKey] = [
            .senderName,
            .senderAvatar,
            .bodyMedia,
            .footer,
            .reactions,
            .sendFailureBadge
        ]
        return activeComponentStateKeys.isSubset(of: Set(validKeys))
    }()

    lazy var isBorderlessBodyMediaMessage: Bool = {
        if isBodyMediaOnlyMessage,
           let bodyMedia = bodyMedia,
           bodyMedia.items.count == 1,
           let firstItem = bodyMedia.items.first,
           firstItem.renderingFlag == .borderless
        {
            return true
        }
        return false
    }()
}

// MARK: -

extension CVComponentState {

    static func buildDateHeader(interaction: TSInteraction,
                                itemBuildingContext: CVItemBuildingContext) -> CVComponentState {
        var builder = CVComponentState.Builder(interaction: interaction,
                                               itemBuildingContext: itemBuildingContext)
        builder.dateHeader = DateHeader()
        return builder.build()
    }

    static func buildUnreadIndicator(interaction: TSInteraction,
                                     itemBuildingContext: CVItemBuildingContext) -> CVComponentState {
        var builder = CVComponentState.Builder(interaction: interaction,
                                               itemBuildingContext: itemBuildingContext)
        builder.unreadIndicator = UnreadIndicator()
        return builder.build()
    }

    static func build(interaction: TSInteraction,
                      itemBuildingContext: CVItemBuildingContext) throws -> CVComponentState {
        var builder = CVComponentState.Builder(interaction: interaction,
                                               itemBuildingContext: itemBuildingContext)
        return try builder.populateAndBuild()
    }
}

// MARK: -

fileprivate extension CVComponentState.Builder {

    mutating func populateAndBuild() throws -> CVComponentState {

        if let reactionState = InteractionReactionState(interaction: interaction,
                                                        transaction: transaction),
           reactionState.hasReactions {
            self.reactions = Reactions(reactionState: reactionState,
                                       viewState: CVReactionCountsView.buildState(with: reactionState))
        }

        self.senderAvatar = tryToBuildSenderAvatar()

        self.failedOrPendingDownloads = tryToBuildFailedOrPendingDownloads()

        switch interaction.interactionType {
        case .threadDetails:
            self.threadDetails = buildThreadDetails()
            return build()
        case .unknownThreadWarning:
            // TODO: Remove this case entirely?
            return build()
        case .defaultDisappearingMessageTimer:
            self.defaultDisappearingMessageTimer = CVComponentSystemMessage.buildDefaultDisappearingMessageTimerState(
                interaction: interaction,
                threadViewModel: threadViewModel,
                transaction: transaction
            )
            return build()
        case .typingIndicator:
            guard let typingIndicatorInteraction = interaction as? TypingIndicatorInteraction else {
                owsFailDebug("Invalid typingIndicator.")
                return build()
            }
            let avatarDataSource = { () -> ConversationAvatarDataSource? in
                guard thread.isGroupThread else {
                    return nil
                }
                return self.avatarBuilder.buildAvatarDataSource(
                    forAddress: typingIndicatorInteraction.address,
                    includingBadge: true,
                    localUserDisplayMode: .asUser,
                    diameterPoints: UInt(ConversationStyle.groupMessageAvatarSizeClass.diameter))
            }()
            self.typingIndicator = TypingIndicator(address: typingIndicatorInteraction.address,
                                                   avatarDataSource: avatarDataSource)
            return build()
        case .info, .error, .call:
            let currentGroupCallThreadUniqueId = viewStateSnapshot.currentGroupCallThreadUniqueId
            self.systemMessage = CVComponentSystemMessage.buildComponentState(interaction: interaction,
                                                                              threadViewModel: threadViewModel,
                                                                              currentGroupCallThreadUniqueId: currentGroupCallThreadUniqueId,
                                                                              transaction: transaction)
            return build()
        case .unreadIndicator:
            unreadIndicator = CVComponentState.UnreadIndicator()
            return build()
        case .dateHeader:
            dateHeader = CVComponentState.DateHeader()
            return build()
        case .incomingMessage, .outgoingMessage:
            guard let message = interaction as? TSMessage else {
                owsFailDebug("Invalid message.")
                return build()
            }
            return try populateAndBuild(message: message, revealedSpoilerIdsSnapshot: revealedSpoilerIdsSnapshot)
        case .unknown:
            owsFailDebug("Unknown interaction type.")
            return build()
        default:
            owsFailDebug("Invalid interaction type: \(NSStringFromOWSInteractionType(interaction.interactionType))")
            return build()
        }
    }

    private func tryToBuildSenderAvatar() -> SenderAvatar? {
        guard thread.isGroupThread,
              let incomingMessage = interaction as? TSIncomingMessage else {
            return nil
        }
        guard let avatarDataSource = self.avatarBuilder.buildAvatarDataSource(
            forAddress: incomingMessage.authorAddress,
            includingBadge: true,
            localUserDisplayMode: .asUser,
            diameterPoints: UInt(ConversationStyle.groupMessageAvatarSizeClass.diameter)
        ) else {
            owsFailDebug("Could build avatar image")
            return nil
        }
        return SenderAvatar(avatarDataSource: avatarDataSource)
    }

    private func tryToBuildFailedOrPendingDownloads() -> FailedOrPendingDownloads? {
        guard let message = interaction as? TSMessage else {
            return nil
        }
        let attachmentPointers = message.failedOrPendingAttachments(transaction: transaction)
        guard !attachmentPointers.isEmpty else {
            return nil
        }
        return FailedOrPendingDownloads(attachmentPointers: attachmentPointers)
    }

    mutating func populateAndBuild(
        message: TSMessage,
        revealedSpoilerIdsSnapshot: Set<StyleIdType>
    ) throws -> CVComponentState {

        if message.wasRemotelyDeleted {
            // If the message has been remotely deleted, suppress everything else.
            self.bodyText = .remotelyDeleted
            return build()
        }

        if message.isViewOnceMessage {
            return try buildViewOnceMessage(message: message)
        }

        if let contact = message.contactShare {
            return try buildContact(message: message, contact: contact)
        }

        if let messageSticker = message.messageSticker {
            return try buildSticker(message: message, messageSticker: messageSticker)
        }

        // Check for quoted replies _before_ media album handling,
        // since that logic may exit early.
        buildQuotedReply(message: message, revealedSpoilerIdsSnapshot: revealedSpoilerIdsSnapshot)

        try buildBodyText(message: message)

        if let outgoingMessage = message as? TSOutgoingMessage {
            let messageStatus: MessageReceiptStatus
            if
                let paymentMessage = message as? OWSPaymentMessage,
                let receipt = paymentMessage.paymentNotification?.mcReceiptData,
                let model = PaymentFinder.paymentModels(
                    forMcReceiptData: receipt,
                    transaction: transaction
                ).first
            {
                messageStatus = MessageRecipientStatusUtils.recipientStatus(
                    outgoingMessage: outgoingMessage,
                    paymentModel: model
                )
            } else {
                messageStatus = MessageRecipientStatusUtils.recipientStatus(
                    outgoingMessage: outgoingMessage,
                    transaction: transaction
                )
            }

            switch messageStatus {
            case .failed:
                sendFailureBadge = SendFailureBadge(color: .ows_accentRed)
            case .pending:
                sendFailureBadge = SendFailureBadge(color: .ows_gray60)
            default:
                break
            }
        }

        // Could be incoming or outgoing; protocol covers both
        if let paymentMessage = message as? OWSPaymentMessage,
           let paymentNotification = paymentMessage.paymentNotification {
            return buildPaymentAttachment(paymentNotification: paymentNotification)
        }

        if let giftBadge = message.giftBadge {
            return try buildGiftBadge(messageUniqueId: message.uniqueId, giftBadge: giftBadge)
        }

        do {
            let bodyAttachments = DependenciesBridge.shared.tsResourceStore.referencedBodyMediaAttachments(
                for: message,
                tx: transaction.asV2Read
            )
            let mediaAlbumItems = buildMediaAlbumItems(for: bodyAttachments, message: message)
            if mediaAlbumItems.count > 0 {
                var mediaAlbumHasFailedAttachment = false
                var mediaAlbumHasPendingAttachment = false
                for attachment in bodyAttachments {
                    guard
                        attachment.attachment.asResourceStream() == nil,
                        let pointer = attachment.attachment.asTransitTierPointer()
                    else {
                        continue
                    }
                    switch pointer.downloadState(tx: transaction.asV2Read) {
                    case .downloading, .enqueued:
                        continue
                    case .failed:
                        mediaAlbumHasFailedAttachment = true
                    case .none:
                        mediaAlbumHasPendingAttachment = true
                    }
                }

                self.bodyMedia = BodyMedia(items: mediaAlbumItems,
                                           mediaAlbumHasFailedAttachment: mediaAlbumHasFailedAttachment,
                                           mediaAlbumHasPendingAttachment: mediaAlbumHasPendingAttachment)
                return build()
            }

            // Only media galleries should have more than one attachment.
            owsAssertDebug(bodyAttachments.count <= 1)
            if let bodyAttachment = bodyAttachments.first {
                try buildNonMediaAttachment(bodyAttachment: bodyAttachment)
            }
        }

        if !threadViewModel.hasPendingMessageRequest,
           let linkPreview = message.linkPreview {
            try buildLinkPreview(message: message, linkPreview: linkPreview)
        }

        let result = build()
        if result.messageCellType == .unknown {
            owsFailDebug("Unknown message cell type.")
        }
        return result
    }
}

// MARK: -

fileprivate extension CVComponentState.Builder {

    mutating func buildThreadDetails() -> ThreadDetails {
        owsAssertDebug(interaction as? ThreadDetailsInteraction != nil)

        return CVComponentThreadDetails.buildComponentState(thread: thread,
                                                            transaction: transaction,
                                                            avatarBuilder: avatarBuilder)
    }

    // TODO: Should we throw more?
    mutating func buildViewOnceMessage(message: TSMessage) throws -> CVComponentState {
        owsAssertDebug(message.isViewOnceMessage)

        func buildViewOnce(viewOnceState: ViewOnceState) -> CVComponentState {
            self.viewOnce = ViewOnce(viewOnceState: viewOnceState)
            return self.build()
        }

        if let outgoingMessage = message as? TSOutgoingMessage {
            let viewOnceState: ViewOnceState
            if message.isViewOnceComplete {
                viewOnceState = .outgoingSentExpired
            } else {
                switch outgoingMessage.messageState {
                case .sending, .pending:
                    viewOnceState = .outgoingSending
                case .failed:
                    viewOnceState = .outgoingFailed
                default:
                    viewOnceState = .outgoingSentExpired
                }
            }
            return buildViewOnce(viewOnceState: viewOnceState)
        } else if nil != message as? TSIncomingMessage {
            if message.isViewOnceComplete {
                return buildViewOnce(viewOnceState: .incomingExpired)
            }
            let attachmentRefs = DependenciesBridge.shared.tsResourceStore.bodyAttachments(
                for: message,
                tx: transaction.asV2Read
            )
            let hasMoreThanOneAttachment: Bool = attachmentRefs.count > 1
            let hasBodyText: Bool = !(message.body?.isEmpty ?? true)
            if hasMoreThanOneAttachment || hasBodyText {
                // Refuse to render incoming "view once" messages if they
                // have more than one attachment or any body text.
                owsFailDebug("Invalid content.")
                return buildViewOnce(viewOnceState: .incomingInvalidContent)
            }
            let mediaAttachments: [ReferencedTSResource] = DependenciesBridge.shared.tsResourceStore
                .referencedBodyMediaAttachments(
                    for: message,
                    tx: transaction.asV2Read
                )
            // We currently only support single attachments for view-once messages.
            guard let mediaAttachment = mediaAttachments.first else {
                owsFailDebug("Missing attachment.")
                return buildViewOnce(viewOnceState: .incomingInvalidContent)
            }
            let renderingFlag = mediaAttachment.reference.renderingFlag
            if let attachmentStream = mediaAttachment.attachment.asResourceStream() {
                if attachmentStream.computeIsValidVisualMedia()
                    && (
                        MimeTypeUtil.isSupportedImageMimeType(attachmentStream.mimeType)
                        || MimeTypeUtil.isSupportedMaybeAnimatedMimeType(attachmentStream.mimeType)
                        || MimeTypeUtil.isSupportedVideoMimeType(attachmentStream.mimeType)
                    )
                {
                    return buildViewOnce(viewOnceState: .incomingAvailable(
                        attachmentStream: attachmentStream,
                        renderingFlag: renderingFlag
                    ))
                }
            } else if let attachmentPointer = mediaAttachment.attachment.asTransitTierPointer() {
                switch attachmentPointer.downloadState(tx: transaction.asV2Read) {
                case .enqueued, .downloading:
                    return buildViewOnce(viewOnceState: .incomingDownloading(
                        attachmentPointer: attachmentPointer,
                        renderingFlag: renderingFlag
                    ))
                case .failed:
                    return buildViewOnce(viewOnceState: .incomingFailed)
                case .none:
                    return buildViewOnce(viewOnceState: .incomingPending)
                }
            }

            owsFailDebug("Invalid content.")
            return buildViewOnce(viewOnceState: .incomingInvalidContent)
        } else {
            throw OWSAssertionError("Invalid message.")
        }
    }

    // TODO: Should we throw more?
    mutating func buildContact(message: TSMessage, contact: OWSContact) throws -> CVComponentState {
        let contactShare = ContactShareViewModel(
            contactShareRecord: contact,
            parentMessage: message,
            transaction: transaction
        )
        let state = CVContactShareView.buildState(
            contactShare: contactShare,
            isIncoming: isIncoming,
            conversationStyle: conversationStyle,
            transaction: transaction
        )
        self.contactShare = ContactShare(state: state)

        let phoneNumberPartition = contactShare.dbRecord.phoneNumberPartition(tx: transaction)
        let phoneNumberAction: CVMessageAction? = phoneNumberPartition.map(
            ifSendablePhoneNumbers: {
                // If system contacts are known/linkable, show a "Send" button.
                return CVMessageAction(
                    title: CommonStrings.sendMessage,
                    accessibilityIdentifier: "send_message_to_contact_share",
                    action: .didTapSendMessage(phoneNumbers: $0)
                )
            },
            elseIfInvitablePhoneNumbers: { _ in
                // If system contacts aren't registered, show an "Invite" button.
                return CVMessageAction(
                    title: OWSLocalizedString("ACTION_INVITE", comment: "Label for 'invite' button in contact view."),
                    accessibilityIdentifier: "invite_contact_share",
                    action: .didTapSendInvite(contactShare: contactShare)
                )
            },
            elseIfAddablePhoneNumbers: { _ in
                // Otherwise, offer to add the number(s) to your address book.
                return CVMessageAction(
                    title: OWSLocalizedString("CONVERSATION_VIEW_ADD_TO_CONTACTS_OFFER", comment: "Message shown in conversation view that offers to add an unknown user to your phone's contacts."),
                    accessibilityIdentifier: "add_to_contacts",
                    action: .didTapAddToContacts(contactShare: contactShare)
                )
            },
            elseIfNoPhoneNumbers: {
                return nil
            }
        )
        if let phoneNumberAction {
            bottomButtonsActions.append(phoneNumberAction)
        }

        return build()
    }

    // TODO: Should we throw more?
    mutating func buildSticker(message: TSMessage, messageSticker: MessageSticker) throws -> CVComponentState {

        guard
            let attachmentReference = DependenciesBridge.shared.tsResourceStore.stickerAttachment(
                for: message,
                tx: transaction.asV2Read
            ),
            let attachment = attachmentReference.fetch(tx: transaction)
        else {
            throw OWSAssertionError("Missing sticker attachment.")
        }
        if let attachmentStream = attachment.asResourceStream() {
            switch attachmentStream.computeContentType() {
            case .image(let pixelSize), .animatedImage(let pixelSize):
                let pixelSize = pixelSize.compute()
                guard pixelSize.isNonEmpty else {
                    fallthrough
                }
            default:
                throw OWSAssertionError("Invalid sticker.")
            }
            let stickerType = StickerManager.stickerType(forContentType: attachmentStream.mimeType)
            guard
                let stickerMetadata = attachmentStream.asStickerMetadata(
                    stickerInfo: messageSticker.info,
                    stickerType: stickerType,
                    emojiString: messageSticker.emoji
                )
            else {
                throw OWSAssertionError("Invalid sticker.")
            }
            self.sticker = .available(
                stickerMetadata: stickerMetadata,
                attachmentStream: .init(reference: attachmentReference, attachmentStream: attachmentStream)
            )
            return build()
        } else if let attachmentPointer = attachment.asTransitTierPointer() {
            let downloadState = attachmentPointer.downloadState(tx: transaction.asV2Read)
            switch downloadState {
            case .enqueued, .downloading:
                self.sticker = .downloading(attachmentPointer: .init(
                    reference: attachmentReference,
                    attachmentPointer: attachmentPointer
                ))
            case .failed, .none:
                self.sticker = .failedOrPending(
                    attachmentPointer: .init(reference: attachmentReference, attachmentPointer: attachmentPointer),
                    transitTierDownloadState: downloadState
                )
            }
            return build()
        } else {
            throw OWSAssertionError("Invalid sticker.")
        }
    }

    // TODO: Should we validate and throw errors?
    mutating func buildQuotedReply(
        message: TSMessage,
        revealedSpoilerIdsSnapshot: Set<StyleIdType>
    ) {
        let quotedReplyModel: QuotedReplyModel? = {
            if
                message.isStoryReply,
                let storyTimestamp = message.storyTimestamp?.uint64Value,
                let storyAuthorAci = message.storyAuthorAci?.wrappedAciValue
            {
                return QuotedReplyModel.build(
                    storyReplyMessage: message,
                    storyTimestamp: storyTimestamp,
                    storyAuthorAci: storyAuthorAci,
                    transaction: transaction
                )
            } else if let quotedMessage = message.quotedMessage {
                return QuotedReplyModel.build(replyMessage: message, quotedMessage: quotedMessage, transaction: transaction)
            } else {
                return nil
            }
        }()
        guard let quotedReplyModel else {
            return
        }
        var displayableQuotedText: DisplayableText?
        if let quotedBody = quotedReplyModel.originalMessageBody, !quotedBody.text.isEmpty {
            displayableQuotedText = CVComponentState.displayableQuotedText(
                text: quotedBody.text,
                ranges: quotedBody.ranges,
                interaction: message,
                revealedSpoilerIdsSnapshot: revealedSpoilerIdsSnapshot,
                transaction: transaction
            )
        }
        let viewState = QuotedMessageView.stateForConversation(
            quotedReplyModel: quotedReplyModel,
            displayableQuotedText: displayableQuotedText,
            conversationStyle: conversationStyle,
            isOutgoing: isOutgoing,
            transaction: transaction
        )
        self.quotedReply = QuotedReply(viewState: viewState)
    }

    mutating func buildBodyText(message: TSMessage) throws {
        bodyText = try CVComponentBodyText.buildComponentState(message: message,
                                                               transaction: transaction)
    }

    // MARK: -

    func buildMediaAlbumItems(for mediaAttachments: [ReferencedTSResource],
                              message: TSMessage) -> [CVMediaAlbumItem] {

        let threadHasPendingMessageRequest = message.thread(tx: transaction)?
            .hasPendingMessageRequest(transaction: transaction)
            ?? false

        var mediaAlbumItems = [CVMediaAlbumItem]()
        for attachment in mediaAttachments {
            guard
                // Use the validated content type and only fall back to the mime type.
                (attachment.attachment.asResourceStream()?.cachedContentType)?.isVisualMedia
                ?? MimeTypeUtil.isSupportedVisualMediaMimeType(attachment.attachment.mimeType)
            else {
                // Well behaving clients should not send a mix of visual media (like JPG) and non-visual media (like PDF's)
                // Since we're not coped to handle a mix of media, return @[]
                owsAssertDebug(mediaAlbumItems.count == 0)
                return []
            }

            guard let cvAttachment = CVAttachment.from(attachment, tx: transaction) else {
                owsFailDebug("Invalid attachment!")
                continue
            }

            let caption = attachment.reference.legacyMessageCaption
            let hasCaption = caption.map {
                return CVComponentState.displayableCaption(
                    text: $0,
                    transaction: transaction
                ).fullTextValue.isEmpty.negated
            } ?? false

            switch cvAttachment {
            case .pointer:
                var mediaSize: CGSize = .zero
                if let sourceMediaSizePixels = attachment.reference.sourceMediaSizePixels {
                    mediaSize = sourceMediaSizePixels
                } else {
                    owsFailDebug("Invalid attachment.")
                }
                mediaAlbumItems.append(CVMediaAlbumItem(
                    attachment: cvAttachment,
                    attachmentStream: nil,
                    hasCaption: hasCaption,
                    mediaSize: mediaSize,
                    isBroken: false,
                    threadHasPendingMessageRequest: threadHasPendingMessageRequest
                ))
                continue
            case .stream(let attachmentStream):
                let attachmentStream = attachmentStream.attachmentStream
                guard attachmentStream.computeIsValidVisualMedia() else {
                    Logger.warn("Filtering invalid media.")
                    mediaAlbumItems.append(CVMediaAlbumItem(
                        attachment: cvAttachment,
                        attachmentStream: nil,
                        hasCaption: hasCaption,
                        mediaSize: .zero,
                        isBroken: true,
                        threadHasPendingMessageRequest: threadHasPendingMessageRequest
                    ))
                    continue
                }
                let mediaSizePixels: CGSize
                switch attachmentStream.computeContentType() {
                case let .image(pixelSize), let .video(_, pixelSize), let .animatedImage(pixelSize):
                    let pixelSize = pixelSize.compute()
                    guard pixelSize.isNonEmpty else {
                        Logger.warn("Filtering media with invalid size.")
                        fallthrough
                    }
                    mediaSizePixels = pixelSize
                case .audio, .file, .invalid:
                    Logger.warn("Filtering media with invalid size.")
                    mediaAlbumItems.append(CVMediaAlbumItem(
                        attachment: cvAttachment,
                        attachmentStream: nil,
                        hasCaption: hasCaption,
                        mediaSize: .zero,
                        isBroken: true,
                        threadHasPendingMessageRequest: threadHasPendingMessageRequest
                    ))
                    continue
                }

                mediaAlbumItems.append(CVMediaAlbumItem(
                    attachment: cvAttachment,
                    attachmentStream: attachmentStream,
                    hasCaption: hasCaption,
                    mediaSize: mediaSizePixels,
                    isBroken: false,
                    threadHasPendingMessageRequest: threadHasPendingMessageRequest
                ))
            }
        }
        return mediaAlbumItems
    }

    mutating func buildNonMediaAttachment(bodyAttachment: ReferencedTSResource?) throws {

        guard let attachment = bodyAttachment else {
            throw OWSAssertionError("Missing attachment.")
        }

        func buildGenericAttachment() {
            if let cvAttachment = CVAttachment.from(attachment, tx: transaction) {
                self.genericAttachment = .init(attachment: cvAttachment)
            }
        }

        guard
            // Use the validated content type and only fall back to the mime type.
            (attachment.attachment.asResourceStream()?.cachedContentType)?.isAudio
            ?? MimeTypeUtil.isSupportedAudioMimeType(attachment.attachment.mimeType)
        else {
            buildGenericAttachment()
            return
        }

        if
            let attachmentStream = attachment.asReferencedStream,
            let audioAttachment = AudioAttachment(
                attachmentStream: attachmentStream,
                owningMessage: interaction as? TSMessage,
                metadata: nil,
                receivedAtDate: interaction.receivedAtDate
            )
        {
            self.audioAttachment = audioAttachment
        } else if let attachmentPointer = attachment.asReferencedPointer {
            self.audioAttachment = AudioAttachment(
                attachmentPointer: attachmentPointer,
                owningMessage: interaction as? TSMessage,
                metadata: nil,
                receivedAtDate: interaction.receivedAtDate,
                transitTierDownloadState: attachmentPointer.attachmentPointer.downloadState(tx: transaction.asV2Read)
            )
        } else {
            buildGenericAttachment()
        }
    }

    mutating func buildPaymentAttachment(
        paymentNotification: TSPaymentNotification
    ) -> CVComponentState {

        // Note: there should only ever be one payment model per receipt,
        // but this is unenforced.
        let paymentModel: TSPaymentModel? = PaymentFinder.paymentModels(
            forMcReceiptData: paymentNotification.mcReceiptData,
            transaction: itemBuildingContext.transaction
        ).first

        self.paymentAttachment = PaymentAttachment(
            notification: paymentNotification,
            model: paymentModel,
            // Only used for 1:1 threads, but not enforced.
            otherUserShortName: threadViewModel.shortName ?? threadViewModel.name
        )

        return build()
    }

    mutating func buildLinkPreview(message: TSMessage, linkPreview: OWSLinkPreview) throws {
        guard bodyText != nil else {
            owsFailDebug("Missing body text.")
            return
        }
        guard let urlString = linkPreview.urlString else {
            owsFailDebug("Missing urlString.")
            return
        }
        guard let url = URL(string: urlString) else {
            owsFailDebug("Invalid urlString.")
            return
        }
        if let groupInviteLinkInfo = GroupManager.parseGroupInviteLink(url) {
            let groupInviteLinkViewModel = CVComponentState.configureGroupInviteLink(url, message: message,
                                                                                     groupInviteLinkInfo: groupInviteLinkInfo)
            if !groupInviteLinkViewModel.isExpired {
                let linkType: LinkPreviewLinkType = (isIncoming
                                                        ? .incomingMessageGroupInviteLink
                                                        : .outgoingMessageGroupInviteLink)
                let state = LinkPreviewGroupLink(linkType: linkType,
                                                 linkPreview: linkPreview,
                                                 groupInviteLinkViewModel: groupInviteLinkViewModel,
                                                 conversationStyle: conversationStyle)
                self.linkPreview = LinkPreview(linkPreview: linkPreview,
                                               linkPreviewAttachment: nil,
                                               state: state)
            }
        } else {
            let linkPreviewAttachment = { () -> TSResource? in
                guard
                    let linkPreviewAttachmentRef = DependenciesBridge.shared.tsResourceStore.linkPreviewAttachment(
                        for: message,
                        tx: transaction.asV2Read
                    ),
                    let linkPreviewAttachment = DependenciesBridge.shared.tsResourceStore.fetch(
                        linkPreviewAttachmentRef.resourceId,
                        tx: transaction.asV2Read
                    )
                else {
                    return nil
                }

                guard MimeTypeUtil.isSupportedImageMimeType(linkPreviewAttachment.mimeType) else {
                    owsFailDebug("Link preview attachment isn't an image.")
                    return nil
                }
                guard let attachmentStream = linkPreviewAttachment.asResourceStream() else {
                    return nil
                }
                guard attachmentStream.computeContentType().isImage else {
                    owsFailDebug("Link preview image attachment isn't valid.")
                    return nil
                }
                return attachmentStream
            }()

            let state = LinkPreviewSent(linkPreview: linkPreview,
                                        imageAttachment: linkPreviewAttachment,
                                        conversationStyle: conversationStyle)
            self.linkPreview = LinkPreview(linkPreview: linkPreview,
                                           linkPreviewAttachment: linkPreviewAttachment,
                                           state: state)
        }
    }

    private mutating func buildGiftBadge(messageUniqueId: String, giftBadge: OWSGiftBadge) throws -> CVComponentState {
        let (level, expirationDate) = try giftBadge.getReceiptDetails()
        self.giftBadge = GiftBadge(
            messageUniqueId: messageUniqueId,
            otherUserShortName: threadViewModel.shortName ?? threadViewModel.name,
            cachedBadge: SubscriptionManagerImpl.getCachedBadge(level: .giftBadge(level)),
            expirationDate: expirationDate,
            redemptionState: giftBadge.redemptionState
        )
        return build()
    }
}

// MARK: - DisplayableText

public extension CVComponentState {

    static func displayableBodyText(text: String,
                                    ranges: MessageBodyRanges?,
                                    interaction: TSInteraction,
                                    transaction: SDSAnyReadTransaction) -> DisplayableText {
        return DisplayableText.displayableText(
            withMessageBody: MessageBody(text: text, ranges: ranges ?? .empty),
            transaction: transaction)
    }

    static func displayableBodyText(
        oversizeTextAttachment attachmentStream: TSResourceStream,
        ranges: MessageBodyRanges?,
        interaction: TSInteraction,
        transaction: SDSAnyReadTransaction
    ) -> DisplayableText {

        let text = { () -> String in
            do {
                return try attachmentStream.decryptedLongText()
            } catch {
                owsFailDebug("Couldn't load oversize text: \(error).")
                return ""
            }
        }()

        return DisplayableText.displayableText(
            withMessageBody: MessageBody(text: text, ranges: ranges ?? .empty),
            transaction: transaction)
    }
}

// MARK: -

fileprivate extension CVComponentState {

    static func displayableQuotedText(
        text: String,
        ranges: MessageBodyRanges?,
        interaction: TSInteraction,
        revealedSpoilerIdsSnapshot: Set<StyleIdType>,
        transaction: SDSAnyReadTransaction
    ) -> DisplayableText {
        return DisplayableText.displayableText(
            withMessageBody: MessageBody(text: text, ranges: ranges ?? .empty),
            transaction: transaction
        )
    }

    static func displayableCaption(
        text: String,
        transaction: SDSAnyReadTransaction
    ) -> DisplayableText {
        return DisplayableText.displayableText(
            withMessageBody: MessageBody(text: text, ranges: .empty),
            transaction: transaction
        )
    }
}

// MARK: -

public extension CVComponentState {
    var hasPrimaryAndSecondaryContentForSelection: Bool {
        var hasPrimaryContent = false

        // Search for a component that qualifies as "non-body text primary".
        for key in activeComponentStateKeys {
            switch key {
            case .bodyText, .linkPreview:
                // "Primary" content is not body text.
                // A link preview is associated with the body text.
                break
            case .bodyMedia, .sticker, .audioAttachment, .genericAttachment, .contactShare:
                hasPrimaryContent = true
            case .senderName, .senderAvatar, .footer, .reactions, .bottomButtons, .sendFailureBadge, .dateHeader, .unreadIndicator, .typingIndicator, .threadDetails, .failedOrPendingDownloads, .unknownThreadWarning, .defaultDisappearingMessageTimer, .messageRoot:
                // "Primary" content is not just metadata / UI.
                break
            case .giftBadge:
                // Gift badges can't be forwarded.
                break
            case .viewOnce:
                // We should never forward view-once messages.
                break
            case .systemMessage:
                // We should never forward system messages.
                break
            case .quotedReply:
                // Quoted replies are never forwarded.
                break
            case .paymentAttachment:
                // Payments can't be forwarded.
                break
            }
        }

        let hasSecondaryContent = bodyText != nil

        return hasPrimaryContent && hasSecondaryContent
    }
}
