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

public import Foundation
import LibSignalClient
public import SignalServiceKit
public import SignalUI

// This entity performs a single load.
public class CVLoader: NSObject {
    private let threadUniqueId: String
    private let loadRequest: CVLoadRequest
    private let viewStateSnapshot: CVViewStateSnapshot
    private let spoilerState: SpoilerRenderState
    private let prevRenderState: CVRenderState
    private let messageLoader: MessageLoader

    init(
        threadUniqueId: String,
        loadRequest: CVLoadRequest,
        viewStateSnapshot: CVViewStateSnapshot,
        spoilerState: SpoilerRenderState,
        prevRenderState: CVRenderState,
        messageLoader: MessageLoader,
    ) {
        self.threadUniqueId = threadUniqueId
        self.loadRequest = loadRequest
        self.viewStateSnapshot = viewStateSnapshot
        self.spoilerState = spoilerState
        self.prevRenderState = prevRenderState
        self.messageLoader = messageLoader
    }

    func loadPromise() -> Promise<CVUpdate> {
        let threadUniqueId = self.threadUniqueId
        let loadRequest = self.loadRequest
        let viewStateSnapshot = self.viewStateSnapshot
        let spoilerState = self.spoilerState
        let prevRenderState = self.prevRenderState
        let messageLoader = self.messageLoader

        struct LoadState {
            let threadViewModel: ThreadViewModel
            let conversationViewModel: ConversationViewModel
            let items: [CVRenderItem]
        }

        return firstly(on: CVUtils.workQueue(isInitialLoad: loadRequest.isInitialLoad)) { () -> CVUpdate in
            // To ensure coherency, the entire load should be done with a single transaction.
            let loadState: LoadState = try SSKEnvironment.shared.databaseStorageRef.read { transaction in
                let thread = TSThread.fetchViaCache(uniqueId: threadUniqueId, transaction: transaction)
                let threadViewModel = { () -> ThreadViewModel in
                    guard let thread else {
                        // If thread has been deleted from the database, use last known model.
                        return prevRenderState.threadViewModel
                    }
                    return ThreadViewModel(thread: thread, forChatList: false, transaction: transaction)
                }()
                let conversationViewModel = { () -> ConversationViewModel in
                    guard let thread else {
                        // If thread has been deleted from the database, use last known model.
                        return prevRenderState.conversationViewModel
                    }
                    return ConversationViewModel.load(for: thread, tx: transaction)
                }()

                guard let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction)?.aci else {
                    throw OWSAssertionError("User not registered")
                }

                let loadContext = CVLoadContext(
                    loadRequest: loadRequest,
                    threadViewModel: threadViewModel,
                    viewStateSnapshot: viewStateSnapshot,
                    spoilerState: spoilerState,
                    messageLoader: messageLoader,
                    prevRenderState: prevRenderState,
                    localAci: localAci,
                    transaction: transaction,
                )

                // Don't cache in the reset() case.
                let canReuseInteractions = loadRequest.canReuseInteractionModels && !loadRequest.didReset
                var updatedInteractionIds = loadRequest.updatedInteractionIds
                let deletedInteractionIds: Set<String>? = loadRequest.didReset ? loadRequest.deletedInteractionIds : nil

                let didThreadDetailsChange: Bool = {
                    let prevThreadViewModel = prevRenderState.threadViewModel
                    guard let groupModel = threadViewModel.threadRecord.groupModelIfGroupThread else {
                        return false
                    }
                    guard let prevGroupModel = prevThreadViewModel.threadRecord.groupModelIfGroupThread else {
                        owsFailDebug("Missing groupModel.")
                        return false
                    }
                    let groupDescriptionDidChange = (groupModel as? TSGroupModelV2)?.descriptionText
                        != (prevGroupModel as? TSGroupModelV2)?.descriptionText
                    return groupModel.groupName != prevGroupModel.groupName ||
                        groupDescriptionDidChange ||
                        groupModel.avatarHash != prevGroupModel.avatarHash ||
                        groupModel.groupMembership.fullMembers.count != prevGroupModel.groupMembership.fullMembers.count
                }()

                // If the thread details did change, reload the thread details
                // item if one is in the load window.
                if
                    didThreadDetailsChange,
                    let prevFirstRenderItem = prevRenderState.items.first,
                    prevFirstRenderItem.interactionType == .threadDetails
                {
                    updatedInteractionIds.insert(prevFirstRenderItem.interactionUniqueId)
                }

                var reusableInteractions = [String: TSInteraction]()
                if canReuseInteractions {
                    for renderItem in prevRenderState.items {
                        let interaction = renderItem.interaction
                        let interactionId = interaction.uniqueId
                        if !updatedInteractionIds.contains(interactionId) {
                            reusableInteractions[interactionId] = interaction
                        }
                    }
                }

                do {
                    switch loadRequest.loadType {
                    case .loadInitialMapping(let focusMessageIdOnOpen, _):
                        owsAssertDebug(reusableInteractions.isEmpty)
                        try messageLoader.loadInitialMessagePage(
                            focusMessageId: focusMessageIdOnOpen,
                            reusableInteractions: [:],
                            deletedInteractionIds: [],
                            tx: transaction,
                        )
                    case .loadSameLocation:
                        try messageLoader.loadSameLocation(
                            reusableInteractions: reusableInteractions,
                            deletedInteractionIds: deletedInteractionIds,
                            tx: transaction,
                        )
                    case .loadOlder:
                        try messageLoader.loadOlderMessagePage(
                            reusableInteractions: reusableInteractions,
                            deletedInteractionIds: deletedInteractionIds,
                            tx: transaction,
                        )
                    case .loadNewer:
                        try messageLoader.loadNewerMessagePage(
                            reusableInteractions: reusableInteractions,
                            deletedInteractionIds: deletedInteractionIds,
                            tx: transaction,
                        )
                    case .loadNewest:
                        try messageLoader.loadNewestMessagePage(
                            reusableInteractions: reusableInteractions,
                            deletedInteractionIds: deletedInteractionIds,
                            tx: transaction,
                        )
                    case .loadPageAroundInteraction(let interactionId, _):
                        try messageLoader.loadMessagePage(
                            aroundInteractionId: interactionId,
                            reusableInteractions: reusableInteractions,
                            deletedInteractionIds: deletedInteractionIds,
                            tx: transaction,
                        )
                    }
                } catch {
                    owsFailDebug("Couldn't load conversation view messages \(error)")
                    throw error
                }

                let initialLoadCount = messageLoader.loadedInteractions.count

                var processedInteractions = Self.preprocessInteractions(
                    messageLoader.loadedInteractions,
                    loadContext: loadContext,
                )

                if case .loadInitialMapping = loadRequest.loadType {
                    let maxExtraLoads = 5
                    var extraLoads = 0
                    while
                        processedInteractions.count < initialLoadCount,
                        messageLoader.canLoadOlder,
                        extraLoads < maxExtraLoads
                    {
                        try messageLoader.loadOlderMessagePage(
                            reusableInteractions: reusableInteractions,
                            deletedInteractionIds: deletedInteractionIds,
                            tx: transaction,
                        )
                        processedInteractions = Self.preprocessInteractions(
                            messageLoader.loadedInteractions,
                            loadContext: loadContext,
                        )
                        extraLoads += 1
                    }
                }

                let itemModels = self.buildItemModels(
                    interactions: processedInteractions,
                    loadContext: loadContext,
                    updatedInteractionIds: updatedInteractionIds,
                    localAci: localAci,
                )

                let items = itemModels.compactMap { item in
                    Self.buildRenderItem(
                        itemBuildingContext: loadContext,
                        itemModel: item,
                    )
                }

                return LoadState(
                    threadViewModel: threadViewModel,
                    conversationViewModel: conversationViewModel,
                    items: items,
                )
            }

            let renderState = CVRenderState(
                threadViewModel: loadState.threadViewModel,
                prevThreadViewModel: prevRenderState.threadViewModel,
                conversationViewModel: loadState.conversationViewModel,
                items: loadState.items,
                canLoadOlderItems: messageLoader.canLoadOlder,
                canLoadNewerItems: messageLoader.canLoadNewer,
                viewStateSnapshot: viewStateSnapshot,
                loadType: loadRequest.loadType,
            )

            let update = CVUpdate.build(
                renderState: renderState,
                prevRenderState: prevRenderState,
                loadRequest: loadRequest,
            )

            return update
        }
    }

    // MARK: -

    private func buildItemModels(
        interactions: [TSInteraction],
        loadContext: CVLoadContext,
        updatedInteractionIds: Set<String>,
        localAci: Aci,
    ) -> [CVItemModel] {
        let conversationStyle = loadContext.conversationStyle

        // Don't cache in the reset() case.
        let canReuseState = loadRequest.canReuseComponentStates && conversationStyle == prevRenderState.conversationStyle

        var itemModelBuilder = CVItemModelBuilder(loadContext: loadContext)

        // CVComponentStates are loaded from the database; these loads
        // can be expensive. Therefore we want to reuse them _unless_:
        //
        // * The corresponding interaction was updated.
        // * We're doing a "reset" reload where we deliberately reload
        //   everything, e.g. in response to an error or a cross-process write.
        if canReuseState {
            itemModelBuilder.reuseComponentStates(
                prevRenderState: prevRenderState,
                updatedInteractionIds: updatedInteractionIds,
            )
        }

        return itemModelBuilder.buildItems(localAci: localAci, interactions: interactions)
    }

    // MARK: - Interaction Preprocessing

    private static let maxCollapseSetSize = 50

    /// Takes a list of interactions and applies preprocessing before the expensive task of creating `CVItemModel`s via `CVItemModelBuilder.buildItems`.
    ///
    ///  1. Inserts date headers
    ///  2. Inserts unread indicator
    ///  3. Collapses chat events
    private static func preprocessInteractions(
        _ interactions: [TSInteraction],
        loadContext: CVLoadContext,
    ) -> [TSInteraction] {
        let thread = loadContext.thread
        let isGroupThread = thread.isGroupThread
        let expandedCollapseSets = loadContext.viewStateSnapshot.expandedCollapseSets
        let oldestUnreadSortId = loadContext.viewStateSnapshot.oldestUnreadMessageSortId

        let todayDate = Date()
        var result = [TSInteraction]()
        var currentRun = [TSInteraction]()
        var currentRunType: CollapseSetInteraction.MessagesType?
        var pastUnreadIndicator = false
        var shouldShowDateOnNextViewItem = true
        var previousDaysBeforeToday: Int?

        func finalizeSet() {
            defer {
                currentRun.removeAll()
                currentRunType = nil
            }
            guard currentRun.count >= 2, let runType = currentRunType else {
                result.append(contentsOf: currentRun)
                return
            }
            let collapseId = "CollapseSet_\(currentRun[0].timestamp)"
            let isExpanded = expandedCollapseSets.contains(collapseId)
            let collapseSetInteraction = CollapseSetInteraction(
                thread: thread,
                collapsedInteractions: currentRun,
                collapseSetType: runType,
                isExpanded: isExpanded,
            )
            result.append(collapseSetInteraction)
            if isExpanded {
                result.append(contentsOf: currentRun)
            }
        }

        for interaction in interactions {
            let timestamp = interaction.timestamp
            let daysBeforeToday = DateUtil.daysFrom(
                firstDate: Date(millisecondsSince1970: timestamp),
                toSecondDate: todayDate,
            )

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

            if
                shouldShowDateOnNextViewItem,
                canShowDateHeader(before: interaction)
            {
                // Collapse sets shouldn't cross date boundaries
                finalizeSet()
                result.append(DateHeaderInteraction(thread: thread, timestamp: timestamp))
                shouldShowDateOnNextViewItem = false
            }
            previousDaysBeforeToday = daysBeforeToday

            // Only insert one unread indicator and don't collapse unread events
            if pastUnreadIndicator {
                result.append(interaction)
                continue
            }

            if let oldestUnreadSortId, oldestUnreadSortId <= interaction.sortId {
                finalizeSet()
                let unreadIndicatorInteraction = UnreadIndicatorInteraction(
                    thread: thread,
                    timestamp: timestamp,
                    receivedAtTimestamp: interaction.receivedAtTimestamp,
                )
                result.append(unreadIndicatorInteraction)
                pastUnreadIndicator = true
                result.append(interaction)
                continue
            }

            guard BuildFlags.collapsingChatEvents else {
                result.append(interaction)
                continue
            }

            let collapseType = collapseSetType(for: interaction, isGroupThread: isGroupThread)
            if let collapseType {
                let isDifferentSetThanCurrentRun = currentRunType != nil && currentRunType != collapseType
                let exceededCurrentRunLimit = currentRun.count >= maxCollapseSetSize
                if isDifferentSetThanCurrentRun || exceededCurrentRunLimit {
                    finalizeSet()
                }
                currentRun.append(interaction)
                currentRunType = collapseType
            } else {
                finalizeSet()
                result.append(interaction)
            }
        }
        finalizeSet()
        return result
    }

    private static func canShowDateHeader(before interaction: TSInteraction) -> Bool {
        switch interaction.interactionType {
        case .unknown, .typingIndicator, .threadDetails, .dateHeader, .unknownThreadWarning, .defaultDisappearingMessageTimer, .collapseSet:
            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
        }
    }

    private static func collapseSetType(
        for interaction: TSInteraction,
        isGroupThread: Bool,
    ) -> CollapseSetInteraction.MessagesType? {
        switch interaction.interactionType {
        case .info:
            guard let infoMessage = interaction as? TSInfoMessage else {
                owsFailDebug("info interaction is not TSInfoMessage")
                return nil
            }
            switch infoMessage.messageType {
            case .typeDisappearingMessagesUpdate:
                return .timerChanges
            case .typeGroupUpdate:
                if
                    let wrapper = infoMessage.infoMessageUserInfo?[.groupUpdateItems]
                    as? TSInfoMessage.PersistableGroupUpdateItemsWrapper
                {
                    for event in wrapper.updateItems {
                        switch event {
                        case
                            .groupTerminatedByLocalUser,
                            .groupTerminatedByOtherUser,
                            .groupTerminatedByUnknownUser:
                            return nil
                        case
                            .disappearingMessagesEnabledByLocalUser,
                            .disappearingMessagesEnabledByOtherUser,
                            .disappearingMessagesEnabledByUnknownUser,
                            .disappearingMessagesDisabledByLocalUser,
                            .disappearingMessagesDisabledByOtherUser,
                            .disappearingMessagesDisabledByUnknownUser:
                            return .timerChanges
                        default:
                            break
                        }
                    }
                }

                return isGroupThread ? .groupUpdates : .chatUpdates
            case .verificationStateChange,
                 .profileUpdate,
                 .phoneNumberChange,
                 .typeEndPoll,
                 .typePinnedMessage:
                return isGroupThread ? .groupUpdates : .chatUpdates
            default:
                return nil
            }
        case .error:
            guard let errorMessage = interaction as? TSErrorMessage else {
                owsFailDebug("error interaction is not TSErrorMessage")
                return nil
            }
            if errorMessage.errorType == .nonBlockingIdentityChange {
                return isGroupThread ? .groupUpdates : .chatUpdates
            }
            return nil
        case .call:
            // Don't collapse an active group call.
            if
                let groupCallMessage = interaction as? OWSGroupCallMessage,
                !groupCallMessage.hasEnded
            {
                return nil
            }
            return .callEvents
        default:
            return nil
        }
    }

    // MARK: -

#if USE_DEBUG_UI

    public static func debugui_buildStandaloneRenderItem(
        interaction: TSInteraction,
        thread: TSThread,
        threadAssociatedData: ThreadAssociatedData,
        containerView: UIView,
        transaction: DBReadTransaction,
    ) -> CVRenderItem? {
        buildStandaloneRenderItem(
            interaction: interaction,
            thread: thread,
            threadAssociatedData: threadAssociatedData,
            containerView: containerView,
            spoilerState: SpoilerRenderState(),
            transaction: transaction,
        )
    }

#endif

    public static func buildStandaloneRenderItem(
        interaction: TSInteraction,
        thread: TSThread,
        threadAssociatedData: ThreadAssociatedData,
        containerView: UIView,
        spoilerState: SpoilerRenderState,
        transaction: DBReadTransaction,
    ) -> CVRenderItem? {
        let chatColor = DependenciesBridge.shared.chatColorSettingStore.resolvedChatColor(
            for: thread,
            tx: transaction,
        )
        let conversationStyle = ConversationStyle(
            type: .`default`,
            thread: thread,
            viewWidth: containerView.width,
            hasWallpaper: false,
            shouldDimWallpaperInDarkMode: false,
            chatColor: chatColor,
        )
        let coreState = CVCoreState(
            conversationStyle: conversationStyle,
            mediaCache: CVMediaCache(),
        )
        let groupNameColors = GroupNameColors.forThread(thread)

        return CVLoader.buildStandaloneRenderItem(
            interaction: interaction,
            thread: thread,
            threadAssociatedData: threadAssociatedData,
            coreState: coreState,
            spoilerState: spoilerState,
            groupNameColors: groupNameColors,
            transaction: transaction,
        )
    }

    public static func buildStandaloneRenderItem(
        interaction: TSInteraction,
        thread: TSThread,
        threadAssociatedData: ThreadAssociatedData,
        conversationStyle: ConversationStyle,
        spoilerState: SpoilerRenderState,
        groupNameColors: GroupNameColors,
        transaction: DBReadTransaction,
    ) -> CVRenderItem? {
        let coreState = CVCoreState(
            conversationStyle: conversationStyle,
            mediaCache: CVMediaCache(),
        )
        return CVLoader.buildStandaloneRenderItem(
            interaction: interaction,
            thread: thread,
            threadAssociatedData: threadAssociatedData,
            coreState: coreState,
            spoilerState: spoilerState,
            groupNameColors: groupNameColors,
            transaction: transaction,
        )
    }

    private static func buildStandaloneRenderItem(
        interaction: TSInteraction,
        thread: TSThread,
        threadAssociatedData: ThreadAssociatedData,
        coreState: CVCoreState,
        spoilerState: SpoilerRenderState,
        groupNameColors: GroupNameColors,
        transaction: DBReadTransaction,
    ) -> CVRenderItem? {
        AssertIsOnMainThread()

        guard let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction)?.aci else {
            owsFailDebug("User not registered")
            return nil
        }

        let threadViewModel = ThreadViewModel(
            thread: thread,
            forChatList: false,
            transaction: transaction,
        )
        let viewStateSnapshot = CVViewStateSnapshot.mockSnapshotForStandaloneItems(
            coreState: coreState,
            spoilerReveal: spoilerState.revealState,
        )
        let avatarBuilder = CVAvatarBuilder(transaction: transaction)
        let itemBuildingContext = CVItemBuildingContextImpl(
            prevRenderState: nil,
            threadViewModel: threadViewModel,
            viewStateSnapshot: viewStateSnapshot,
            transaction: transaction,
            avatarBuilder: avatarBuilder,
            localAci: localAci,
        )
        guard
            let itemModel = CVItemModelBuilder.buildStandaloneItem(
                interaction: interaction,
                thread: thread,
                threadAssociatedData: threadAssociatedData,
                threadViewModel: threadViewModel,
                itemBuildingContext: itemBuildingContext,
                groupNameColors: groupNameColors,
                transaction: transaction,
            )
        else {
            owsFailDebug("Couldn't build item model.")
            return nil
        }
        return Self.buildRenderItem(
            itemBuildingContext: itemBuildingContext,
            itemModel: itemModel,
        )
    }

    public static func buildStandaloneComponentState(
        interaction: TSInteraction,
        spoilerState: SpoilerRenderState,
        transaction: DBReadTransaction,
    ) -> CVComponentState? {
        AssertIsOnMainThread()

        guard let thread = interaction.thread(tx: transaction) else {
            owsFailDebug("Missing thread for interaction.")
            return nil
        }

        guard let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction)?.aci else {
            owsFailDebug("User not registered")
            return nil
        }

        let chatColor = DependenciesBridge.shared.chatColorSettingStore.resolvedChatColor(
            for: thread,
            tx: transaction,
        )
        let mockViewWidth: CGFloat = 800
        let conversationStyle = ConversationStyle(
            type: .`default`,
            thread: thread,
            viewWidth: mockViewWidth,
            hasWallpaper: false,
            shouldDimWallpaperInDarkMode: false,
            chatColor: chatColor,
        )
        let coreState = CVCoreState(
            conversationStyle: conversationStyle,
            mediaCache: CVMediaCache(),
        )
        let threadViewModel = ThreadViewModel(
            thread: thread,
            forChatList: false,
            transaction: transaction,
        )
        let viewStateSnapshot = CVViewStateSnapshot.mockSnapshotForStandaloneItems(
            coreState: coreState,
            spoilerReveal: spoilerState.revealState,
        )
        let avatarBuilder = CVAvatarBuilder(transaction: transaction)
        let itemBuildingContext = CVItemBuildingContextImpl(
            prevRenderState: nil,
            threadViewModel: threadViewModel,
            viewStateSnapshot: viewStateSnapshot,
            transaction: transaction,
            avatarBuilder: avatarBuilder,
            localAci: localAci,
        )
        do {
            return try CVComponentState.build(
                interaction: interaction,
                itemBuildingContext: itemBuildingContext,
            )
        } catch {
            owsFailDebug("Error: \(error)")
            return nil
        }
    }

    private static func buildRenderItem(
        itemBuildingContext: CVItemBuildingContext,
        itemModel: CVItemModel,
    ) -> CVRenderItem? {

        let conversationStyle = itemBuildingContext.conversationStyle

        let rootComponent: CVRootComponent
        switch itemModel.messageCellType {
        case .dateHeader:
            guard let dateHeaderState = itemModel.itemViewState.dateHeaderState else {
                owsFailDebug("Missing dateHeader.")
                return nil
            }
            rootComponent = CVComponentDateHeader(
                itemModel: itemModel,
                dateHeaderState: dateHeaderState,
            )
        case .unreadIndicator:
            rootComponent = CVComponentUnreadIndicator(itemModel: itemModel)
        case .threadDetails:
            guard let threadDetails = itemModel.componentState.threadDetails else {
                owsFailDebug("Missing threadDetails.")
                return nil
            }
            rootComponent = CVComponentThreadDetails(itemModel: itemModel, threadDetails: threadDetails)
        case .unknownThreadWarning:
            guard let unknownThreadWarning = itemModel.componentState.unknownThreadWarning else {
                owsFailDebug("Missing unknownThreadWarning.")
                return nil
            }
            rootComponent = CVComponentSystemMessage(
                itemModel: itemModel,
                systemMessage: unknownThreadWarning,
            )
        case .defaultDisappearingMessageTimer:
            guard let defaultDisappearingMessageTimer = itemModel.componentState.defaultDisappearingMessageTimer else {
                owsFailDebug("Missing unknownThreadWarning.")
                return nil
            }
            rootComponent = CVComponentSystemMessage(
                itemModel: itemModel,
                systemMessage: defaultDisappearingMessageTimer,
            )
        case .textOnlyMessage, .audio, .genericAttachment, .paymentAttachment, .archivedPaymentAttachment,
             .undownloadableAttachment,
             .contactShare, .bodyMedia, .viewOnce, .stickerMessage, .quoteOnlyMessage,
             .giftBadge, .poll:
            rootComponent = CVComponentMessage(itemModel: itemModel)
        case .typingIndicator:
            guard let typingIndicator = itemModel.componentState.typingIndicator else {
                owsFailDebug("Missing typingIndicator.")
                return nil
            }
            rootComponent = CVComponentTypingIndicator(
                itemModel: itemModel,
                typingIndicator: typingIndicator,
            )
        case .systemMessage:
            guard let systemMessage = itemModel.componentState.systemMessage else {
                owsFailDebug("Missing systemMessage.")
                return nil
            }
            rootComponent = CVComponentSystemMessage(itemModel: itemModel, systemMessage: systemMessage)
        case .collapseSet:
            guard let collapseSet = itemModel.componentState.collapseSet else {
                owsFailDebug("Missing collapseSet.")
                return nil
            }
            rootComponent = CVComponentCollapseSet(itemModel: itemModel, collapseSet: collapseSet)
        case .unknown:
            Logger.warn("Discarding item: \(itemModel.messageCellType).")
            return nil
        }

        let cellMeasurement = buildCellMeasurement(
            rootComponent: rootComponent,
            conversationStyle: conversationStyle,
        )

        return CVRenderItem(
            itemModel: itemModel,
            rootComponent: rootComponent,
            cellMeasurement: cellMeasurement,
        )
    }

    private static func buildEmptyCellMeasurement() -> CVCellMeasurement {
        CVCellMeasurement.Builder().build()
    }

    private static func buildCellMeasurement(
        rootComponent: CVRootComponent,
        conversationStyle: ConversationStyle,
    ) -> CVCellMeasurement {
        let measurementBuilder = CVCellMeasurement.Builder()
        measurementBuilder.cellSize = rootComponent.measure(
            maxWidth: conversationStyle.viewWidth,
            measurementBuilder: measurementBuilder,
        )
        let cellMeasurement = measurementBuilder.build()
        owsAssertDebug(cellMeasurement.cellSize.width <= conversationStyle.viewWidth)
        return cellMeasurement
    }
}
