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

import LibSignalClient
import SignalServiceKit
import SignalUI

// CVItemViewState represents the transient, un-persisted values
// that may affect item appearance.
//
// Compare with CVComponentState which represents the persisted values
// that may affect item appearance.
//
// CVItemViewState might be affected by adjacent items, profile changes,
// the passage of time, etc.
public struct CVItemViewState: Equatable {
    let shouldShowSenderAvatar: Bool
    let accessibilityAuthorName: String?
    let shouldHideFooter: Bool
    let isFirstInCluster: Bool
    let isLastInCluster: Bool
    let shouldCollapseSystemMessageAction: Bool

    // Some components have transient state.
    let senderNameState: CVComponentState.SenderName?
    let footerState: CVComponentFooter.State?
    let dateHeaderState: CVComponentDateHeader.State?
    let bodyTextState: CVComponentBodyText.State?
    let giftBadgeState: CVComponentGiftBadge.ViewState?
    let nextAudioAttachment: AudioAttachment?
    let audioPlaybackRate: Float

    let uiMode: ConversationUIMode
    let previousUIMode: ConversationUIMode

    public var isShowingSelectionUI: Bool { uiMode.hasSelectionUI }
    public var wasShowingSelectionUI: Bool { previousUIMode.hasSelectionUI }

    public class Builder {
        var shouldShowSenderAvatar = false
        var accessibilityAuthorName: String?
        var shouldHideFooter = false
        var isFirstInCluster = false
        var isLastInCluster = false
        var shouldCollapseSystemMessageAction = false
        var senderNameState: CVComponentState.SenderName?
        var footerState: CVComponentFooter.State?
        var dateHeaderState: CVComponentDateHeader.State?
        var bodyTextState: CVComponentBodyText.State?
        var giftBadgeState: CVComponentGiftBadge.ViewState?
        var nextAudioAttachment: AudioAttachment?
        var audioPlaybackRate: Float = 1
        var uiMode: ConversationUIMode = .normal
        var previousUIMode: ConversationUIMode = .normal

        func build() -> CVItemViewState {
            CVItemViewState(shouldShowSenderAvatar: shouldShowSenderAvatar,
                            accessibilityAuthorName: accessibilityAuthorName,
                            shouldHideFooter: shouldHideFooter,
                            isFirstInCluster: isFirstInCluster,
                            isLastInCluster: isLastInCluster,
                            shouldCollapseSystemMessageAction: shouldCollapseSystemMessageAction,
                            senderNameState: senderNameState,
                            footerState: footerState,
                            dateHeaderState: dateHeaderState,
                            bodyTextState: bodyTextState,
                            giftBadgeState: giftBadgeState,
                            nextAudioAttachment: nextAudioAttachment,
                            audioPlaybackRate: audioPlaybackRate,
                            uiMode: uiMode,
                            previousUIMode: previousUIMode)
        }
    }
}

// MARK: -

struct CVItemModelBuilder: CVItemBuilding, Dependencies {

    let itemBuildingContext: CVItemBuildingContext
    let messageLoader: MessageLoader

    // MARK: -

    private var shouldShowDateOnNextViewItem = true
    private let todayDate = Date()
    private var previousDaysBeforeToday: Int?

    private var items = [ItemBuilder]()
    private var previousItem: ItemBuilder? {
        items.last
    }

    init(loadContext: CVLoadContext) {
        self.itemBuildingContext = loadContext
        self.messageLoader = loadContext.messageLoader
    }

    // TODO: How should we handle failed stickers?
    // TODO: Do we need a new equivalent of clearNeedsUpdate?
    mutating func buildItems() -> [CVItemModel] {
        // Contact Offers / Thread Details are the first item in the thread
        if messageLoader.shouldShowThreadDetails {
            // The thread details should have a stable timestamp.
            let threadDetailsTimestamp: UInt64
            if let firstInteraction = messageLoader.loadedInteractions.first {
                threadDetailsTimestamp = max(1, firstInteraction.timestamp) - 2
            } else {
                threadDetailsTimestamp = 1
            }
            let threadDetails = ThreadDetailsInteraction(thread: thread,
                                                         timestamp: threadDetailsTimestamp)
            let item = addItem(interaction: threadDetails)
            owsAssertDebug(item != nil)
        }

        var interactionIds = Set<String>()
        for interaction in messageLoader.loadedInteractions {
            guard !interactionIds.contains(interaction.uniqueId) else {
                owsFailDebug("Duplicate interaction(1): \(interaction.uniqueId)")
                continue
            }
            interactionIds.insert(interaction.uniqueId)

            let item = addItem(interaction: interaction)
            owsAssertDebug(item != nil)
        }

        if messageLoader.shouldShowDefaultDisappearingMessageTimer(
            thread: thread,
            transaction: transaction
        ) {
            let interaction = DefaultDisappearingMessageTimerInteraction(
                thread: thread,
                timestamp: NSDate.ows_millisecondTimeStamp() - 1
            )
            let item = addItem(interaction: interaction)
            owsAssertDebug(item != nil)
        }

        if let typingIndicatorsSender = viewStateSnapshot.typingIndicatorsSender {
            let interaction = TypingIndicatorInteraction(thread: thread,
                                                         timestamp: NSDate.ows_millisecondTimeStamp(),
                                                         address: typingIndicatorsSender)
            let item = addItem(interaction: interaction)
            owsAssertDebug(item != nil)
        }

        let groupNameColors = ChatColors.groupNameColors(forThread: thread)
        let displayNameCache = DisplayNameCache()

        // Update the properties of the view items.
        //
        // NOTE: This logic uses the break properties which are set in the previous pass.
        for (index, item) in items.enumerated() {
            let previousItem: ItemBuilder? = items[safe: index - 1]
            let nextItem: ItemBuilder? = items[safe: index + 1]

            Self.configureItemViewState(item: item,
                                        previousItem: previousItem,
                                        nextItem: nextItem,
                                        thread: thread,
                                        threadViewModel: threadViewModel,
                                        viewStateSnapshot: viewStateSnapshot,
                                        groupNameColors: groupNameColors,
                                        displayNameCache: displayNameCache,
                                        transaction: transaction)
        }

        return items.map { (itemBuilder: ItemBuilder) in
            itemBuilder.build(coreState: viewStateSnapshot.coreState)
        }
    }

    public static func buildStandaloneItem(interaction: TSInteraction,
                                           thread: TSThread,
                                           threadAssociatedData: ThreadAssociatedData,
                                           threadViewModel: ThreadViewModel,
                                           itemBuildingContext: CVItemBuildingContext,
                                           transaction: SDSAnyReadTransaction) -> CVItemModel? {
        AssertIsOnMainThread()

        let viewStateSnapshot = itemBuildingContext.viewStateSnapshot

        guard let itemBuilder = Self.itemBuilder(forInteraction: interaction,
                                                 thread: thread,
                                                 threadAssociatedData: threadAssociatedData,
                                                 itemBuildingContext: itemBuildingContext,
                                                 componentStateCache: ComponentStateCache()) else {
            owsFailDebug("Could not create itemBuilder.")
            return nil
        }

        let groupNameColors = ChatColors.groupNameColors(forThread: thread)
        let displayNameCache = DisplayNameCache()

        configureItemViewState(item: itemBuilder,
                               previousItem: nil,
                               nextItem: nil,
                               thread: thread,
                               threadViewModel: threadViewModel,
                               viewStateSnapshot: viewStateSnapshot,
                               groupNameColors: groupNameColors,
                               displayNameCache: displayNameCache,
                               transaction: transaction)

        return itemBuilder.build(coreState: viewStateSnapshot.coreState)
    }

    private static func configureItemViewState(item: ItemBuilder,
                                               previousItem: ItemBuilder?,
                                               nextItem: ItemBuilder?,
                                               thread: TSThread,
                                               threadViewModel: ThreadViewModel,
                                               viewStateSnapshot: CVViewStateSnapshot,
                                               groupNameColors: ChatColors.GroupNameColors,
                                               displayNameCache: DisplayNameCache,
                                               transaction: SDSAnyReadTransaction) {
        let itemViewState = item.itemViewState
        itemViewState.shouldShowSenderAvatar = false
        itemViewState.shouldHideFooter = false
        itemViewState.isFirstInCluster = true
        itemViewState.isLastInCluster = true

        let interaction = item.interaction
        let timestampText = DateUtil.formatTimestampShort(interaction.timestamp)

        let hasTapForMore: Bool = {
            guard let bodyText = item.componentState.bodyText,
                  let displayableText = bodyText.displayableText else {
                return false
            }
            guard displayableText.isTextTruncated else {
                return false
            }
            let interactionId = item.interaction.uniqueId
            let isTruncatedTextVisible = viewStateSnapshot.textExpansion.isTextExpanded(interactionId: interactionId)
            return !isTruncatedTextVisible
        }()

        if let paymentMessage = interaction as? OWSPaymentMessage {
            itemViewState.footerState = CVComponentFooter.buildPaymentState(
                interaction: interaction,
                paymentNotification: paymentMessage.paymentNotification,
                hasTapForMore: hasTapForMore,
                transaction: transaction
            )
        } else {
            itemViewState.footerState = CVComponentFooter.buildState(
                interaction: interaction,
                hasTapForMore: hasTapForMore,
                transaction: transaction
            )
        }

        if let giftBadge = item.componentState.giftBadge {
            itemViewState.giftBadgeState = CVComponentGiftBadge.buildViewState(giftBadge)
        }

        itemViewState.audioPlaybackRate =  threadViewModel.associatedData.audioPlaybackRate

        if interaction.interactionType == .dateHeader {
            itemViewState.dateHeaderState = CVComponentDateHeader.buildState(interaction: interaction)
        }
        if let bodyText = item.componentState.bodyText {
            itemViewState.bodyTextState = CVComponentBodyText.buildState(interaction: interaction,
                                                                         bodyText: bodyText,
                                                                         viewStateSnapshot: viewStateSnapshot,
                                                                         hasTapForMore: hasTapForMore,
                                                                         hasPendingMessageRequest: threadViewModel.hasPendingMessageRequest)
        }
        itemViewState.uiMode = viewStateSnapshot.uiMode
        itemViewState.previousUIMode = viewStateSnapshot.previousUIMode

        func canClusterMessages(_ left: ItemBuilder, _ right: ItemBuilder) -> Bool {
            let leftTime = left.interaction.receivedAtTimestamp
            let rightTime = right.interaction.receivedAtTimestamp
            if rightTime < leftTime {
                // Ensure left was received first.
                return canClusterMessages(right, left)
            }
            if left.componentState.reactions != nil {
                // Don't cluster message if the earlier message has a reaction.
                return false
            }
            let maxClusterTimeDifferenceMs = UInt64(kMinuteInMs) * 3
            let elapsedMs = rightTime - leftTime
            return elapsedMs < maxClusterTimeDifferenceMs
        }

        if let outgoingMessage = interaction as? TSOutgoingMessage {
            let receiptStatus = MessageRecipientStatusUtils.recipientStatus(outgoingMessage: outgoingMessage, transaction: transaction)
            let isDisappearingMessage = outgoingMessage.hasPerConversationExpiration
            itemViewState.accessibilityAuthorName = CommonStrings.you

            // clustering
            if let previousItem = previousItem,
               previousItem.interactionType == .outgoingMessage,
               canClusterMessages(previousItem, item) {
                itemViewState.isFirstInCluster = false
            } else {
                itemViewState.isFirstInCluster = true
            }

            if let nextItem = nextItem,
               let nextOutgoingMessage = nextItem.interaction as? TSOutgoingMessage,
               canClusterMessages(item, nextItem) {
                itemViewState.isLastInCluster = false

                // We can skip the "outgoing message status" footer if the next message
                // has the same footer and no "date break" separates us...
                // ...but always show the "sending" and "failed to send" statuses...
                // ...and always show the "disappearing messages" animation...
                // ...and always show the "tap to read more" footer.
                let nextReceiptStatus = MessageRecipientStatusUtils.recipientStatus(outgoingMessage: nextOutgoingMessage, transaction: transaction)
                let nextTimestampText = DateUtil.formatTimestampShort(nextOutgoingMessage.timestamp)
                itemViewState.shouldHideFooter = (
                    timestampText == nextTimestampText &&
                    receiptStatus == nextReceiptStatus &&
                    outgoingMessage.messageState != .failed &&
                    outgoingMessage.messageState != .sending &&
                    outgoingMessage.messageState != .pending &&
                    outgoingMessage.editState == .none &&
                    !isDisappearingMessage &&
                    !hasTapForMore
                )
            } else {
                itemViewState.isLastInCluster = true
            }
        } else if let incomingMessage = interaction as? TSIncomingMessage {
            let incomingSenderAddress: SignalServiceAddress = incomingMessage.authorAddress
            owsAssertDebug(incomingSenderAddress.isValid)
            let isDisappearingMessage = incomingMessage.hasPerConversationExpiration

            // clustering

            if let previousItem = previousItem,
               let previousIncomingMessage = previousItem.interaction as? TSIncomingMessage,
               incomingSenderAddress == previousIncomingMessage.authorAddress,
               canClusterMessages(previousItem, item) {
                itemViewState.isFirstInCluster = false
            } else {
                itemViewState.isFirstInCluster = true
            }

            if let nextItem = nextItem,
               let nextIncomingMessage = nextItem.interaction as? TSIncomingMessage,
                incomingSenderAddress == nextIncomingMessage.authorAddress,
                canClusterMessages(item, nextItem) {
                itemViewState.isLastInCluster = false

                // We can skip the "incoming message status" footer in a cluster if the next message
                // has the same footer and no "date break" separates us...
                // ...but always show the "disappearing messages" animation...
                // ...and always show the "tap to read more" footer.
                let nextTimestampText = DateUtil.formatTimestampShort(nextIncomingMessage.timestamp)
                itemViewState.shouldHideFooter = (timestampText == nextTimestampText &&
                                                    !isDisappearingMessage &&
                                                    incomingMessage.editState == .none &&
                                                    !hasTapForMore)
            } else {
                itemViewState.isLastInCluster = true
            }

            if thread.isGroupThread {
                // Show the sender name for incoming group messages unless
                // the previous message has the same sender name and
                // no "date break" separates us.
                var shouldShowSenderName = true
                let authorName = displayNameCache.displayName(address: incomingSenderAddress, transaction: transaction)
                itemViewState.accessibilityAuthorName = authorName

                if let previousItem = previousItem,
                   let previousIncomingMessage = previousItem.interaction as? TSIncomingMessage {
                    let previousIncomingSenderAddress = previousIncomingMessage.authorAddress
                    owsAssertDebug(previousIncomingSenderAddress.isValid)

                    shouldShowSenderName = incomingSenderAddress != previousIncomingSenderAddress
                }
                if shouldShowSenderName {
                    let senderName = NSAttributedString(string: authorName)
                    let senderNameColor = groupNameColors.color(for: incomingSenderAddress)
                    itemViewState.senderNameState = CVComponentState.SenderName(senderName: senderName,
                                                                                senderNameColor: senderNameColor)
                }

                // Show the sender avatar for incoming group messages unless
                // the next message has the same sender avatar and
                // no "date break" separates us.
                itemViewState.shouldShowSenderAvatar = true
                if let nextItem = nextItem,
                   let nextIncomingMessage = nextItem.interaction as? TSIncomingMessage {
                    let nextIncomingSenderAddress: SignalServiceAddress = nextIncomingMessage.authorAddress
                    itemViewState.shouldShowSenderAvatar = incomingSenderAddress != nextIncomingSenderAddress
                }
            } else {
                // In a 1:1 thread, we can avoid cluttering up voiceover string with the recipient's
                // full name. Group thread's will continue to read off the full name.
                itemViewState.accessibilityAuthorName = displayNameCache.shortDisplayName(
                    address: incomingSenderAddress,
                    transaction: transaction
                )
            }

        } else if [.call, .info, .error].contains(interaction.interactionType) {
            // clustering

            if let previousItem = previousItem,
               interaction.interactionType == previousItem.interaction.interactionType {

                switch previousItem.interaction.interactionType {
                case .error:
                    if let errorMessage = interaction as? TSErrorMessage,
                       let previousErrorMessage = previousItem.interaction as? TSErrorMessage,
                       (errorMessage.errorType == .nonBlockingIdentityChange
                            || previousErrorMessage.errorType != errorMessage.errorType) {
                        itemViewState.isFirstInCluster = true
                    } else {
                        itemViewState.isFirstInCluster = false
                    }
                case .info:
                    if let infoMessage = interaction as? TSInfoMessage,
                       let previousInfoMessage = previousItem.interaction as? TSInfoMessage,
                       (infoMessage.messageType == .verificationStateChange
                            || previousInfoMessage.messageType != infoMessage.messageType) {
                        itemViewState.isFirstInCluster = true
                    } else {
                        itemViewState.isFirstInCluster = false
                    }
                case .call:
                    itemViewState.isFirstInCluster = false
                default:
                    itemViewState.isFirstInCluster = true
                }
            } else {
                itemViewState.isFirstInCluster = true
            }

            if let nextItem = nextItem,
               interaction.interactionType == nextItem.interaction.interactionType {
                switch nextItem.interaction.interactionType {
                case .error:
                    if let errorMessage = interaction as? TSErrorMessage,
                       let nextErrorMessage = nextItem.interaction as? TSErrorMessage,
                       (errorMessage.errorType == .nonBlockingIdentityChange
                            || nextErrorMessage.errorType != errorMessage.errorType) {
                        itemViewState.isLastInCluster = true
                    } else {
                        itemViewState.isLastInCluster = false
                    }
                case .info:
                    if let infoMessage = interaction as? TSInfoMessage,
                       let nextInfoMessage = nextItem.interaction as? TSInfoMessage,
                       (infoMessage.messageType == .verificationStateChange
                            || nextInfoMessage.messageType != infoMessage.messageType) {
                        itemViewState.isLastInCluster = true
                    } else {
                        itemViewState.isLastInCluster = false
                    }
                case .call:
                    itemViewState.isLastInCluster = false
                default:
                    itemViewState.isLastInCluster = true
                }
            } else {
                itemViewState.isLastInCluster = true
            }
        }

        let collapseCutoffTimestamp = NSDate.ows_millisecondsSince1970(for: viewStateSnapshot.collapseCutoffDate)
        if interaction.receivedAtTimestamp > collapseCutoffTimestamp {
            itemViewState.shouldHideFooter = false
        }

        if
            let nextMessage = nextItem?.interaction as? TSMessage,
            let attachmentRef = DependenciesBridge.shared.tsResourceStore
                .bodyMediaAttachments(for: nextMessage, tx: transaction.asV2Read).first,
            let attachment = attachmentRef.fetch(tx: transaction),
            (attachment.asResourceStream()?.cachedContentType)?.isAudio
                ?? MimeTypeUtil.isSupportedAudioMimeType(attachment.mimeType)
        {

            if let stream = attachment.asResourceStream() {
                itemViewState.nextAudioAttachment = AudioAttachment(
                    attachmentStream: .init(reference: attachmentRef, attachmentStream: stream),
                    owningMessage: nextMessage,
                    metadata: nil,
                    receivedAtDate: nextMessage.receivedAtDate
                )
            } else if let pointer = attachment.asTransitTierPointer() {
                itemViewState.nextAudioAttachment = AudioAttachment(
                    attachmentPointer: .init(reference: attachmentRef, attachmentPointer: pointer),
                    owningMessage: nextMessage,
                    metadata: nil,
                    receivedAtDate: nextMessage.receivedAtDate,
                    transitTierDownloadState: pointer.downloadState(tx: transaction.asV2Read)
                )
            } else {
                owsFailDebug("Invalid attachment!")
            }
        }
    }

    private mutating func addDateHeaderViewItemIfNecessary(item: ItemBuilder) {
        let itemTimestamp = item.interaction.timestamp
        owsAssertDebug(itemTimestamp > 0)

        let itemDate = NSDate.ows_date(withMillisecondsSince1970: itemTimestamp)
        let daysBeforeToday = DateUtil.daysFrom(firstDate: itemDate, toSecondDate: todayDate)

        var shouldShowDate = false
        if let previousDaysBeforeToday = self.previousDaysBeforeToday {
            if daysBeforeToday != previousDaysBeforeToday {
                shouldShowDateOnNextViewItem = true
            }
        } else {
            // Only show for the first item if the date is not today
            shouldShowDateOnNextViewItem = daysBeforeToday != 0
        }

        if shouldShowDateOnNextViewItem && item.canShowDate {
            shouldShowDate = true
            shouldShowDateOnNextViewItem = false
        }

        if shouldShowDate {
            let interaction = DateHeaderInteraction(thread: thread, timestamp: itemTimestamp)
            let componentState = CVComponentState.buildDateHeader(interaction: interaction,
                                                                  itemBuildingContext: itemBuildingContext)
            let item = ItemBuilder(interaction: interaction,
                                   thread: thread,
                                   threadAssociatedData: threadAssociatedData,
                                   componentState: componentState)
            items.append(item)
        }

        self.previousDaysBeforeToday = daysBeforeToday
    }

    var hasPlacedUnreadIndicator = false

    private mutating func addUnreadHeaderViewItemIfNecessary(item: ItemBuilder) {
        let itemTimestamp = item.interaction.timestamp
        owsAssertDebug(itemTimestamp > 0)

        if hasPlacedUnreadIndicator {
            return
        }
        if let oldestSortId = viewStateSnapshot.oldestUnreadMessageSortId, oldestSortId <= item.interaction.sortId {
            hasPlacedUnreadIndicator = true
            let interaction = UnreadIndicatorInteraction(thread: thread,
                                                         timestamp: itemTimestamp,
                                                         receivedAtTimestamp: item.interaction.receivedAtTimestamp)
            let componentState = CVComponentState.buildUnreadIndicator(interaction: interaction,
                                                                       itemBuildingContext: itemBuildingContext)
            let item = ItemBuilder(interaction: interaction,
                                   thread: thread,
                                   threadAssociatedData: threadAssociatedData,
                                   componentState: componentState)
            items.append(item)
        }
    }

    private class ComponentStateCache {
        var cache = [String: CVComponentState]()

        func add(interactionId: String, componentState: CVComponentState) {
            cache[interactionId] = componentState
        }

        func get(interactionId: String) -> CVComponentState? {
            cache[interactionId]
        }
    }
    private var componentStateCache = ComponentStateCache()

    mutating func reuseComponentStates(prevRenderState: CVRenderState,
                                       updatedInteractionIds: Set<String>) {

        for renderItem in prevRenderState.items {
            guard !updatedInteractionIds.contains(renderItem.interactionUniqueId) else {
                continue
            }
            componentStateCache.add(interactionId: renderItem.interactionUniqueId,
                                    componentState: renderItem.rootComponent.componentState)
        }
    }

    private static func buildComponentState(interaction: TSInteraction,
                                            itemBuildingContext: CVItemBuildingContext,
                                            componentStateCache: ComponentStateCache) throws -> CVComponentState {
        if let componentState = componentStateCache.get(interactionId: interaction.uniqueId) {
            // CVComponentState is immutable and safe to re-use without copying. It's currently a struct.
            return componentState
        }
        return try CVComponentState.build(interaction: interaction,
                                          itemBuildingContext: itemBuildingContext)
    }

    private mutating func addItem(interaction: TSInteraction) -> ItemBuilder? {
        guard let item = Self.itemBuilder(forInteraction: interaction,
                                          thread: thread,
                                          threadAssociatedData: threadAssociatedData,
                                          itemBuildingContext: itemBuildingContext,
                                          componentStateCache: componentStateCache) else {
            return nil
        }

        // Insert dynamic header item(s) before this item if necessary.
        addDateHeaderViewItemIfNecessary(item: item)
        addUnreadHeaderViewItemIfNecessary(item: item)

        if let previousItem = previousItem {
            configureAdjacent(item: item,
                              previousItem: previousItem,
                              viewStateSnapshot: viewStateSnapshot)
        }

        // Hide "call" buttons if there is an active call in another thread.
        if
            item.interactionType == .call,
            viewStateSnapshot.hasActiveCall,
            viewStateSnapshot.currentGroupCallThreadUniqueId != thread.uniqueId
        {
            item.itemViewState.shouldCollapseSystemMessageAction = true
        }

        items.append(item)

        return item
    }

    private static func itemBuilder(forInteraction interaction: TSInteraction,
                                    thread: TSThread,
                                    threadAssociatedData: ThreadAssociatedData,
                                    itemBuildingContext: CVItemBuildingContext,
                                    componentStateCache: ComponentStateCache) -> ItemBuilder? {
        let componentState: CVComponentState
        do {
            componentState = try buildComponentState(interaction: interaction,
                                                     itemBuildingContext: itemBuildingContext,
                                                     componentStateCache: componentStateCache)
        } catch {
            owsFailDebug("Error: \(error)")
            return nil
        }

        return ItemBuilder(interaction: interaction,
                           thread: thread,
                           threadAssociatedData: threadAssociatedData,
                           componentState: componentState)
    }

    private func configureAdjacent(item: ItemBuilder,
                                   previousItem: ItemBuilder,
                                   viewStateSnapshot: CVViewStateSnapshot) {
        let interaction = item.interaction
        guard previousItem.interactionType == item.interactionType else {
            return
        }

        switch item.interactionType {
        case .error:
            guard let errorMessage = interaction as? TSErrorMessage,
                  let previousErrorMessage = previousItem.interaction as? TSErrorMessage else {
                owsFailDebug("Invalid interactions.")
                return
            }
            if errorMessage.errorType == .nonBlockingIdentityChange {
                return
            }
            previousItem.itemViewState.shouldCollapseSystemMessageAction
                = previousErrorMessage.errorType == errorMessage.errorType
        case .info:
            guard let infoMessage = interaction as? TSInfoMessage,
                  let previousInfoMessage = previousItem.interaction as? TSInfoMessage else {
                owsFailDebug("Invalid interactions.")
                return
            }
            switch infoMessage.messageType {
            case .verificationStateChange, .typeGroupUpdate, .threadMerge, .sessionSwitchover:
                return // never collapse
            case .phoneNumberChange:
                // Only collapse if the previous message was a change number for the same user
                guard
                    case .phoneNumberChange = previousInfoMessage.messageType,
                    let previousAciString = previousInfoMessage.infoMessageUserInfo?[.changePhoneNumberAciString] as? String,
                    let previousAci = Aci.parseFrom(aciString: previousAciString),
                    let currentAciString = infoMessage.infoMessageUserInfo?[.changePhoneNumberAciString] as? String,
                    let currentAci = Aci.parseFrom(aciString: currentAciString)
                else {
                    return
                }
                previousItem.itemViewState.shouldCollapseSystemMessageAction = previousAci == currentAci
            default:
                // always collapse matching types
                previousItem.itemViewState.shouldCollapseSystemMessageAction
                    = previousInfoMessage.messageType == infoMessage.messageType
            }
        case .call:
            previousItem.itemViewState.shouldCollapseSystemMessageAction = true
        default:
            break
        }
    }
}

// MARK: -

private extension MessageLoader {
    var shouldShowThreadDetails: Bool {
        !canLoadOlder
    }

    func shouldShowDefaultDisappearingMessageTimer(thread: TSThread, transaction: SDSAnyReadTransaction) -> Bool {
        ThreadFinder().shouldSetDefaultDisappearingMessageTimer(thread: thread, transaction: transaction)
    }
}

// MARK: -

private class ItemBuilder {
    let interaction: TSInteraction
    let thread: TSThread
    let threadAssociatedData: ThreadAssociatedData
    let componentState: CVComponentState
    var itemViewState = CVItemViewState.Builder()

    init(interaction: TSInteraction,
         thread: TSThread,
         threadAssociatedData: ThreadAssociatedData,
         componentState: CVComponentState) {
        self.interaction = interaction
        self.thread = thread
        self.threadAssociatedData = threadAssociatedData
        self.componentState = componentState
    }

    func build(coreState: CVCoreState) -> CVItemModel {
        CVItemModel(interaction: interaction,
                    thread: thread,
                    threadAssociatedData: threadAssociatedData,
                    componentState: componentState,
                    itemViewState: itemViewState.build(),
                    coreState: coreState)
    }

    var interactionType: OWSInteractionType {
        interaction.interactionType
    }

    var canShowDate: Bool {
        switch interaction.interactionType {
        case .unknown, .typingIndicator, .threadDetails, .dateHeader, .unknownThreadWarning, .defaultDisappearingMessageTimer:
            return false
        case .info:
            guard let infoMessage = interaction as? TSInfoMessage else {
                owsFailDebug("Invalid interaction.")
                return false
            }
            // Only show the date for non-synced thread messages;
            return infoMessage.messageType != .syncedThread
        case .unreadIndicator, .incomingMessage, .outgoingMessage, .error, .call:
            return true
        }
    }
}

// MARK: -

class DisplayNameCache: Dependencies {
    private var displayNameCache = [ServiceId: DisplayName]()

    private func _displayName(for address: SignalServiceAddress, tx: SDSAnyReadTransaction) -> DisplayName {
        if let serviceId = address.serviceId, let displayName = displayNameCache[serviceId] {
            return displayName
        }
        let displayName = contactsManager.displayName(for: address, tx: tx)
        if let serviceId = address.serviceId {
            displayNameCache[serviceId] = displayName
        }
        return displayName
    }

    func shortDisplayName(address: SignalServiceAddress, transaction: SDSAnyReadTransaction) -> String {
        return _displayName(for: address, tx: transaction).resolvedValue(useShortNameIfAvailable: true)
    }

    func displayName(address: SignalServiceAddress, transaction: SDSAnyReadTransaction) -> String {
        return _displayName(for: address, tx: transaction).resolvedValue(useShortNameIfAvailable: false)
    }
}
