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

import SignalServiceKit

extension CallsListViewController {
    /// Responsible for loading ``CallViewModel``s from disk and persisting them
    /// in-memory.
    ///
    /// This type will maintain an ordered list of references to every
    /// ``CallViewModel`` it has ever loaded, as well as a bounded in-memory
    /// cache of full view models. It exposes methods for paging forwards and
    /// backwards in time, which results in updates to the currently-cached view
    /// models as well as additions to the loaded view model references as
    /// appropriate.
    ///
    /// Callers should inspect ``loadedViewModelReferences`` for a comprehensive
    /// list of all view models this instance is aware of, and use
    /// ``getCachedViewModel(loadedViewModelReferenceIndex:)`` to request a full
    /// view model for the reference at the given index in
    /// ``loadedViewModelReferences``.
    ///
    /// Note that in the event of a "cache miss" when requesting a full view
    /// model from this type, it is the callers responsibility to instruct this
    /// type to load models to refill the cache such that the next request
    /// results in a cache hit.
    struct ViewModelLoader {
        typealias CreateCallViewModelBlock = (
            _ primaryCallRecord: CallRecord,
            _ coalescedCallRecords: [CallRecord],
            _ tx: DBReadTransaction
        ) -> CallViewModel

        typealias FetchCallRecordBlock = (
            _ callRecordId: CallRecord.ID,
            _ tx: DBReadTransaction
        ) -> CallRecord?

        enum LoadDirection {
            case older
            case newer
        }

        private var callRecordLoader: CallRecordLoader
        private let createCallViewModelBlock: CreateCallViewModelBlock
        private let fetchCallRecordBlock: FetchCallRecordBlock
        private let viewModelPageSize: UInt
        private let maxCachedViewModelCount: Int
        private let maxCoalescedCallsInOneViewModel: UInt

        init(
            callRecordLoader: CallRecordLoader,
            createCallViewModelBlock: @escaping CreateCallViewModelBlock,
            fetchCallRecordBlock: @escaping FetchCallRecordBlock,
            viewModelPageSize: UInt = 50,
            maxCachedViewModelCount: Int = 150,
            maxCoalescedCallsInOneViewModel: UInt = 50
        ) {
            owsAssert(
                maxCachedViewModelCount >= viewModelPageSize,
                "Must be able to cache at least one page of view models!"
            )

            self.callRecordLoader = callRecordLoader
            self.createCallViewModelBlock = createCallViewModelBlock
            self.fetchCallRecordBlock = fetchCallRecordBlock
            self.viewModelPageSize = viewModelPageSize
            self.maxCachedViewModelCount = maxCachedViewModelCount
            self.maxCoalescedCallsInOneViewModel = maxCoalescedCallsInOneViewModel

            self.loadedViewModelReferences = IdentifierIndexedArray(elements: [])
            self.cachedViewModels = IdentifierIndexedArray(elements: [])
        }

        // MARK: - Loaded view model references

        /// All the view model references we've loaded thus far.
        private(set) var loadedViewModelReferences: IdentifierIndexedArray<CallViewModel.Reference>

        /// Returns a view model reference matching the given one, with any of
        /// the given call record IDs removed. If the resulting reference would
        /// contain no call record IDs, `nil` is returned.
        private func dropCallRecordIds(
            _ idsToDrop: Set<CallRecord.ID>,
            fromViewModelReference viewModelReference: CallViewModel.Reference
        ) -> CallViewModel.Reference? {
            var containedCallRecordIds: [CallRecord.ID] = viewModelReference.containedIds

            let countBefore = containedCallRecordIds.count
            containedCallRecordIds.removeAll { idsToDrop.contains($0) }

            guard countBefore != containedCallRecordIds.count else {
                // Nothing was dropped, keep the same view model reference.
                return viewModelReference
            }

            if containedCallRecordIds.isEmpty {
                return nil
            } else if containedCallRecordIds.count == 1 {
                return .singleCall(containedCallRecordIds.first!)
            } else {
                return .coalescedCalls(
                    primary: containedCallRecordIds.first!,
                    coalesced: Array(containedCallRecordIds.dropFirst())
                )
            }
        }

        // MARK: - Cached view models

        /// All the hydrated view models we currently have loaded.
        private var cachedViewModels: IdentifierIndexedArray<CallViewModel>

        /// Returns the cached view model corresponding to the view model
        /// reference at the given index into ``loadedViewModelReferences``, if
        /// one is cached.
        ///
        /// - SeeAlso ``loadUntilCached(loadedViewModelReferenceIndex:tx:)``
        func getCachedViewModel(loadedViewModelReferenceIndex index: Int) -> CallViewModel? {
            guard let viewModelReference = loadedViewModelReferences[safe: index] else {
                return nil
            }

            return getCachedViewModel(reference: viewModelReference)
        }

        /// Returns whether a view model is cached corresponding to the view
        /// model reference at the given index into
        /// ``loadedViewModelReferences``.
        func hasCachedViewModel(loadedViewModelReferenceIndex index: Int) -> Bool {
            return getCachedViewModel(loadedViewModelReferenceIndex: index) != nil
        }

        private func getCachedViewModel(reference: CallViewModel.Reference) -> CallViewModel? {
            return cachedViewModels[id: reference.primaryId]
        }

        /// Merges the given view models into the view model cache in the given
        /// direction, dropping models from the other end of the cache if
        /// necessary.
        private mutating func mergeIntoCachedViewModels(
            newViewModels: [CallViewModel],
            direction: LoadDirection
        ) {
            if newViewModels.isEmpty { return }

            let mergedCachedViewModels: [CallViewModel] = {
                let combinedViewModels: [CallViewModel] = {
                    switch direction {
                    case .older: return cachedViewModels.allElements + newViewModels
                    case .newer: return newViewModels + cachedViewModels.allElements
                    }
                }()

                if combinedViewModels.count <= maxCachedViewModelCount {
                    return combinedViewModels
                } else {
                    switch direction {
                    case .older: return Array(combinedViewModels.suffix(maxCachedViewModelCount))
                    case .newer: return Array(combinedViewModels.prefix(maxCachedViewModelCount))
                    }
                }
            }()

            cachedViewModels = IdentifierIndexedArray(elements: mergedCachedViewModels)
        }

        /// Returns a view model reference matching the given one, with any
        /// should-be-dropped call record IDs removed. If the resulting
        /// reference contains no call record IDs, `nil` is returned.
        private func dropCallRecordIds(
            _ idsToDrop: Set<CallRecord.ID>,
            fromViewModel viewModel: CallViewModel,
            tx: DBReadTransaction
        ) -> CallViewModel? {
            var containedCallRecords = viewModel.allCallRecords

            let countBefore = containedCallRecords.count
            containedCallRecords.removeAll { idsToDrop.contains($0.id) }

            guard countBefore != containedCallRecords.count else {
                // Nothing was dropped, keep the same view model.
                return viewModel
            }

            if containedCallRecords.isEmpty {
                return nil
            } else {
                return createCallViewModelBlock(
                    containedCallRecords.first!,
                    Array(containedCallRecords.dropFirst()),
                    tx
                )
            }
        }

        // MARK: - Load more

        /// Repeatedly loads pages of view models until a cached view model is
        /// available for the given loaded view model reference index.
        ///
        /// This method is safe to call for any valid loaded view model
        /// reference index.
        ///
        /// - Note
        /// This may result in multiple synchronous page loads if necessary. For
        /// example, if view models are cached for rows in range `(500, 600)`
        /// and this method is called for row 10, all the rows between row 500
        /// and row 10 will be loaded.
        ///
        /// This behavior should be fine in practice, since loading a page is a
        /// fast operation.
        mutating func loadUntilCached(
            loadedViewModelReferenceIndex index: Int,
            tx: DBReadTransaction
        ) {
            guard
                !hasCachedViewModel(loadedViewModelReferenceIndex: index),
                !cachedViewModels.isEmpty
            else { return }

            guard let cachedViewModelReferenceIndices = cachedViewModelReferenceIndices() else {
                owsFail("Missing cached view model indices, but we checked above for empty cached view models!")
            }

            let loadDirection: LoadDirection = {
                if index > cachedViewModelReferenceIndices.last {
                    return .older
                } else if index < cachedViewModelReferenceIndices.first {
                    return .newer
                }

                owsFail("Row index is in the cached range, but somehow we didn't have a cached model. How did that happen?")
            }()

            while true {
                if hasCachedViewModel(loadedViewModelReferenceIndex: index) {
                    break
                }

                _ = loadMore(direction: loadDirection, tx: tx)
            }
        }

        /// Load a page of calls in the requested direction.
        ///
        /// This method may load brand new view models; it may page view models
        /// into this loader's cache for view model references it had loaded in
        /// the past; or it may do a combination of both.
        ///
        /// - Returns
        /// Whether changes were made to ``loadedViewModelReferences`` as a
        /// result of this load.
        mutating func loadMore(
            direction loadDirection: LoadDirection,
            tx: DBReadTransaction
        ) -> Bool {
            var viewModelsToLoadCount = viewModelPageSize

            let rehydratedCount = rehydrateLoadedViewModelReferencesIntoCache(
                maxCount: viewModelsToLoadCount,
                direction: loadDirection,
                tx: tx
            )

            owsAssert(rehydratedCount <= viewModelsToLoadCount)
            viewModelsToLoadCount -= rehydratedCount

            if viewModelsToLoadCount == 0 {
                /// We've loaded all the calls we need to, simply by rehydrating
                /// already-loaded view model references.
                return false
            }

            switch (loadDirection, cachedViewModels.count) {
            case (.older, _), (.newer, 0):
                /// If we're asked to load newer calls, but we don't have any
                /// calls loaded yet, do an "older" load since they're gonna be
                /// equivalent.

                let brandNewViewModels = loadBrandNewViewModelsOlder(
                    maxCount: viewModelsToLoadCount, tx: tx
                )

                if brandNewViewModels.isEmpty {
                    return false
                }

                loadedViewModelReferences.append(
                    newElements: brandNewViewModels.map { $0.reference }
                )
                mergeIntoCachedViewModels(
                    newViewModels: brandNewViewModels,
                    direction: .older
                )

                return true
            case (.newer, _):
                guard let newestCachedViewModel = cachedViewModels.first else {
                    owsFail("Non-zero cached view model count, but missing first view model!")
                }

                let loadResult = loadBrandNewViewModelsNewer(
                    maxCount: viewModelsToLoadCount,
                    currentNewestViewModel: newestCachedViewModel,
                    tx: tx
                )

                switch loadResult {
                case .nothingLoaded:
                    return false
                case .coalescedIntoCurrentNewest(let updatedNewestViewModel):
                    /// If we coalesced newly-loaded view models into the
                    /// existing most-recent call, replace that call
                    /// in-place and let callers know things changed.

                    loadedViewModelReferences.replace(
                        elementAtIndex: 0, with: updatedNewestViewModel.reference
                    )
                    cachedViewModels.replace(
                        elementAtIndex: 0, with: updatedNewestViewModel
                    )

                    return true
                case .loaded(let newViewModels):
                    owsAssert(newViewModels.count > 0)

                    loadedViewModelReferences.prepend(
                        newElements: newViewModels.map { $0.reference }
                    )
                    mergeIntoCachedViewModels(
                        newViewModels: newViewModels,
                        direction: .newer
                    )

                    return true
                }
            }
        }

        // MARK: - Rehydration

        /// "Rehydrate" already-loaded view model references that we know about
        /// in the direction we want to load, and add the rehydrated view models
        /// to our cache.
        ///
        /// Rehydration will begin with the next already-loaded view model
        /// reference without a corresponding view model in the cache, in the
        /// given direction. For example, if we've loaded view model references
        /// for rows `[0, 100]`, have cached view models for `[25, 75]`, the
        /// first rehydrated view model will be at index 24 or 76.
        ///
        /// - Parameter maxCount
        /// The max number of view models to rehydrate.
        /// - Returns
        /// The actual number of view models rehydrated. This may be lower than
        /// the max if while rehydrating we run out of already-loaded view model
        /// references in the given direction.
        private mutating func rehydrateLoadedViewModelReferencesIntoCache(
            maxCount: UInt,
            direction loadDirection: LoadDirection,
            tx: DBReadTransaction
        ) -> UInt {
            let cachedViewModelReferenceIndices: CachedViewModelReferenceIndices? = cachedViewModelReferenceIndices()

            /// Collect any view model references we've already loaded, in the
            /// given direction, for which we don't have a loaded view model.
            let loadedViewModelReferencesToHydrate: [CallViewModel.Reference] = {
                guard let cachedViewModelReferenceIndices else { return [] }

                var referencesToHydrate = [CallViewModel.Reference]()

                switch loadDirection {
                case .older:
                    var indexToHydrate = cachedViewModelReferenceIndices.last + 1
                    while
                        indexToHydrate < loadedViewModelReferences.count,
                        referencesToHydrate.count < maxCount
                    {
                        referencesToHydrate.append(loadedViewModelReferences[index: indexToHydrate])
                        indexToHydrate += 1
                    }
                case .newer:
                    var indexToHydrate = cachedViewModelReferenceIndices.first - 1
                    while
                        indexToHydrate >= 0,
                        referencesToHydrate.count < maxCount
                    {
                        referencesToHydrate.insert(loadedViewModelReferences[index: indexToHydrate], at: 0)
                        indexToHydrate -= 1
                    }
                }

                return referencesToHydrate
            }()

            let rehydratedViewModels = loadedViewModelReferencesToHydrate.map { viewModelReference in
                hydrate(viewModelReference: viewModelReference, tx: tx)
            }

            mergeIntoCachedViewModels(
                newViewModels: rehydratedViewModels,
                direction: loadDirection
            )

            return UInt(rehydratedViewModels.count)
        }

        /// Hydrates a full view model from the given reference.
        private func hydrate(
            viewModelReference: CallViewModel.Reference,
            tx: DBReadTransaction
        ) -> CallViewModel {
            switch viewModelReference {
            case .singleCall(let id):
                guard let primaryCallRecord = fetchCallRecordBlock(id, tx) else {
                    owsFail("Missing call record for single-call view model!")
                }

                return createCallViewModelBlock(primaryCallRecord, [], tx)
            case .coalescedCalls(let primaryId, let coalescedIds):
                guard let primaryCallRecord = fetchCallRecordBlock(primaryId, tx) else {
                    owsFail("Missing primary call record for coalesced-call view model!")
                }

                let coalescedCallRecords = coalescedIds.map { coalescedId -> CallRecord in
                    guard let coalescedCallRecord = fetchCallRecordBlock(coalescedId, tx) else {
                        owsFail("Missing coalesced call record for coalesced-call view model!")
                    }

                    return coalescedCallRecord
                }

                return createCallViewModelBlock(primaryCallRecord, coalescedCallRecords, tx)
            }
        }

        // MARK: - Brand new models, older

        /// Loads brand new view models older than the last cached view model.
        ///
        /// - Important
        /// This method must only be called if our current view model cache
        /// includes the oldest loaded view model reference. If this is not the
        /// case, callers should first rehydrate older loaded view model
        /// references until it is and then call this method.
        ///
        /// - Returns
        /// View models not yet loaded in this loader's lifetime. No more than
        /// `maxCount` view models will be returned, although note that due to
        /// coalescing this may correspond to more than `maxCount` call records
        /// having been loaded.
        private func loadBrandNewViewModelsOlder(
            maxCount: UInt,
            tx: DBReadTransaction
        ) -> [CallViewModel] {
            owsAssert(
                cachedViewModels.isEmpty || cachedViewModels.last!.reference == loadedViewModelReferences.last!,
                "Unexpectedly loading brand new view models, but last loaded view model reference does not have a cached view model!"
            )

            let oldestLoadedCallTimestamp: UInt64? = cachedViewModels.last.map { oldestViewModel in
                return oldestViewModel.oldestContainedTimestamp
            }

            let newCallRecordsCursor: CallRecordCursor = callRecordLoader.loadCallRecords(
                loadDirection: .olderThan(
                    oldestCallTimestamp: oldestLoadedCallTimestamp
                ),
                tx: tx
            )

            /// Collate groups of call records that will each correspond to a
            /// single view model, with the first call in the group being the
            /// primary and all subsequent calls being coalesced under the
            /// primary.
            var groupedCallRecordsForViewModels = [[CallRecord]]()
            var callRecordsForNextViewModel = [CallRecord]()
            while true {
                if groupedCallRecordsForViewModels.count == maxCount {
                    /// If we've reached the max view model count simply bail,
                    /// even if we started a grouping for another view model.
                    break
                }

                guard let nextCallRecord = try? newCallRecordsCursor.next() else {
                    /// We may have run out of call records while assembling a
                    /// group for the next view model. If so, we want to commit
                    /// the grouping as it is.
                    if !callRecordsForNextViewModel.isEmpty {
                        groupedCallRecordsForViewModels.append(callRecordsForNextViewModel)
                    }

                    break
                }

                if callRecordsForNextViewModel.isEmpty {
                    /// Start the first view model grouping.
                    callRecordsForNextViewModel.append(nextCallRecord)
                } else if
                    callRecordsForNextViewModel.first!.isValidCoalescingAnchor(for: nextCallRecord),
                    callRecordsForNextViewModel.count < maxCoalescedCallsInOneViewModel
                {
                    /// Build on the current new view model grouping.
                    callRecordsForNextViewModel.append(nextCallRecord)
                } else {
                    /// Finish the current view model grouping, and start the
                    /// next one.
                    groupedCallRecordsForViewModels.append(callRecordsForNextViewModel)
                    callRecordsForNextViewModel = [nextCallRecord]
                }
            }

            owsAssert(
                groupedCallRecordsForViewModels.allSatisfy { !$0.isEmpty },
                "Had an empty grouping for a view model. How did this happen?"
            )

            return groupedCallRecordsForViewModels.map { callRecords -> CallViewModel in
                return createCallViewModelBlock(
                    callRecords.first!,
                    Array(callRecords.dropFirst()),
                    tx
                )
            }
        }

        // MARK: - Brand new models, newer

        private enum LoadBrandNewViewModelsNewerResult {
            /// No new view models were loaded.
            case nothingLoaded

            /// New view models were loaded.
            /// - Note
            /// The contained `newViewModels` are ordered descending by
            /// their primary call record's timestamp.
            case loaded(newViewModels: [CallViewModel])

            /// New calls were loaded, but were coalesced into the existing
            /// newest (by timestamp) view model.
            case coalescedIntoCurrentNewest(updatedNewestViewModel: CallViewModel)
        }

        /// Loads brand new view models newer than the first cached view model.
        ///
        /// - Note
        /// Unlike when loading brand new older view models, we do not generally
        /// coalesce the newer calls loaded by this method.
        ///
        /// In general usage, this type expects to start empty and monotonically
        /// load older calls; finding a brand-new newer call implies the call
        /// was inserted after our initial load. Finding a single brand-new
        /// newer call is expected in the case where a new call starts, but it
        /// is unexpected to find multiple brand-new newer calls at once.
        ///
        /// Consequently, we will coalesce a single brand-new newer call into
        /// the given `currentNewestViewModel`, so as to support the "new call"
        /// scenario. However, if multiple brand-new newer calls are found, they
        /// will each be transformed into their own view models.
        ///
        /// Users are unlikely to encounter this limitation in practice, and the
        /// effect is simply that calls that should have coalesced are not until
        /// the next time we load-from-empty. In return, we are able to simplify
        /// this code as a result.
        ///
        /// - Important
        /// This method must only be called if our current view model cache
        /// includes the newest loaded view model reference. If this is not the
        /// case, callers should first rehydrate newer loaded view model
        /// references until it is and then call this method.
        ///
        /// - Parameter currentNewestViewModel
        /// The current newest view model from our cache. This must correspond
        /// to the newest loaded view model reference – see above.
        ///
        /// - Returns
        /// The result of loading newer calls.
        private func loadBrandNewViewModelsNewer(
            maxCount: UInt,
            currentNewestViewModel: CallViewModel,
            tx: DBReadTransaction
        ) -> LoadBrandNewViewModelsNewerResult {
            owsAssert(currentNewestViewModel.reference == loadedViewModelReferences.first)

            let newCallRecordsCursor: CallRecordCursor = callRecordLoader.loadCallRecords(
                loadDirection: .newerThan(
                    newestCallTimestamp: currentNewestViewModel.newestContainedTimestamp
                ),
                tx: tx
            )

            guard let pageOfNewCallRecordsAsc = try? newCallRecordsCursor.drain(
                maxResults: maxCount
            ) else {
                return .nothingLoaded
            }

            /// The call records we get back from our cursor will be ordered
            /// ascending, but we'll ultimately need them sorted descending to
            /// merge into `cachedViewModels` and `loadedViewModelReferences`.
            ///
            /// We'll reverse them here, and return them ordered descending, to
            /// make things easier on our callers.
            owsAssert(pageOfNewCallRecordsAsc.isSortedByTimestamp(.ascending))
            let pageOfNewCallRecords = pageOfNewCallRecordsAsc.reversed()

            if pageOfNewCallRecords.isEmpty {
                return .nothingLoaded
            } else if
                pageOfNewCallRecords.count == 1,
                let singleNewCallRecord = pageOfNewCallRecords.first,
                singleNewCallRecord.isValidCoalescingAnchor(for: currentNewestViewModel.primaryCallRecord)
            {
                let updatedNewestViewModel = createCallViewModelBlock(
                    singleNewCallRecord,
                    [currentNewestViewModel.primaryCallRecord] + currentNewestViewModel.coalescedCallRecords,
                    tx
                )

                return .coalescedIntoCurrentNewest(updatedNewestViewModel: updatedNewestViewModel)
            } else {
                let uncoalescedViewModels: [CallViewModel] = pageOfNewCallRecords
                    .map { callRecord in createCallViewModelBlock(callRecord, [], tx) }

                return .loaded(newViewModels: uncoalescedViewModels)
            }
        }

        // MARK: -

        /// Represents the bounding indices of the currently-cached view models
        /// within ``loadedViewModelReferences``.
        private struct CachedViewModelReferenceIndices {
            let first: Int
            let last: Int
        }

        /// Computes the bounding indices of the currently-cached view models
        /// within ``loadedViewModelReferences``.
        private func cachedViewModelReferenceIndices() -> CachedViewModelReferenceIndices? {
            func getRowIndex(cachedViewModel: CallViewModel?) -> Int? {
                return cachedViewModel.flatMap {
                    return loadedViewModelReferences.index(forId: $0.reference.primaryId)
                }
            }

            guard
                let firstCachedViewModelRowIndex = getRowIndex(cachedViewModel: cachedViewModels.first),
                let lastCachedViewModelRowIndex = getRowIndex(cachedViewModel: cachedViewModels.last)
            else {
                return nil
            }

            return CachedViewModelReferenceIndices(
                first: firstCachedViewModelRowIndex,
                last: lastCachedViewModelRowIndex
            )
        }

        // MARK: -

        /// Drops any calls with the given IDs from the loaded view model
        /// references and view model cache.
        ///
        /// - Important
        /// ``loadedViewModelReferences`` may have changed as a result of
        /// calling this method.
        mutating func dropCalls(
            matching callRecordIdsToDrop: [CallRecord.ID],
            tx: DBReadTransaction
        ) {
            let callRecordIdsToDrop = Set(callRecordIdsToDrop)

            let droppedViewModelReferences = loadedViewModelReferences.allElements
                .compactMap { viewModelReference in
                    return dropCallRecordIds(callRecordIdsToDrop, fromViewModelReference: viewModelReference)
                }

            let droppedCachedViewModels = cachedViewModels.allElements
                .compactMap { viewModel in
                    return dropCallRecordIds(callRecordIdsToDrop, fromViewModel: viewModel, tx: tx)
                }

            loadedViewModelReferences = IdentifierIndexedArray(elements: droppedViewModelReferences)
            cachedViewModels = IdentifierIndexedArray(elements: droppedCachedViewModels)
        }

        /// Refreshes cached view models containing any of the given IDs. If no
        /// cached view models contain a given ID, that ID is ignored.
        ///
        /// - Returns
        /// References for any view models that were refreshed. Note that this
        /// will not include any IDs that were ignored.
        mutating func refreshViewModels(
            callRecordIds callRecordIdsForWhichToRefreshViewModels: [CallRecord.ID],
            tx: DBReadTransaction
        ) -> [CallViewModel.Reference] {
            func refreshIfPossible(_ callRecord: CallRecord) -> CallRecord {
                return fetchCallRecordBlock(callRecord.id, tx) ?? callRecord
            }

            /// The given call record IDs may not have a cached view model, in
            /// which case there's nothing to refresh. Multiple call record IDs
            /// may also point to the same cached view model, which only needs
            /// to be refreshed once since we'll re-fetch all the call records
            /// when refreshing each view model anyway.
            let cachedViewModelIndicesToRefresh: Set<Int> = Set(
                callRecordIdsForWhichToRefreshViewModels.compactMap { callRecordId -> Int? in
                    return cachedViewModels.index(forId: callRecordId)
                }
            )

            var refreshedViewModelReferences = [CallViewModel.Reference]()
            for cachedViewModelIndex in cachedViewModelIndicesToRefresh {
                /// For each cached view model containing a call record we need
                /// to reload, we'll in fact reload all the call records
                /// associated with that view model. It's a tiny cost, and makes
                /// this code a lot simpler; the alternative being to dissect
                /// each view model to reload specific contained call records.
                let cachedViewModel = cachedViewModels[index: cachedViewModelIndex]

                let newPrimaryViewModel = refreshIfPossible(cachedViewModel.primaryCallRecord)
                let newCoalescedViewModels = cachedViewModel.coalescedCallRecords.map(refreshIfPossible(_:))
                let newCallViewModel = createCallViewModelBlock(
                    newPrimaryViewModel,
                    newCoalescedViewModels,
                    tx
                )

                /// We haven't changed any of the actual call record IDs in the
                /// view model, so we don't need to recompute indices.
                cachedViewModels.replace(
                    elementAtIndex: cachedViewModelIndex,
                    with: newCallViewModel
                )
                refreshedViewModelReferences.append(newCallViewModel.reference)
            }

            return refreshedViewModelReferences
        }
    }
}

// MARK: -

private extension CallRecord {
    private enum Constants {
        /// A time interval representing a window within which two call records
        /// can be coalesced together.
        static let coalescingTimeWindow: TimeInterval = 4 * kHourInterval
    }

    /// Whether the given call record can be coalesced under this call record.
    func isValidCoalescingAnchor(for otherCallRecord: CallRecord) -> Bool {
        guard
            threadRowId == otherCallRecord.threadRowId,
            callDirection == otherCallRecord.callDirection,
            callStatus.isMissedCall == otherCallRecord.callStatus.isMissedCall,
            callBeganDate.addingTimeInterval(-Constants.coalescingTimeWindow) < otherCallRecord.callBeganDate
        else { return false }

        return true
    }
}

// MARK: - ContainsIdentifiers

extension CallsListViewController.CallViewModel.Reference: ContainsIdentifiers {
    typealias ContainedIdType = CallRecord.ID

    var containedIds: [CallRecord.ID] {
        switch self {
        case .singleCall(let callRecordId): return [callRecordId]
        case .coalescedCalls(let primary, let coalesced): return [primary] + coalesced
        }
    }
}

extension CallsListViewController.CallViewModel: ContainsIdentifiers {
    typealias ContainedIdType = CallRecord.ID

    var containedIds: [CallRecord.ID] {
        return reference.containedIds
    }
}
