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

import SignalUI
import SignalServiceKit
import SignalRingRTC

// MARK: - CallCellDelegate

private protocol CallCellDelegate: AnyObject {
    func joinCall(from viewModel: CallsListViewController.CallViewModel)
    func returnToCall(from viewModel: CallsListViewController.CallViewModel)
    func showCallInfo(from viewModel: CallsListViewController.CallViewModel)
}

// MARK: - CallsListViewController

class CallsListViewController: OWSViewController, HomeTabViewController, CallServiceStateObserver {
    private typealias DiffableDataSource = UITableViewDiffableDataSource<Section, RowIdentifier>
    private typealias Snapshot = NSDiffableDataSourceSnapshot<Section, RowIdentifier>

    private enum Constants {
        /// The maximum number of search results to match.
        static let maxSearchResults: Int = 100

        /// An interval to wait after the search term changes before actually
        /// issuing a search.
        static let searchDebounceInterval: TimeInterval = 0.1
    }

    // MARK: - Dependencies

    private struct Dependencies {
        let badgeManager: BadgeManager
        let blockingManager: BlockingManager
        let callRecordDeleteAllJobQueue: CallRecordDeleteAllJobQueue
        let callRecordMissedCallManager: CallRecordMissedCallManager
        let callRecordQuerier: CallRecordQuerier
        let callRecordStore: CallRecordStore
        let callService: CallService
        let contactsManager: any ContactManager
        let db: SDSDatabaseStorage
        let interactionDeleteManager: InteractionDeleteManager
        let interactionStore: InteractionStore
        let searchableNameFinder: SearchableNameFinder
        let threadStore: ThreadStore
        let tsAccountManager: any TSAccountManager
    }

    private lazy var deps: Dependencies = Dependencies(
        badgeManager: AppEnvironment.shared.badgeManager,
        blockingManager: SSKEnvironment.shared.blockingManagerRef,
        callRecordDeleteAllJobQueue: SSKEnvironment.shared.callRecordDeleteAllJobQueueRef,
        callRecordMissedCallManager: DependenciesBridge.shared.callRecordMissedCallManager,
        callRecordQuerier: DependenciesBridge.shared.callRecordQuerier,
        callRecordStore: DependenciesBridge.shared.callRecordStore,
        callService: AppEnvironment.shared.callService,
        contactsManager: NSObject.contactsManager,
        db: NSObject.databaseStorage,
        interactionDeleteManager: DependenciesBridge.shared.interactionDeleteManager,
        interactionStore: DependenciesBridge.shared.interactionStore,
        searchableNameFinder: SearchableNameFinder(
            contactManager: NSObject.contactsManager,
            searchableNameIndexer: DependenciesBridge.shared.searchableNameIndexer,
            phoneNumberVisibilityFetcher: DependenciesBridge.shared.phoneNumberVisibilityFetcher,
            recipientDatabaseTable: DependenciesBridge.shared.recipientDatabaseTable
        ),
        threadStore: DependenciesBridge.shared.threadStore,
        tsAccountManager: DependenciesBridge.shared.tsAccountManager
    )

    // MARK: - Lifecycle

    private var logger: PrefixedLogger = PrefixedLogger(prefix: "[CallsListVC]")

    private lazy var emptyStateMessageView: UILabel = {
        let label = UILabel()
        label.numberOfLines = 0
        label.textAlignment = .center
        return label
    }()

    private lazy var noSearchResultsView: UILabel = {
        let label = UILabel()
        label.numberOfLines = 0
        label.textAlignment = .center
        label.font = .dynamicTypeBody
        return label
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

        navigationItem.titleView = filterPicker
        updateBarButtonItems()

        view.addSubview(tableView)
        tableView.autoPinEdgesToSuperviewEdges()
        tableView.delegate = self
        tableView.allowsSelectionDuringEditing = true
        tableView.allowsMultipleSelectionDuringEditing = true
        tableView.separatorStyle = .none
        tableView.contentInset = .zero
        // [CallLink] TODO: Add the icon & formatting to the table view row.
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: Self.createCallLinkReuseIdentifier)
        tableView.register(CallCell.self, forCellReuseIdentifier: Self.callCellReuseIdentifier)
        tableView.dataSource = dataSource

        view.addSubview(emptyStateMessageView)
        emptyStateMessageView.autoCenterInSuperview()

        view.addSubview(noSearchResultsView)
        noSearchResultsView.autoPinWidthToSuperviewMargins()
        noSearchResultsView.autoPinEdge(toSuperviewMargin: .top, withInset: 80)

        applyTheme()
        attachSelfAsObservers()

        loadCallRecordsAnew(animated: false)
    }

    override func viewWillAppear(_ animated: Bool) {
        updateDisplayedDateForAllCallCells()
        clearMissedCallsIfNecessary()
    }

    override func themeDidChange() {
        super.themeDidChange()
        applyTheme()
        reloadAllRows()
    }

    private func updateBarButtonItems() {
        if tableView.isEditing {
            navigationItem.leftBarButtonItem = cancelMultiselectButton()
            navigationItem.rightBarButtonItem = deleteAllCallsButton()
        } else {
            navigationItem.leftBarButtonItem = profileBarButtonItem()
            navigationItem.rightBarButtonItem = newCallButton()
        }
    }

    private func applyTheme() {
        view.backgroundColor = Theme.backdropColor
        tableView.backgroundColor = Theme.backgroundColor
    }

    // MARK: Profile button

    private func profileBarButtonItem() -> UIBarButtonItem {
        createSettingsBarButtonItem(
            databaseStorage: databaseStorage,
            actions: { settingsAction in
                [
                    .init(
                        title: Strings.selectCallsButtonTitle,
                        image: Theme.iconImage(.contextMenuSelect),
                        attributes: []
                    ) { [weak self] _ in
                        self?.startMultiselect()
                    },
                    settingsAction,
                ]
            },
            showAppSettings: { [weak self] in
                self?.showAppSettings()
            }
        )
    }

    private func showAppSettings() {
        AssertIsOnMainThread()

        conversationSplitViewController?.selectedConversationViewController?.dismissMessageContextMenu(animated: true)
        presentFormSheet(AppSettingsViewController.inModalNavigationController(), animated: true)
    }

    private func startMultiselect() {
        Logger.debug("Select calls")
        // Swipe actions count as edit mode, so cancel those
        // before entering multiselection editing mode.
        tableView.setEditing(false, animated: true)
        tableView.setEditing(true, animated: true)
        updateBarButtonItems()
        showToolbar()
    }

    private var multiselectToolbarContainer: BlurredToolbarContainer?
    private var multiselectToolbar: UIToolbar? {
        multiselectToolbarContainer?.toolbar
    }

    private lazy var toolbarDeleteButton = UIBarButtonItem(
        title: CommonStrings.deleteButton,
        style: .plain,
        target: self,
        action: #selector(deleteSelectedCalls)
    )

    private func showToolbar() {
        guard
            // Don't create a new toolbar if we already have one
            multiselectToolbarContainer == nil,
            let tabController = tabBarController as? HomeTabBarController
        else { return }

        let toolbarContainer = BlurredToolbarContainer()
        toolbarContainer.alpha = 0
        view.addSubview(toolbarContainer)
        toolbarContainer.autoPinWidthToSuperview()
        toolbarContainer.autoPinEdge(toSuperviewEdge: .bottom)
        self.multiselectToolbarContainer = toolbarContainer

        let bottomInset = tabController.tabBar.height - tabController.tabBar.safeAreaInsets.bottom
        self.tableView.contentInset.bottom = bottomInset
        self.tableView.verticalScrollIndicatorInsets.bottom = bottomInset

        tabController.setTabBarHidden(true, animated: true, duration: 0.1) { [weak self] _ in
            guard let self else { return }
            // See ChatListViewController.showToolbar for why this is async
            DispatchQueue.main.async {
                self.multiselectToolbar?.setItems(
                    [.flexibleSpace(), self.toolbarDeleteButton],
                    animated: false
                )
                self.updateMultiselectToolbarButtons()
            }
            UIView.animate(withDuration: 0.25) {
                toolbarContainer.alpha = 1
            }
        }
    }

    private func updateMultiselectToolbarButtons() {
        let selectedRows = tableView.indexPathsForSelectedRows ?? []
        let hasSelectedEntries = !selectedRows.isEmpty
        toolbarDeleteButton.isEnabled = hasSelectedEntries
    }

    @objc
    private func deleteSelectedCalls() {
        guard let selectedRows = tableView.indexPathsForSelectedRows else {
            return
        }

        let selectedViewModelReferences: [CallViewModel.Reference] = selectedRows.map { idxPath in
            viewModelLoader.loadedViewModelReferences[index: idxPath.row]
        }

        deleteCalls(viewModelReferences: selectedViewModelReferences)
    }

    // MARK: Call Link Button

    private func createCallLink() {
        CreateCallLinkViewController().createCallLinkOnServerAndPresent(fromViewController: self)
    }

    // MARK: New call button

    private func newCallButton() -> UIBarButtonItem {
        let barButtonItem = UIBarButtonItem(
            image: Theme.iconImage(.buttonNewCall),
            style: .plain,
            target: self,
            action: #selector(newCall)
        )
        barButtonItem.accessibilityLabel = OWSLocalizedString(
            "NEW_CALL_LABEL",
            comment: "Accessibility label for the new call button on the Calls Tab"
        )
        barButtonItem.accessibilityHint = OWSLocalizedString(
            "NEW_CALL_HINT",
            comment: "Accessibility hint describing the action of the new call button on the Calls Tab"
        )
        return barButtonItem
    }

    @objc
    private func newCall() {
        let viewController = NewCallViewController()
        viewController.delegate = self
        let modal = OWSNavigationController(rootViewController: viewController)
        self.navigationController?.presentFormSheet(modal, animated: true)
    }

    // MARK: Cancel multiselect button

    private func cancelMultiselectButton() -> UIBarButtonItem {
        .cancelButton { [weak self] in
            self?.cancelMultiselect()
        }
    }

    private func cancelMultiselect() {
        tableView.setEditing(false, animated: true)
        updateBarButtonItems()
        hideToolbar()
    }

    private func hideToolbar() {
        guard let multiselectToolbarContainer else { return }
        UIView.animate(withDuration: 0.25) {
            multiselectToolbarContainer.alpha = 0
        } completion: { _ in
            multiselectToolbarContainer.removeFromSuperview()
            self.multiselectToolbarContainer = nil
            guard let tabController = self.tabBarController as? HomeTabBarController else { return }
            tabController.setTabBarHidden(false, animated: true, duration: 0.1) { _ in
                self.tableView.contentInset.bottom = 0
                self.tableView.verticalScrollIndicatorInsets.bottom = 0
            }
        }
    }

    // MARK: Delete All button

    private func deleteAllCallsButton() -> UIBarButtonItem {
        return UIBarButtonItem(
            title: Strings.deleteAllCallsButtonTitle,
            style: .plain,
            target: self,
            action: #selector(promptAboutDeletingAllCalls)
        )
    }

    @objc
    private func promptAboutDeletingAllCalls() {
        OWSActionSheets.showConfirmationAlert(
            title: Strings.deleteAllCallsPromptTitle,
            message: Strings.deleteAllCallsPromptMessage,
            proceedTitle: Strings.deleteAllCallsButtonTitle,
            proceedStyle: .destructive
        ) { _ in
            /// Delete-all should use the timestamp of the most-recent call, at
            /// the time the action was initiated, as the timestamp we delete
            /// before (and include in the outgoing sync message).
            ///
            /// If we don't have a most-recent call there's no point in
            /// doing a delete anyway.
            ///
            /// We also want to be sure we get the absolute most-recent call,
            /// rather than the most recent call matching our UI state – if the
            /// user does delete-all while filtering to Missed, we still want to
            /// actually delete all.
            self.deps.db.asyncWrite { tx in
                guard
                    let mostRecentCallRecord = try? self.deps.callRecordQuerier.fetchCursor(
                        ordering: .descending, tx: tx.asV2Read
                    )?.next()
                else { return }

                /// This will ultimately post "call records deleted"
                /// notifications that this view is listening to, so we don't
                /// need to do any manual UI updates.
                self.deps.callRecordDeleteAllJobQueue.addJob(
                    sendDeleteAllSyncMessage: true,
                    deleteAllBefore: .callRecord(mostRecentCallRecord),
                    tx: tx
                )
            }
        }
    }

    // MARK: Tab picker

    private enum FilterMode: Int {
        case all = 0
        case missed = 1
    }

    private lazy var filterPicker: UISegmentedControl = {
        let segmentedControl = UISegmentedControl(items: [
            Strings.filterPickerOptionAll,
            Strings.filterPickerOptionMissed
        ])
        segmentedControl.selectedSegmentIndex = 0
        segmentedControl.addTarget(self, action: #selector(filterChanged), for: .valueChanged)
        return segmentedControl
    }()

    // MARK: Search bar

    /// Sets the navigation item's search controller if it hasn't already been
    /// set. Call this after loading the table the first time so that the search
    /// bar is collapsed by default.
    func setSearchControllerIfNeeded() {
        guard navigationItem.searchController == nil else { return }
        let searchController = UISearchController(searchResultsController: nil)
        navigationItem.searchController = searchController
        searchController.searchResultsUpdater = self
    }

    @objc
    private func filterChanged() {
        loadCallRecordsAnew(animated: true)
        updateMultiselectToolbarButtons()
    }

    private var currentFilterMode: FilterMode {
        FilterMode(rawValue: filterPicker.selectedSegmentIndex) ?? .all
    }

    // MARK: - Observers and Notifications

    private func attachSelfAsObservers() {
        deps.db.appendDatabaseChangeDelegate(self)

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(significantTimeChangeOccurred),
            name: UIApplication.significantTimeChangeNotification,
            object: nil
        )

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(groupCallInteractionWasUpdated),
            name: GroupCallInteractionUpdatedNotification.name,
            object: nil
        )

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(receivedCallRecordStoreNotification),
            name: CallRecordStoreNotification.name,
            object: nil
        )

        // No need to sync state since we're still setting up the view.
        deps.callService.callServiceState.addObserver(self, syncStateImmediately: false)
    }

    /// A significant time change has occurred, according to the system. We
    /// should update the displayed date for all visible calls.
    @objc
    private func significantTimeChangeOccurred() {
        updateDisplayedDateForAllCallCells()
    }

    private func updateDisplayedDateForAllCallCells() {
        for callCell in tableView.visibleCells.compactMap({ $0 as? CallCell }) {
            callCell.updateDisplayedDateAndScheduleRefresh()
        }
    }

    /// When a group call interaction changes, we'll reload the row for the call
    /// it represents (if that row is loaded) so as to reflect the latest state
    /// for that group call.
    ///
    /// Recall that we track "is a group call ongoing" as a property on the
    /// interaction representing that group call, so we need this so we reload
    /// when the call ends.
    ///
    /// Note also that the ``didUpdateCall(from:to:)`` hook below is hit during
    /// the group-call-join process but before we have actually joined the call,
    /// due to the asynchronous nature of group calls. Consequently, we also
    /// need this hook to reload when we ourselves have joined the call, as us
    /// joining updates the "joined members" property also tracked on the group
    /// call interaction.
    @objc
    private func groupCallInteractionWasUpdated(_ notification: NSNotification) {
        guard let notification = GroupCallInteractionUpdatedNotification(notification) else {
            owsFail("Unexpectedly failed to instantiate group call interaction updated notification!")
        }

        if DebugFlags.internalLogging {
            logger.info("Group call interaction was updated, reloading.")
        }

        let callRecordIdForGroupCall = CallRecord.ID(
            callId: notification.callId,
            threadRowId: notification.groupThreadRowId
        )

        reloadRows(callRecordIds: [callRecordIdForGroupCall])
    }

    @objc
    private func receivedCallRecordStoreNotification(_ notification: NSNotification) {
        guard let callRecordStoreNotification = CallRecordStoreNotification(notification) else {
            owsFail("Unexpected notification! \(type(of: notification))")
        }

        switch callRecordStoreNotification.updateType {
        case .inserted:
            newCallRecordWasInserted()
        case .deleted(let callRecordIds):
            existingCallRecordsWereDeleted(callRecordIds: callRecordIds)
        case .statusUpdated(let callRecordId):
            callRecordStatusWasUpdated(callRecordId: callRecordId)
        }
    }

    /// When a call record is inserted, we'll try loading newer records.
    ///
    /// The 99% case for a call record being inserted is that a new call was
    /// started – which is to say, the inserted call record is the most recent
    /// call. For this case, by loading newer calls we'll load that new call and
    /// present it at the top.
    ///
    /// It is possible that we'll have a call inserted into the middle of our
    /// existing calls, for example if we receive a delayed sync message about a
    /// call from a while ago that we somehow never learned about on this
    /// device. If that happens, we won't load and live-update with that call –
    /// instead, we'll see it the next time this view is reloaded.
    private func newCallRecordWasInserted() {
        /// Only attempt to load newer calls if the top row is visible. If not,
        /// we'll load newer calls when the user scrolls up anyway.
        let shouldLoadNewerCalls: Bool = {
            guard
                let visibleIndexPaths = tableView.indexPathsForVisibleRows,
                !visibleIndexPaths.isEmpty
            else {
                return true
            }

            return visibleIndexPaths.contains(.indexPathForPrimarySection(row: 0))
        }()

        if shouldLoadNewerCalls {
            loadMoreCalls(direction: .newer, animated: true)
        }
    }

    private func existingCallRecordsWereDeleted(callRecordIds: [CallRecord.ID]) {
        deps.db.read { tx in
            viewModelLoader.dropCalls(
                matching: callRecordIds, tx: tx.asV2Read
            )
        }

        updateSnapshot(animated: true)
    }

    /// When the status of a call record changes, we'll reload the row it
    /// represents (if that row is loaded) so as to reflect the latest state for
    /// that record.
    ///
    /// For example, imagine a ringing call that is declined on this device and
    /// accepted on another device. The other device will tell us it accepted
    /// via a sync message, and we should update this view to reflect the
    /// accepted call.
    private func callRecordStatusWasUpdated(callRecordId: CallRecord.ID) {
        reloadRows(callRecordIds: [callRecordId])
    }

    // MARK: CallServiceStateObserver

    /// When we learn that this device has joined or left a call, we'll reload
    /// any rows related to that call so that we show the latest state in this
    /// view.
    ///
    /// Recall that any 1:1 call we are not actively joined to has ended, and
    /// that that is not the case for group calls.
    func didUpdateCall(from oldValue: SignalCall?, to newValue: SignalCall?) {
        let callRecordIdsToReload = [oldValue, newValue].compactMap { call -> CallRecord.ID? in
            return call?.callRecordId
        }

        reloadRows(callRecordIds: callRecordIdsToReload)
    }

    // MARK: - Clear missed calls

    /// A serial queue for clearing the missed-call badge.
    private let clearMissedCallQueue = DispatchQueue(label: "org.signal.calls-list-clear-missed")

    /// Asynchronously clears any missed-call badges, avoiding write
    /// transactions if possible.
    ///
    /// - Important
    /// The asynchronous work enqueued by this method is executed serially, such
    /// that multiple calls to this method will not race.
    private func clearMissedCallsIfNecessary() {
        clearMissedCallQueue.async {
            let unreadMissedCallCount = self.deps.db.read { tx in
                self.deps.callRecordMissedCallManager.countUnreadMissedCalls(
                    tx: tx.asV2Read
                )
            }

            /// We expect that the only unread calls to mark as read will be
            /// missed calls, so if there's no unread missed calls no need to
            /// open a write transaction.
            guard unreadMissedCallCount > 0 else { return }

            self.deps.db.write { tx in
                self.deps.callRecordMissedCallManager.markUnreadCallsAsRead(
                    beforeTimestamp: nil,
                    sendSyncMessage: true,
                    tx: tx.asV2Write
                )
            }
        }
    }

    // MARK: - Call loading

    private var _viewModelLoader: ViewModelLoader!
    private var viewModelLoader: ViewModelLoader! {
        get {
            AssertIsOnMainThread()
            return _viewModelLoader
        }
        set(newValue) {
            AssertIsOnMainThread()
            _viewModelLoader = newValue
        }
    }

    /// Used to avoid concurrent calls to ``loadCallRecordsAnew(animated:)``
    /// from clobbering each other.
    private var loadCallRecordsAnewCounter = 0

    /// Asynchronously resets our current ``LoadedCalls`` for the current UI
    /// state, then kicks off an initial page load.
    ///
    /// - Note
    /// This method will perform an FTS search for our current search term, if
    /// we have one. That operation can be painfully slow for users with a large
    /// FTS index, so we need to do it asynchronously.
    private func loadCallRecordsAnew(animated: Bool) {
        let searchTerm = self.searchTerm
        let onlyLoadMissedCalls: Bool = {
            switch self.currentFilterMode {
            case .all: return false
            case .missed: return true
            }
        }()

        self.loadCallRecordsAnewCounter += 1
        let loadCallRecordsAnewCounterSnapshot = self.loadCallRecordsAnewCounter

        deps.db.asyncRead(
            block: { tx -> CallRecordLoaderImpl.Configuration in
                if let searchTerm {
                    guard let localIdentifiers = self.deps.tsAccountManager.localIdentifiers(tx: tx.asV2Read) else {
                        owsFail("Can't search if you've never been registered.")
                    }
                    var threadRowIdsMatchingSearchTerm = Set<Int64>()
                    let addresses = self.deps.searchableNameFinder.searchNames(
                        for: searchTerm,
                        maxResults: Constants.maxSearchResults,
                        localIdentifiers: localIdentifiers,
                        tx: tx.asV2Read,
                        checkCancellation: {},
                        addGroupThread: { groupThread in
                            guard let sqliteRowId = groupThread.sqliteRowId else {
                                owsFail("How did we match a thread in the FTS index that hasn't been inserted?")
                            }
                            threadRowIdsMatchingSearchTerm.insert(sqliteRowId)
                        },
                        addStoryThread: { _ in }
                    )

                    for address in addresses {
                        guard
                            let contactThread = TSContactThread.getWithContactAddress(address, transaction: tx),
                            contactThread.shouldThreadBeVisible
                        else {
                            continue
                        }
                        guard let sqliteRowId = contactThread.sqliteRowId else {
                            owsFail("How did we match a thread in the FTS index that hasn't been inserted?")
                        }
                        threadRowIdsMatchingSearchTerm.insert(sqliteRowId)
                    }

                    return CallRecordLoaderImpl.Configuration(
                        onlyLoadMissedCalls: onlyLoadMissedCalls,
                        onlyMatchThreadRowIds: Array(threadRowIdsMatchingSearchTerm)
                    )
                } else {
                    return CallRecordLoaderImpl.Configuration(
                        onlyLoadMissedCalls: onlyLoadMissedCalls,
                        onlyMatchThreadRowIds: nil
                    )
                }
            },
            completionQueue: .main,
            completion: { configuration in
                guard self.loadCallRecordsAnewCounter == loadCallRecordsAnewCounterSnapshot else {
                    /// While we were building the configuration, another caller
                    /// entered this method. Bail out in preference of the later
                    /// caller!
                    return
                }

                // Build a loader for this view's current state.
                let callRecordLoader = CallRecordLoaderImpl(
                    callRecordQuerier: self.deps.callRecordQuerier,
                    configuration: configuration
                )

                /// We don't want to capture self in the blocks we pass to the
                /// view model loader (and thereby create a retain cycle), so
                /// instead we'll early-capture just the dependencies those
                /// blocks actually need and give them a copy.
                let capturedDeps = self.deps

                // Reset our loaded calls.
                self.viewModelLoader = ViewModelLoader(
                    callRecordLoader: callRecordLoader,
                    createCallViewModelBlock: { primaryCallRecord, coalescedCallRecords, tx in
                        return Self.createCallViewModel(
                            primaryCallRecord: primaryCallRecord,
                            coalescedCallRecords: coalescedCallRecords,
                            deps: capturedDeps,
                            tx: SDSDB.shimOnlyBridge(tx)
                        )
                    },
                    fetchCallRecordBlock: { callRecordId, tx -> CallRecord? in
                        return capturedDeps.callRecordStore.fetch(
                            callRecordId: callRecordId,
                            tx: SDSDB.shimOnlyBridge(tx)
                        ).unwrapped
                    }
                )

                // Load the initial page of records. We've thrown away all our
                // existing calls, so we want to always update the snapshot.
                self.loadMoreCalls(
                    direction: .older,
                    animated: animated,
                    forceUpdateSnapshot: true
                )
            }
        )
    }

    /// Load more calls as necessary given that a row for the given index path
    /// is soon going to be presented.
    ///
    /// - Returns
    /// The call view model for this index path. A return value of `nil`
    /// represents an unexpected error.
    private func loadMoreCallsIfNecessary(
        indexToBeDisplayed callIndex: Int
    ) -> CallViewModel? {
        if callIndex == viewModelLoader.loadedViewModelReferences.count - 1 {
            /// If this index path represents the oldest loaded call, try and
            /// load another page of even-older calls.
            loadMoreCalls(direction: .older, animated: false)
        } else if !viewModelLoader.hasCachedViewModel(
            loadedViewModelReferenceIndex: callIndex
        ) {
            /// If we don't have a view model ready to go for this row, load
            /// until we do.
            ///
            /// This is probably because we're scrolling through so many calls
            /// we can't keep them all cached and we've evicted the view model
            /// for this row, but we've scrolled back to it and need to re-load
            /// the view model.
            ///
            /// Note that the table may request view models for totally disjoint
            /// rows. For example, imagine we're scrolled down a couple hundred
            /// calls and the user triggers the "scroll-to-top" gesture. The
            /// table is gonna scroll super-fast to the top and request models
            /// for rows right at the top, which have long been paged out. Along
            /// the way, it may request view models for sporadic rows as it
            /// passes over them.
            ///
            /// All of that to say: we need support for "page in view models for
            /// an arbitrary index path", which is what this does.
            deps.db.read { tx in
                viewModelLoader.loadUntilCached(
                    loadedViewModelReferenceIndex: callIndex, tx: tx.asV2Read
                )
            }
        }

        return viewModelLoader.getCachedViewModel(
            loadedViewModelReferenceIndex: callIndex
        )
    }

    /// Synchronously loads more calls, then asynchronously update the snapshot
    /// if any new calls were actually loaded.
    ///
    /// - Parameter forceUpdateSnapshot
    /// Whether we should always update the snapshot, regardless of if any new
    /// calls were loaded.
    private func loadMoreCalls(
        direction loadDirection: ViewModelLoader.LoadDirection,
        animated: Bool,
        forceUpdateSnapshot: Bool = false
    ) {
        let shouldUpdateSnapshot = deps.db.read { tx in
            return viewModelLoader.loadMore(
                direction: loadDirection, tx: tx.asV2Read
            )
        }

        guard forceUpdateSnapshot || shouldUpdateSnapshot else { return }

        DispatchQueue.main.async {
            self.updateSnapshot(animated: animated)
            // Add the search bar after loading table content the first time so
            // that it is collapsed by default.
            self.setSearchControllerIfNeeded()
        }
    }

    /// Converts ``CallRecord``s into a ``CallViewModel``.
    ///
    /// - Important
    /// The primary and and coalesced call records *must* all have the same
    /// thread, direction, missed status, and call type.
    private static func createCallViewModel(
        primaryCallRecord: CallRecord,
        coalescedCallRecords: [CallRecord],
        deps: Dependencies,
        tx: SDSAnyReadTransaction
    ) -> CallViewModel {
        owsAssert(
            coalescedCallRecords.allSatisfy { $0.threadRowId == primaryCallRecord.threadRowId },
            "Coalesced call records were for a different thread than the primary!"
        )
        owsAssert(
            coalescedCallRecords.allSatisfy { $0.callDirection == primaryCallRecord.callDirection },
            "Coalesced call records were of a different direction than the primary!"
        )
        owsAssert(
            coalescedCallRecords.allSatisfy { $0.callStatus.isMissedCall == primaryCallRecord.callStatus.isMissedCall },
            "Coalesced call records were of a different missed status than the primary!"
        )
        owsAssert(
            ([primaryCallRecord] + coalescedCallRecords).isSortedByTimestamp(.descending),
            "Primary and coalesced call records were not ordered descending by timestamp!"
        )

        guard let callThread = deps.threadStore.fetchThread(
            rowId: primaryCallRecord.threadRowId,
            tx: tx.asV2Read
        ) else {
            owsFail("Missing thread for call record! This should be impossible, per the DB schema.")
        }

        let callDirection: CallViewModel.Direction = {
            if primaryCallRecord.callStatus.isMissedCall {
                return .missed
            }

            switch primaryCallRecord.callDirection {
            case .incoming: return .incoming
            case .outgoing: return .outgoing
            }
        }()

        /// The call state may be different between the primary and the
        /// coalesced calls. For the view model's state, we use the primary.
        let callState: CallViewModel.State = {
            let currentCallId: UInt64? = deps.callService.callServiceState.currentCall?.callId

            switch primaryCallRecord.callStatus {
            case .individual:
                if primaryCallRecord.callId == currentCallId {
                    // We can have at most one 1:1 call active at a time, and if
                    // we have an active 1:1 call we must be in it. All other
                    // 1:1 calls must have ended.
                    return .participating
                }
            case .group:
                guard let groupCallInteraction: OWSGroupCallMessage = deps.interactionStore
                    .fetchAssociatedInteraction(
                        callRecord: primaryCallRecord, tx: tx.asV2Read
                    )
                else {
                    owsFail("Missing interaction for group call. This should be impossible per the DB schema!")
                }

                // We learn that a group call ended by peeking the group. During
                // that peek, we update the group call interaction. It's a
                // leetle wonky that we use the interaction to store that info,
                // but such is life.
                if !groupCallInteraction.hasEnded {
                    if primaryCallRecord.callId == currentCallId {
                        return .participating
                    }

                    return .active
                }
            }

            return .ended
        }()

        if let contactThread = callThread as? TSContactThread {
            let callType: CallViewModel.RecipientType.CallType = {
                switch primaryCallRecord.callType {
                case .audioCall:
                    return .audio
                case .groupCall:
                    owsFailDebug("Had group call type for 1:1 call!")
                    fallthrough
                case .videoCall:
                    return .video
                }
            }()

            return CallViewModel(
                primaryCallRecord: primaryCallRecord,
                coalescedCallRecords: coalescedCallRecords,
                title: deps.contactsManager.displayName(
                    for: contactThread.contactAddress, tx: tx
                ).resolvedValue(),
                recipientType: .individual(type: callType, contactThread: contactThread),
                direction: callDirection,
                state: callState
            )
        } else if let groupThread = callThread as? TSGroupThread {
            return CallViewModel(
                primaryCallRecord: primaryCallRecord,
                coalescedCallRecords: coalescedCallRecords,
                title: groupThread.groupModel.groupNameOrDefault,
                recipientType: .group(groupThread: groupThread),
                direction: callDirection,
                state: callState
            )
        } else {
            owsFail("Call thread was neither contact nor group! This should be impossible.")
        }
    }

    // MARK: - Search term

    /// - Important
    /// Don't use this directly – use ``searchTerm``.
    private var _searchTerm: String? {
        didSet {
            guard oldValue != searchTerm else {
                // If the term hasn't changed, don't do anything.
                return
            }

            searchTermDidChange()
        }
    }

    /// The user's current search term. Coalesces empty strings into `nil`.
    private var searchTerm: String? {
        get { _searchTerm }
        set { _searchTerm = newValue?.nilIfEmpty }
    }

    private var searchTermCounter = 0

    private func searchTermDidChange() {
        self.searchTermCounter += 1
        let searchTermCounterSnapshot = self.searchTermCounter

        DispatchQueue.main.asyncAfter(deadline: .now() + Constants.searchDebounceInterval) {
            guard self.searchTermCounter == searchTermCounterSnapshot else {
                /// The search term changed in the debounce period, so we'll
                /// bail out here in preference for the later-changed search
                /// term.
                return
            }

            self.loadCallRecordsAnew(animated: true)
        }
    }

    // MARK: - Table view

    fileprivate enum Section: Int, Hashable {
        case createCallLink
        case existingCalls
    }

    fileprivate enum RowIdentifier: Hashable {
        case createCallLink
        case callViewModelReference(CallViewModel.Reference)
    }

    struct CallViewModel {
        enum Reference: Hashable {
            case singleCall(CallRecord.ID)
            case coalescedCalls(
                primary: CallRecord.ID,
                coalesced: [CallRecord.ID]
            )

            var primaryId: CallRecord.ID {
                switch self {
                case .singleCall(let callRecordId): return callRecordId
                case .coalescedCalls(let primary, _): return primary
                }
            }
        }

        enum Direction {
            case outgoing
            case incoming
            case missed

            var label: String {
                switch self {
                case .outgoing:
                    return Strings.callDirectionLabelOutgoing
                case .incoming:
                    return Strings.callDirectionLabelIncoming
                case .missed:
                    return Strings.callDirectionLabelMissed
                }
            }
        }

        enum State {
            /// This call is active, but the user is not in it.
            case active
            /// The user is currently in this call.
            case participating
            /// The call is no longer active.
            case ended
        }

        enum RecipientType {
            case individual(type: CallType, contactThread: TSContactThread)
            case group(groupThread: TSGroupThread)

            enum CallType: Hashable {
                case audio
                case video
            }
        }

        let primaryCallRecord: CallRecord
        let coalescedCallRecords: [CallRecord]

        var allCallRecords: [CallRecord] { [primaryCallRecord] + coalescedCallRecords }

        var reference: Reference {
            if coalescedCallRecords.isEmpty {
                return .singleCall(primaryCallRecord.id)
            }

            return .coalescedCalls(
                primary: primaryCallRecord.id,
                coalesced: coalescedCallRecords.map { $0.id }
            )
        }

        let title: String
        let recipientType: RecipientType
        let direction: Direction
        let state: State

        var callId: UInt64 { primaryCallRecord.callId }
        var threadRowId: Int64 { primaryCallRecord.threadRowId }
        var callBeganDate: Date { primaryCallRecord.callBeganDate }

        /// The `callBeganTimestamp` of the oldest call contained herein.
        var oldestContainedTimestamp: UInt64 {
            if let lastCoalescedTimestamp = coalescedCallRecords.last?.callBeganTimestamp {
                return lastCoalescedTimestamp
            }

            return primaryCallRecord.callBeganTimestamp
        }

        /// The `callBeganTimestamp` of the newest call contained herein.
        var newestContainedTimestamp: UInt64 {
            return primaryCallRecord.callBeganTimestamp
        }

        init(
            primaryCallRecord: CallRecord,
            coalescedCallRecords: [CallRecord],
            title: String,
            recipientType: RecipientType,
            direction: Direction,
            state: State
        ) {
            self.primaryCallRecord = primaryCallRecord
            self.coalescedCallRecords = coalescedCallRecords
            self.title = title
            self.recipientType = recipientType
            self.direction = direction
            self.state = state
        }

        var callType: RecipientType.CallType {
            switch recipientType {
            case let .individual(callType, _):
                return callType
            case .group(_):
                return .video
            }
        }

        /// The `TSThread` for the call. If a `TSContactThread` or
        /// `TSGroupThread` is needed, switch on `recipientType`
        /// instead of typecasting this property.
        var thread: TSThread {
            switch recipientType {
            case let .individual(_, contactThread):
                return contactThread
            case let .group(groupThread):
                return groupThread
            }
        }

        var isMissed: Bool {
            switch direction {
            case .outgoing, .incoming:
                return false
            case .missed:
                return true
            }
        }
    }

    let tableView = UITableView(frame: .zero, style: .plain)

    private static let createCallLinkReuseIdentifier = "createCallLink"
    private static let callCellReuseIdentifier = "callCell"

    private lazy var dataSource = DiffableDataSource(
        tableView: tableView
    ) { [weak self] tableView, indexPath, _ -> UITableViewCell? in
        return self?.buildTableViewCell(tableView: tableView, indexPath: indexPath) ?? UITableViewCell()
    }

    private func buildTableViewCell(tableView: UITableView, indexPath: IndexPath) -> UITableViewCell? {
        switch Section(rawValue: indexPath.section) {
        case .createCallLink:
            let createCallLinkCell = tableView.dequeueReusableCell(withIdentifier: Self.createCallLinkReuseIdentifier, for: indexPath)
            // [CallLink] TODO: Localize.
            createCallLinkCell.textLabel?.text = "Create a Call Link"
            return createCallLinkCell
        case .existingCalls:
            guard
                let callCell = tableView.dequeueReusableCell(
                    withIdentifier: Self.callCellReuseIdentifier
                ) as? CallCell
            else {
                return nil
            }
            /// This load should be sufficiently fast that doing it here
            /// synchronously is fine.
            if let viewModelForIndexPath = self.loadMoreCallsIfNecessary(
                indexToBeDisplayed: indexPath.row
            ) {
                callCell.delegate = self
                callCell.viewModel = viewModelForIndexPath

                return callCell
            } else {
                owsFailDebug("Missing cached view model – how did this happen?")
                /// Return an empty table cell, rather than a ``CallCell`` that's
                /// gonna be incorrectly configured.
            }
        case .none:
            break
        }
        return nil
    }

    private func getSnapshot() -> Snapshot {
        var snapshot = Snapshot()
        snapshot.appendSections([.createCallLink])
        if FeatureFlags.callLinkCreate {
            snapshot.appendItems([.createCallLink])
        }
        snapshot.appendSections([.existingCalls])
        snapshot.appendItems(viewModelLoader.loadedViewModelReferences.allElements.map { .callViewModelReference($0) })
        return snapshot
    }

    private func updateSnapshot(animated: Bool) {
        dataSource.apply(getSnapshot(), animatingDifferences: animated)
        updateEmptyStateMessage()
        cancelMultiselectIfEmpty()
    }

    /// Reload any rows containing one of the given call record IDs.
    private func reloadRows(callRecordIds callRecordIdsToReload: [CallRecord.ID]) {
        /// Recreate the view models, so when the data source reloads the rows
        /// it'll reflect the new underlying state for that row.
        ///
        /// This step will also drop any IDs for models that are not currently
        /// loaded, which should not be included in the snapshot.
        let referencesToReload = deps.db.read { tx -> [CallViewModel.Reference] in
            return viewModelLoader.refreshViewModels(
                callRecordIds: callRecordIdsToReload, tx: tx.asV2Read
            )
        }

        if DebugFlags.internalLogging {
            logger.info("Reloading \(referencesToReload.count) rows.")
        }

        var snapshot = getSnapshot()
        snapshot.reloadItems(referencesToReload.map { .callViewModelReference($0) })
        dataSource.apply(snapshot)
    }

    private func reloadAllRows() {
        var snapshot = getSnapshot()
        snapshot.reloadSections([.existingCalls])
        dataSource.apply(snapshot)
    }

    private func cancelMultiselectIfEmpty() {
        if
            tableView.isEditing,
            viewModelLoader.loadedViewModelReferences.isEmpty
        {
            cancelMultiselect()
        }
    }

    private func updateEmptyStateMessage() {
        switch (viewModelLoader.loadedViewModelReferences.count, searchTerm) {
        case (0, .some(let searchTerm)) where !searchTerm.isEmpty:
            noSearchResultsView.text = String(
                format: Strings.searchNoResultsFoundLabelFormat,
                arguments: [searchTerm]
            )
            noSearchResultsView.layer.opacity = 1
            emptyStateMessageView.layer.opacity = 0
        case (0, _):
            emptyStateMessageView.attributedText = NSAttributedString.composed(of: {
                switch currentFilterMode {
                case .all:
                    return [
                        Strings.noRecentCallsLabel,
                        "\n",
                        Strings.noRecentCallsSuggestionLabel
                            .styled(with: .font(.dynamicTypeSubheadline)),
                    ]
                case .missed:
                    return [
                        Strings.noMissedCallsLabel
                    ]
                }
            }())
            .styled(
                with: .font(.dynamicTypeSubheadline.semibold())
            )
            noSearchResultsView.layer.opacity = 0
            emptyStateMessageView.layer.opacity = 1
        case (_, _):
            // Hide empty state message
            noSearchResultsView.layer.opacity = 0
            emptyStateMessageView.layer.opacity = 0
        }
    }
}

private extension IndexPath {
    static func indexPathForPrimarySection(row: Int) -> IndexPath {
        return IndexPath(
            row: row,
            section: CallsListViewController.Section.existingCalls.rawValue
        )
    }
}

private extension SignalCall {
    var callId: UInt64? {
        switch mode {
        case .individual(let individualCall):
            return individualCall.callId
        case .groupThread(let call as GroupCall), .callLink(let call as GroupCall):
            return call.ringRtcCall.peekInfo?.eraId.map { callIdFromEra($0) }
        }
    }

    var callRecordId: CallRecord.ID? {
        guard let callId else { return nil }
        return CallRecord.ID(callId: callId, threadRowId: threadRowId)
    }

    private var threadRowId: Int64 {
        switch mode {
        case .individual(let call):
            return call.thread.sqliteRowId!
        case .groupThread(let call):
            return call.groupThread.sqliteRowId!
        case .callLink:
            owsFail("[CallLink] TODO: Can't fetch threadRowId for a CallLink call")
        }
    }
}

private extension CallRecordStore {
    func fetch(
        callRecordId: CallRecord.ID,
        tx: SDSAnyReadTransaction
    ) -> CallRecordStore.MaybeDeletedFetchResult {
        return fetch(
            callId: callRecordId.callId,
            threadRowId: callRecordId.threadRowId,
            tx: tx.asV2Read
        )
    }
}

// MARK: UITableViewDelegate

extension CallsListViewController: UITableViewDelegate {

    private func viewModel(
        forIndexPathThatShouldHaveOne indexPath: IndexPath
    ) -> CallViewModel? {
        owsAssert(
            indexPath.section == Section.existingCalls.rawValue,
            "Unexpected section for index path: \(indexPath.section)"
        )

        guard let viewModel = viewModelLoader.getCachedViewModel(
            loadedViewModelReferenceIndex: indexPath.row
        ) else {
            owsFailBeta("Missing view model for index path. How did this happen?")
            return nil
        }

        return viewModel
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        if tableView.isEditing {
            updateMultiselectToolbarButtons()
            return
        }

        tableView.deselectRow(at: indexPath, animated: true)

        switch Section(rawValue: indexPath.section) {
        case .createCallLink:
            createCallLink()
        case .existingCalls, .none:
            guard let viewModel = viewModel(forIndexPathThatShouldHaveOne: indexPath) else {
                return owsFailDebug("Missing view model")
            }
            callBack(from: viewModel)
        }
    }

    func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
        if tableView.isEditing {
            updateMultiselectToolbarButtons()
        }
    }

    func tableView(_ tableView: UITableView, shouldBeginMultipleSelectionInteractionAt indexPath: IndexPath) -> Bool {
        switch Section(rawValue: indexPath.section) {
        case .createCallLink:
            return false
        case .existingCalls, .none:
            return true
        }
    }

    func tableView(_ tableView: UITableView, didBeginMultipleSelectionInteractionAt indexPath: IndexPath) {
        updateBarButtonItems()
        showToolbar()
    }

    func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
        switch Section(rawValue: indexPath.section) {
        case .createCallLink:
            return nil
        case .existingCalls, .none:
            break
        }

        return self.longPressActions(forRowAt: indexPath)
            .map { actions in UIMenu.init(children: actions) }
            .map { menu in
                UIContextMenuConfiguration(identifier: indexPath as NSCopying, previewProvider: nil) { _ in menu }
            }
    }

    func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
        switch Section(rawValue: indexPath.section) {
        case .createCallLink:
            return nil
        case .existingCalls, .none:
            break
        }

        guard let viewModel = viewModel(forIndexPathThatShouldHaveOne: indexPath) else {
            owsFailDebug("Missing call view model")
            return nil
        }

        let goToChatAction = makeContextualAction(
            style: .normal,
            color: .ows_accentBlue,
            image: "arrow-square-upright-fill",
            title: Strings.goToChatActionTitle
        ) { [weak self] in
            self?.goToChat(from: viewModel)
        }

        return .init(actions: [goToChatAction])
    }

    func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
        switch Section(rawValue: indexPath.section) {
        case .createCallLink:
            return nil
        case .existingCalls, .none:
            break
        }

        guard let viewModel = viewModel(forIndexPathThatShouldHaveOne: indexPath) else {
            owsFailDebug("Missing call view model")
            return nil
        }

        let deleteAction = makeContextualAction(
            style: .destructive,
            color: .ows_accentRed,
            image: "trash-fill",
            title: CommonStrings.deleteButton
        ) { [weak self] in
            self?.deleteCalls(viewModelReferences: [viewModel.reference])
        }

        return .init(actions: [deleteAction])
    }

    private func makeContextualAction(
        style: UIContextualAction.Style,
        color: UIColor,
        image: String,
        title: String,
        action: @escaping () -> Void
    ) -> UIContextualAction {
        let action = UIContextualAction(
            style: style,
            title: nil
        ) { _, _, completion in
            action()
            completion(true)
        }
        action.backgroundColor = color
        action.image = UIImage(named: image)?.withTitle(
            title,
            font: .dynamicTypeFootnote.medium(),
            color: .ows_white,
            maxTitleWidth: 68,
            minimumScaleFactor: CGFloat(8) / CGFloat(13),
            spacing: 4
        )?.withRenderingMode(.alwaysTemplate)

        return action
    }

    private func longPressActions(forRowAt indexPath: IndexPath) -> [UIAction]? {
        guard let viewModel = viewModel(forIndexPathThatShouldHaveOne: indexPath) else {
            owsFailDebug("Missing call view model")
            return nil
        }

        var actions = [UIAction]()

        switch viewModel.state {
        case .active:
            let joinCallTitle: String
            let joinCallIconName: String
            switch viewModel.callType {
            case .audio:
                joinCallTitle = Strings.joinVoiceCallActionTitle
                joinCallIconName = Theme.iconName(.contextMenuVoiceCall)
            case .video:
                joinCallTitle = Strings.joinVideoCallActionTitle
                joinCallIconName = Theme.iconName(.contextMenuVideoCall)
            }
            let joinCallAction = UIAction(
                title: joinCallTitle,
                image: UIImage(named: joinCallIconName),
                attributes: []
            ) { [weak self] _ in
                self?.joinCall(from: viewModel)
            }
            actions.append(joinCallAction)
        case .participating:
            let returnToCallIconName: String
            switch viewModel.callType {
            case .audio:
                returnToCallIconName = Theme.iconName(.contextMenuVoiceCall)
            case .video:
                returnToCallIconName = Theme.iconName(.contextMenuVideoCall)
            }
            let returnToCallAction = UIAction(
                title: Strings.returnToCallActionTitle,
                image: UIImage(named: returnToCallIconName),
                attributes: []
            ) { [weak self] _ in
                self?.returnToCall(from: viewModel)
            }
            actions.append(returnToCallAction)
        case .ended:
            switch viewModel.recipientType {
            case .individual:
                let audioCallAction = UIAction(
                    title: Strings.startVoiceCallActionTitle,
                    image: Theme.iconImage(.contextMenuVoiceCall),
                    attributes: []
                ) { [weak self] _ in
                    self?.startVoiceCall(from: viewModel)
                }
                actions.append(audioCallAction)
            case .group:
                break
            }

            let videoCallAction = UIAction(
                title: Strings.startVideoCallActionTitle,
                image: Theme.iconImage(.contextMenuVideoCall),
                attributes: []
            ) { [weak self] _ in
                self?.startVideoCall(from: viewModel)
            }
            actions.append(videoCallAction)
        }

        let goToChatAction = UIAction(
            title: Strings.goToChatActionTitle,
            image: Theme.iconImage(.contextMenuOpenInChat),
            attributes: []
        ) { [weak self] _ in
            self?.goToChat(from: viewModel)
        }
        actions.append(goToChatAction)

        let infoAction = UIAction(
            title: Strings.viewCallInfoActionTitle,
            image: Theme.iconImage(.contextMenuInfo),
            attributes: []
        ) { [weak self] _ in
            self?.showCallInfo(from: viewModel)
        }
        actions.append(infoAction)

        let selectAction = UIAction(
            title: Strings.selectCallActionTitle,
            image: Theme.iconImage(.contextMenuSelect),
            attributes: []
        ) { [weak self] _ in
            self?.selectCall(forRowAt: indexPath)
        }
        actions.append(selectAction)

        switch viewModel.state {
        case .active, .ended:
            let deleteAction = UIAction(
                title: Strings.deleteCallActionTitle,
                image: Theme.iconImage(.contextMenuDelete),
                attributes: .destructive
            ) { [weak self] _ in
                self?.deleteCalls(viewModelReferences: [viewModel.reference])
            }
            actions.append(deleteAction)
        case .participating:
            break
        }

        return actions
    }
}

// MARK: - Actions

extension CallsListViewController: CallCellDelegate, NewCallViewControllerDelegate {

    private func callBack(from viewModel: CallViewModel) {
        switch viewModel.callType {
        case .audio:
            startVoiceCall(from: viewModel)
        case .video:
            startVideoCall(from: viewModel)
        }
    }

    private var callStarterContext: CallStarter.Context {
        .init(
            blockingManager: deps.blockingManager,
            databaseStorage: deps.db,
            callService: deps.callService
        )
    }

    private func startVoiceCall(from viewModel: CallViewModel) {
        switch viewModel.recipientType {
        case let .individual(_, contactThread):
            CallStarter(
                contactThread: contactThread,
                withVideo: false,
                context: self.callStarterContext
            ).startCall(from: self)
        case .group:
            owsFail("Shouldn't be able to start voice call from group")
        }
    }

    private func startVideoCall(from viewModel: CallViewModel) {
        switch viewModel.recipientType {
        case let .individual(_, contactThread):
            CallStarter(
                contactThread: contactThread,
                withVideo: true,
                context: self.callStarterContext
            ).startCall(from: self)
        case let .group(groupThread):
            CallStarter(
                groupThread: groupThread,
                context: self.callStarterContext
            ).startCall(from: self)
        }
    }

    private func goToChat(from viewModel: CallViewModel) {
        goToChat(for: viewModel.thread)
    }

    private func deleteCalls(viewModelReferences: [CallViewModel.Reference]) {
        deps.db.asyncWrite { tx in
            let callRecordIdsToDelete: [CallRecord.ID] = viewModelReferences.flatMap { reference in
                return reference.containedIds
            }

            let callRecordsToDelete = callRecordIdsToDelete.compactMap { callRecordId -> CallRecord? in
                return self.deps.callRecordStore.fetch(
                    callRecordId: callRecordId, tx: tx
                ).unwrapped
            }

            /// Deleting these call records will trigger a ``CallRecordStoreNotification``,
            /// which we're listening for in this view and will in turn lead us
            /// to update the UI as appropriate.
            self.deps.interactionDeleteManager.delete(
                alongsideAssociatedCallRecords: callRecordsToDelete,
                sideEffects: .default(),
                tx: tx.asV2Write
            )
        }
    }

    private func selectCall(forRowAt indexPath: IndexPath) {
        startMultiselect()
        tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none)
    }

    // MARK: CallCellDelegate

    fileprivate func joinCall(from viewModel: CallViewModel) {
        guard case let .group(groupThread) = viewModel.recipientType else {
            owsFailBeta("Individual call should not be showing a join button")
            return
        }
        CallStarter(
            groupThread: groupThread,
            context: self.callStarterContext
        ).startCall(from: self)
    }

    fileprivate func returnToCall(from viewModel: CallViewModel) {
        guard WindowManager.shared.hasCall else { return }
        WindowManager.shared.returnToCallView()
    }

    fileprivate func showCallInfo(from viewModel: CallViewModel) {
        AssertIsOnMainThread()

        let (threadViewModel, isSystemContact) = deps.db.read { tx in
            let thread = viewModel.thread
            let threadViewModel = ThreadViewModel(
                thread: thread,
                forChatList: false,
                transaction: tx
            )
            let isSystemContact = thread.isSystemContact(
                contactsManager: deps.contactsManager,
                tx: tx
            )
            return (threadViewModel, isSystemContact)
        }

        let callDetailsView = ConversationSettingsViewController(
            threadViewModel: threadViewModel,
            isSystemContact: isSystemContact,
            // Nothing would have been revealed, so this can be a fresh instance
            spoilerState: SpoilerRenderState(),
            callViewModel: viewModel
        )

        callDetailsView.hidesBottomBarWhenPushed = true
        navigationController?.pushViewController(callDetailsView, animated: true)
    }

    // MARK: NewCallViewControllerDelegate

    func goToChat(for thread: TSThread) {
        SignalApp.shared.presentConversationForThread(thread, action: .compose, animated: false)
    }
}

// MARK: UISearchResultsUpdating

extension CallsListViewController: UISearchResultsUpdating {
    func updateSearchResults(for searchController: UISearchController) {
        self.searchTerm = searchController.searchBar.text
    }
}

// MARK: - DatabaseChangeDelegate

extension CallsListViewController: DatabaseChangeDelegate {
    /// If the database changed externally – which is to say, in the NSE – state
    /// that this view relies on may have changed. We can't know if it'll have
    /// affected us, so we'll simply load calls fresh and make the table view
    /// reload all the cells.
    func databaseChangesDidUpdateExternally() {
        logger.info("Database changed externally, loading calls anew and reloading all rows.")

        loadCallRecordsAnew(animated: false)
        reloadAllRows()
    }

    func databaseChangesDidUpdate(databaseChanges: DatabaseChanges) {}
    func databaseChangesDidReset() {}
}

// MARK: - Call cell

private extension CallsListViewController {
    class CallCell: UITableViewCell {
        private static var verticalMargin: CGFloat = 11
        private static var horizontalMargin: CGFloat = 20
        private static var joinButtonMargin: CGFloat = 18

        weak var delegate: CallCellDelegate?

        var viewModel: CallViewModel? {
            didSet {
                updateContents()
            }
        }

        // MARK: Subviews

        private lazy var avatarView = ConversationAvatarView(
            sizeClass: .thirtySix,
            localUserDisplayMode: .asUser
        )

        private lazy var titleLabel: UILabel = {
            let label = UILabel()
            label.font = .dynamicTypeHeadline
            return label
        }()

        private lazy var subtitleLabel = UILabel()

        private lazy var timestampLabel: UILabel = {
            let label = UILabel()
            label.font = .dynamicTypeBody2
            return label
        }()

        private lazy var detailsButton: OWSButton = {
            let button = OWSButton { [weak self] in
                self?.detailsTapped()
            }
            // The info icon is the button's own image and should be `horizontalMargin` from the edge
            button.contentEdgeInsets.trailing = Self.horizontalMargin
            button.contentEdgeInsets.leading = 8
            // The join button is a separate subview and should be `joinButtonMargin` from the edge
            button.layoutMargins.trailing = Self.joinButtonMargin
            return button
        }()

        private var joinPill: UIView?

        private func makeJoinPill() -> UIView? {
            guard let viewModel else { return nil }

            let icon: UIImage
            switch viewModel.callType {
            case .audio:
                icon = Theme.iconImage(.phoneFill16)
            case .video:
                icon = Theme.iconImage(.videoFill16)
            }

            let text: String
            switch viewModel.state {
            case .active:
                text = Strings.joinCallButtonTitle
            case .participating:
                text = Strings.returnToCallButtonTitle
            case .ended:
                return nil
            }

            let button = OWSRoundedButton()
            let font = UIFont.dynamicTypeBody2.bold()
            let title = NSAttributedString.composed(of: [
                NSAttributedString.with(
                    image: icon,
                    font: .dynamicTypeCallout,
                    centerVerticallyRelativeTo: font,
                    heightReference: .pointSize
                ),
                " ",
                text,
            ]).styled(
                with: .font(font),
                .color(.ows_white)
            )
            button.setAttributedTitle(title, for: .normal)
            button.backgroundColor = .ows_accentGreen
            button.contentEdgeInsets = .init(hMargin: 12, vMargin: 4)
            button.setCompressionResistanceHigh()
            button.isUserInteractionEnabled = false
            return button
        }

        // MARK: Init

        override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
            super.init(style: style, reuseIdentifier: reuseIdentifier)

            let bodyVStack = UIStackView(arrangedSubviews: [
                titleLabel,
                subtitleLabel,
            ])
            bodyVStack.axis = .vertical

            let leadingHStack = UIStackView(arrangedSubviews: [
                avatarView,
                bodyVStack,
            ])
            leadingHStack.alignment = .center
            leadingHStack.axis = .horizontal
            leadingHStack.spacing = 12

            let trailingHStack = UIStackView(arrangedSubviews: [
                timestampLabel,
                detailsButton,
            ])
            trailingHStack.axis = .horizontal
            trailingHStack.spacing = 0

            let outerHStack = UIStackView(arrangedSubviews: [
                leadingHStack,
                UIView(),
                trailingHStack,
            ])
            outerHStack.axis = .horizontal
            outerHStack.spacing = 4

            // The details button should take up the entire trailing space,
            // top to bottom, so the content should have zero margins.
            contentView.preservesSuperviewLayoutMargins = false
            contentView.layoutMargins = .zero

            leadingHStack.preservesSuperviewLayoutMargins = false
            leadingHStack.isLayoutMarginsRelativeArrangement = true
            leadingHStack.layoutMargins = .init(
                top: Self.verticalMargin,
                leading: Self.horizontalMargin,
                bottom: Self.verticalMargin,
                trailing: 0
            )

            contentView.addSubview(outerHStack)
            outerHStack.autoPinEdgesToSuperviewMargins()

            tintColor = .ows_accentBlue
        }

        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }

        deinit {
            timestampDisplayRefreshTimer?.invalidate()
        }

        // MARK: Dynamically-refreshing timestamp

        /// A timer tracking the next time this cell should refresh its
        /// displayed timestamp.
        private var timestampDisplayRefreshTimer: Timer?

        /// Immediately update the display timestamp for this cell, and schedule
        /// an automatic refresh of the display timestamp as appropriate.
        func updateDisplayedDateAndScheduleRefresh() {
            AssertIsOnMainThread()

            timestampDisplayRefreshTimer?.invalidate()
            timestampDisplayRefreshTimer = nil

            guard let viewModel else { return }

            let date: Date? = {
                switch viewModel.state {
                case .active, .participating:
                    /// Don't show a date for active calls.
                    return nil
                case .ended:
                    return viewModel.callBeganDate
                }
            }()

            guard let date else {
                timestampLabel.text = nil
                return
            }

            let (formattedDate, nextRefreshDate) = DateUtil.formatDynamicDateShort(date)

            timestampLabel.text = formattedDate

            if let nextRefreshDate {
                timestampDisplayRefreshTimer = .scheduledTimer(
                    withTimeInterval: max(1, nextRefreshDate.timeIntervalSinceNow),
                    repeats: false
                ) { [weak self] _ in
                    guard let self else { return }
                    self.updateDisplayedDateAndScheduleRefresh()
                }
            }
        }

        // MARK: Updates

        private func updateContents() {
            applyTheme()

            guard let viewModel else {
                return owsFailDebug("Missing view model")
            }

            avatarView.updateWithSneakyTransactionIfNecessary { configuration in
                configuration.dataSource = .thread(viewModel.thread)
            }

            let titleText: String = {
                if viewModel.coalescedCallRecords.isEmpty {
                    return viewModel.title
                }

                return String(
                    format: Strings.coalescedCallsTitleFormat,
                    viewModel.title,
                    "\(viewModel.allCallRecords.count)"
                )
            }()
            self.titleLabel.text = titleText

            switch viewModel.direction {
            case .incoming, .outgoing:
                titleLabel.textColor = Theme.primaryTextColor
            case .missed:
                titleLabel.textColor = .ows_accentRed
            }

            self.subtitleLabel.attributedText = {
                let icon: ThemeIcon
                switch viewModel.callType {
                case .audio:
                    icon = .phone16
                case .video:
                    icon = .video16
                }

                return .composed(of: [
                    NSAttributedString.with(
                        image: Theme.iconImage(icon),
                        font: .dynamicTypeCallout,
                        centerVerticallyRelativeTo: .dynamicTypeBody2,
                        heightReference: .pointSize
                    ),
                    " ",
                    viewModel.direction.label,
                ]).styled(with: .font(.dynamicTypeBody2))
            }()

            self.joinPill?.removeFromSuperview()

            switch viewModel.state {
            case .active, .participating:
                // Join button
                detailsButton.setImage(imageName: nil)
                detailsButton.tintColor = .ows_white

                if let joinPill = makeJoinPill() {
                    self.joinPill = joinPill
                    detailsButton.addSubview(joinPill)
                    joinPill.autoVCenterInSuperview()
                    joinPill.autoPinWidthToSuperviewMargins()
                }
            case .ended:
                // Info button
                detailsButton.setImage(imageName: "info")
                detailsButton.tintColor = Theme.primaryIconColor
            }

            updateDisplayedDateAndScheduleRefresh()
        }

        private func applyTheme() {
            backgroundColor = Theme.backgroundColor
            selectedBackgroundView?.backgroundColor = Theme.tableCell2SelectedBackgroundColor
            multipleSelectionBackgroundView?.backgroundColor = Theme.tableCell2MultiSelectedBackgroundColor

            titleLabel.textColor = Theme.primaryTextColor
            subtitleLabel.textColor = Theme.snippetColor
            timestampLabel.textColor = Theme.snippetColor
        }

        // MARK: Actions

        private func detailsTapped() {
            guard let viewModel else {
                return owsFailDebug("Missing view model")
            }

            guard let delegate else {
                return owsFailDebug("Missing delegate")
            }

            switch viewModel.state {
            case .active:
                delegate.joinCall(from: viewModel)
            case .participating:
                delegate.returnToCall(from: viewModel)
            case .ended:
                delegate.showCallInfo(from: viewModel)
            }
        }
    }
}
