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

import Foundation
import MessageUI
import SignalServiceKit

public class RecipientPickerViewController: OWSViewController, OWSNavigationChildController {

    public enum SelectionMode {
        case `default`

        // The .blocklist selection mode changes the behavior in a few ways:
        //
        // - If numbers aren't registered, allow them to be chosen. You may want to
        //   block someone even if they aren't registered.
        //
        // - If numbers aren't registered, don't offer to invite them to Signal. If
        //   you want to block someone, you probably don't want to invite them.
        case blocklist
    }

    public enum GroupsToShow {
        case noGroups
        case groupsThatUserIsMemberOfWhenSearching
        case allGroupsWhenSearching
    }

    public weak var delegate: RecipientPickerDelegate? {
        didSet {
            recipientContextMenuHelper.delegate = delegate
        }
    }

    // MARK: Configuration

    public var allowsAddByAddress = true
    public var shouldHideLocalRecipient = true
    public var selectionMode = SelectionMode.default
    public var groupsToShow = GroupsToShow.groupsThatUserIsMemberOfWhenSearching
    public var shouldShowInvites = false
    public var shouldShowAlphabetSlider = true
    public var shouldShowNewGroup = false
    public var shouldUseAsyncSelection = false
    public var findByPhoneNumberButtonTitle: String?

    // MARK: Signal Connections

    private var signalConnections = [ComparableDisplayName]()
    private var signalConnectionAddresses = Set<SignalServiceAddress>()

    // MARK: Picker

    public var pickedRecipients: [PickedRecipient] = [] {
        didSet {
            updateTableContents()
        }
    }

    // MARK: UIViewController

    public override func viewDidLoad() {
        super.viewDidLoad()

        title = OWSLocalizedString("MESSAGE_COMPOSEVIEW_TITLE", comment: "")

        updateSignalConnections()
        contactsViewHelper.addObserver(self)

        // Stack View
        signalContactsStackView.isHidden = isNoContactsModeActive
        view.addSubview(signalContactsStackView)
        signalContactsStackView.autoPinEdgesToSuperviewEdges()

        // Search Bar
        signalContactsStackView.addArrangedSubview(searchBar)

        // Custom Header Views
        if let customHeaderViews = delegate?.recipientPickerCustomHeaderViews() {
            customHeaderViews.forEach { signalContactsStackView.addArrangedSubview($0) }
        }

        // Table View
        addChild(tableViewController)
        signalContactsStackView.addArrangedSubview(tableViewController.view)

        // "No Signal Contacts"
        noSignalContactsView.isHidden = !isNoContactsModeActive
        view.addSubview(noSignalContactsView)
        noSignalContactsView.autoPinWidthToSuperview()
        noSignalContactsView.autoPinEdge(toSuperviewEdge: .top)
        noSignalContactsView.autoPin(toBottomLayoutGuideOf: self, withInset: 0)

        // Pull to Refresh
        let refreshControl = UIRefreshControl()
        refreshControl.tintColor = .gray
        refreshControl.accessibilityIdentifier = "RecipientPickerViewController.pullToRefreshView"
        refreshControl.addTarget(self, action: #selector(pullToRefreshPerformed), for: .valueChanged)
        tableView.refreshControl = refreshControl

        updateTableContents()

        applyTheme()
    }

    public override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        // Make sure we have requested contact access at this point if, e.g.
        // the user has no messages in their inbox and they choose to compose
        // a message.
        contactsManagerImpl.requestSystemContactsOnce()

        showContactAppropriateViews()
    }

    public override func viewSafeAreaInsetsDidChange() {
        super.viewSafeAreaInsetsDidChange()
        updateSearchBarMargins()
    }

    public override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        updateSearchBarMargins()
    }

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

    public var preferredNavigationBarStyle: OWSNavigationBarStyle { .solid }

    public var navbarBackgroundColorOverride: UIColor? { tableViewController.tableBackgroundColor }

    // MARK: Search

    private static let minimumSearchLength = 1

    private var searchText: String { searchBar.text?.stripped ?? "" }

    private var lastSearchText: String?

    private var _searchResults = Atomic<RecipientSearchResultSet?>(wrappedValue: nil)

    private var searchResults: RecipientSearchResultSet? {
        get { _searchResults.wrappedValue }
        set {
            _searchResults.wrappedValue = newValue
            updateTableContents()
        }
    }

    private func searchTextDidChange() {
        let searchText = self.searchText

        guard searchText.count >= Self.minimumSearchLength else {
            searchResults = nil
            lastSearchText = nil
            return
        }

        guard lastSearchText != searchText else { return }
        lastSearchText = searchText

        var searchResults: RecipientSearchResultSet?
        databaseStorage.asyncRead(
            block: { tx in
                searchResults = self.fullTextSearcher.searchForRecipients(
                    searchText: searchText,
                    includeLocalUser: !self.shouldHideLocalRecipient,
                    includeStories: false,
                    tx: tx
                )
            },
            completion: { [weak self] in
                guard let self else { return }
                guard self.lastSearchText == searchText else {
                    // Discard obsolete search results.
                    return
                }
                self.searchResults = searchResults
            }
        )
    }

    private func updateSearchBarMargins() {
        // This should ideally compute the insets for self.tableView, but that
        // view's size hasn't been updated when the viewDidLayoutSubviews method is
        // called. As a quick fix, use self.view's size, which matches the eventual
        // width of self.tableView. (A more complete fix would likely add a
        // callback when self.tableView’s size is available.)
        searchBar.layoutMargins = OWSTableViewController2.cellOuterInsets(in: view)
    }

    internal func clearSearchText() {
        searchBar.text = ""
        searchTextDidChange()
    }

    // MARK: UI

    private var inviteFlow: InviteFlow?

    private var isNoContactsModeActive = false {
        didSet {
            guard oldValue != isNoContactsModeActive else { return }

            signalContactsStackView.isHidden = isNoContactsModeActive
            noSignalContactsView.isHidden = !isNoContactsModeActive

            updateTableContents()
        }
    }

    private let collation = UILocalizedIndexedCollation.current()

    private lazy var signalContactsStackView: UIStackView = {
        let stackView = UIStackView()
        stackView.axis = .vertical
        stackView.alignment = .fill
        return stackView
    }()

    private lazy var searchBar: OWSSearchBar = {
        let searchBar = OWSSearchBar()
        searchBar.delegate = self
        searchBar.placeholder = OWSLocalizedString(
            "SEARCH_BY_NAME_OR_USERNAME_OR_NUMBER_PLACEHOLDER_TEXT",
            comment: "Placeholder text indicating the user can search for contacts by name, username, or phone number."
        )
        searchBar.accessibilityIdentifier = "RecipientPickerViewController.searchBar"
        searchBar.textField?.accessibilityIdentifier = "RecipientPickerViewController.contact_search"
        searchBar.sizeToFit()
        searchBar.setCompressionResistanceVerticalHigh()
        searchBar.setContentHuggingVerticalHigh()
        return searchBar
    }()

    private lazy var tableViewController: OWSTableViewController2 = {
        let viewController = OWSTableViewController2()
        viewController.delegate = self
        viewController.defaultSeparatorInsetLeading = OWSTableViewController2.cellHInnerMargin
            + CGFloat(AvatarBuilder.smallAvatarSizePoints) + ContactCellView.avatarTextHSpacing
        viewController.tableView.register(ContactTableViewCell.self, forCellReuseIdentifier: ContactTableViewCell.reuseIdentifier)
        viewController.tableView.register(NonContactTableViewCell.self, forCellReuseIdentifier: NonContactTableViewCell.reuseIdentifier)
        viewController.view.setCompressionResistanceVerticalHigh()
        viewController.view.setContentHuggingVerticalHigh()
        return viewController
    }()

    private lazy var noSignalContactsView = createNoSignalContactsView()

    private var tableView: UITableView { tableViewController.tableView }

    private func applyTheme() {
        tableViewController.applyTheme(to: self)
        searchBar.searchFieldBackgroundColorOverride = Theme.searchFieldElevatedBackgroundColor
        tableViewController.tableView.sectionIndexColor = Theme.primaryTextColor
        if let owsNavigationController = navigationController as? OWSNavigationController {
            owsNavigationController.updateNavbarAppearance()
        }
    }

    public func applyTheme(to viewController: UIViewController) {
        tableViewController.applyTheme(to: viewController)
    }

    // MARK: Context Menu

    /// This must be retained for as long as we want to be able
    /// to display recipient context menus in this view controller.
    private lazy var recipientContextMenuHelper = {
        return RecipientContextMenuHelper(
            databaseStorage: databaseStorage,
            blockingManager: blockingManager,
            recipientHidingManager: DependenciesBridge.shared.recipientHidingManager,
            accountManager: DependenciesBridge.shared.tsAccountManager,
            contactsManager: contactsManager,
            fromViewController: self,
            delegate: self.delegate
        )
    }()

    // MARK: - Fetching Signal Connections

    private func updateSignalConnections() {
        databaseStorage.read { tx in
            let tsAccountManager = DependenciesBridge.shared.tsAccountManager

            // All Signal Connections that we believe are registered. In theory, this
            // should include your system contacts and the people you chat with.
            let whitelistedAddresses = Set(profileManager.allWhitelistedRegisteredAddresses(tx: tx))
            let blockedAddresses = blockingManager.blockedAddresses(transaction: tx)
            let hiddenAddresses = DependenciesBridge.shared.recipientHidingManager.hiddenAddresses(tx: tx.asV2Read)

            var resolvedAddresses = Set(whitelistedAddresses).subtracting(blockedAddresses).subtracting(hiddenAddresses)
            if shouldHideLocalRecipient, let localIdentifiers = tsAccountManager.localIdentifiers(tx: tx.asV2Read) {
                resolvedAddresses.remove(localIdentifiers.aciAddress)
            }

            signalConnections = contactsManagerImpl.sortedComparableNames(for: resolvedAddresses, tx: tx).filter { $0.displayName.hasKnownValue }
            signalConnectionAddresses = Set(signalConnections.lazy.map { $0.address })
        }
    }

    // MARK: Table Contents

    public func reloadContent() {
        updateTableContents()
    }

    private func updateTableContents() {
        AssertIsOnMainThread()

        guard !isNoContactsModeActive else {
            tableViewController.contents = OWSTableContents()
            return
        }

        let tableContents = OWSTableContents()

        // App is killed and restarted when the user changes their contact
        // permissions, so no need to "observe" anything to re-render this.
        if let reminderSection = contactAccessReminderSection() {
            tableContents.add(reminderSection)
        }

        let staticSection = OWSTableSection()
        staticSection.separatorInsetLeading = OWSTableViewController2.cellHInnerMargin + 24 + OWSTableItem.iconSpacing

        let isSearching = searchResults != nil

        if shouldShowNewGroup && !isSearching {
            staticSection.add(OWSTableItem.disclosureItem(
                icon: .genericGroup,
                name: OWSLocalizedString(
                    "NEW_GROUP_BUTTON",
                    comment: "Label for the 'create new group' button."
                ),
                accessibilityIdentifier: "RecipientPickerViewController.new_group",
                actionBlock: { [weak self] in
                    self?.newGroupButtonPressed()
                }
            ))
        }

        if allowsAddByAddress && !isSearching {
            // Find by username
            staticSection.add(OWSTableItem.disclosureItem(
                icon: .profileUsername,
                name: OWSLocalizedString(
                    "NEW_CONVERSATION_FIND_BY_USERNAME",
                    comment: "A label for the cell that lets you add a new member by their username"
                ),
                accessibilityIdentifier: "RecipientPickerViewController.find_by_username",
                actionBlock: { [weak self] in
                    guard let self else { return }
                    let viewController = FindByUsernameViewController()
                    viewController.findByUsernameDelegate = self
                    self.navigationController?.pushViewController(viewController, animated: true)
                }
            ))

            // Find by phone number
            staticSection.add(OWSTableItem.disclosureItem(
                icon: .phoneNumber,
                name: OWSLocalizedString(
                    "NEW_CONVERSATION_FIND_BY_PHONE_NUMBER",
                    comment: "A label the cell that lets you add a new member to a group."
                ),
                accessibilityIdentifier: "RecipientPickerViewController.find_by_phone",
                actionBlock: { [weak self] in
                    guard let self else { return }
                    let viewController = FindByPhoneNumberViewController(
                        delegate: self,
                        buttonText: self.findByPhoneNumberButtonTitle,
                        requiresRegisteredNumber: self.selectionMode != .blocklist
                    )
                    self.navigationController?.pushViewController(viewController, animated: true)
                }
            ))
        }

        if staticSection.itemCount > 0 {
            tableContents.add(staticSection)
        }

        // Render any non-contact picked recipients
        if !pickedRecipients.isEmpty && !isSearching {
            let sectionRecipients = pickedRecipients.filter { recipient in
                guard let recipientAddress = recipient.address else { return false }
                if signalConnectionAddresses.contains(recipientAddress) {
                    return false
                }
                return true
            }
            if !sectionRecipients.isEmpty {
                tableContents.add(OWSTableSection(
                    title: OWSLocalizedString(
                        "NEW_GROUP_NON_CONTACTS_SECTION_TITLE",
                        comment: "a title for the selected section of the 'recipient picker' view."
                    ),
                    items: sectionRecipients.map { item(forRecipient: $0) }
                ))
            }
        }

        if let searchResults {
            tableContents.add(sections: contactsSections(for: searchResults))
        } else {
            // Count the non-collated sections, before we add our collated sections.
            // Later we'll need to offset which sections our collation indexes reference
            // by this amount. e.g. otherwise the "B" index will reference names starting with "A"
            // And the "A" index will reference the static non-collated section(s).
            let beforeContactsSectionCount = tableContents.sections.count
            tableContents.add(sections: contactsSection())

            if shouldShowAlphabetSlider {
                tableContents.sectionForSectionIndexTitleBlock = { [weak tableContents, weak self] title, index in
                    guard let self, let tableContents else { return 0 }

                    // Offset the collation section to account for the noncollated sections.
                    let sectionIndex = self.collation.section(forSectionIndexTitle: index) + beforeContactsSectionCount
                    guard sectionIndex >= 0 else {
                        // Sentinel in case we change our section ordering in a surprising way.
                        owsFailDebug("Unexpected negative section index")
                        return 0
                    }
                    guard sectionIndex < tableContents.sections.count else {
                        // Sentinel in case we change our section ordering in a surprising way.
                        owsFailDebug("Unexpectedly large index")
                        return 0
                    }
                    return sectionIndex
                }
                tableContents.sectionIndexTitlesForTableViewBlock = { [weak self] in
                    guard let self else { return [] }
                    return self.collation.sectionTitles
                }
            }
        }

        // Invite Contacts
        if shouldShowInvites && !isSearching && contactsManagerImpl.sharingAuthorization != .denied {
            let bottomSection = OWSTableSection(title: OWSLocalizedString(
                "INVITE_FRIENDS_CONTACT_TABLE_HEADER",
                comment: "Header label above a section for more options for adding contacts"
            ))
            bottomSection.add(OWSTableItem.disclosureItem(
                icon: .settingsInvite,
                name: OWSLocalizedString(
                    "INVITE_FRIENDS_CONTACT_TABLE_BUTTON",
                    comment: "Label for the cell that presents the 'invite contacts' workflow."
                ),
                accessibilityIdentifier: "RecipientPickerViewController.invite_contacts",
                actionBlock: { [weak self] in
                    self?.presentInviteFlow()
                }
            ))
            tableContents.add(bottomSection)
        }

        tableViewController.contents = tableContents
    }

    // MARK: -

    @objc
    private func pullToRefreshPerformed(_ refreshControl: UIRefreshControl) {
        AssertIsOnMainThread()
        Logger.info("Beginning refreshing")

        let refreshPromise: AnyPromise
        if DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegisteredPrimaryDevice {
            refreshPromise = contactsManagerImpl.userRequestedSystemContactsRefresh()
        } else {
            refreshPromise = syncManager.sendAllSyncRequestMessages(timeout: 20)
        }
        _ = refreshPromise.ensure {
            Logger.info("ending refreshing")
            refreshControl.endRefreshing()
        }
    }
}

extension RecipientPickerViewController: OWSTableViewControllerDelegate {

    public func tableViewWillBeginDragging(_ tableView: UITableView) {
        searchBar.resignFirstResponder()
        delegate?.recipientPickerTableViewWillBeginDragging(self)
    }
}

extension RecipientPickerViewController: UISearchBarDelegate {

    public func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
        searchTextDidChange()
    }

    public func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
        searchTextDidChange()
    }

    public func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
        searchTextDidChange()
    }

    public func searchBarResultsListButtonClicked(_ searchBar: UISearchBar) {
        searchTextDidChange()
    }

    public func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
        searchTextDidChange()
    }
}

extension RecipientPickerViewController: ContactsViewHelperObserver {

    public func contactsViewHelperDidUpdateContacts() {
        updateSignalConnections()
        updateTableContents()
        showContactAppropriateViews()
    }
}

extension RecipientPickerViewController {

    public func groupSection(for searchResults: RecipientSearchResultSet) -> OWSTableSection? {
        let groupThreads: [TSGroupThread]
        switch groupsToShow {
        case .noGroups:
            return nil
        case .groupsThatUserIsMemberOfWhenSearching:
            groupThreads = searchResults.groupThreads.filter { thread in
                thread.isLocalUserFullMember
            }
        case .allGroupsWhenSearching:
            groupThreads = searchResults.groupThreads
        }

        guard !groupThreads.isEmpty else { return nil }

        return OWSTableSection(
            title: OWSLocalizedString(
                "COMPOSE_MESSAGE_GROUP_SECTION_TITLE",
                comment: "Table section header for group listing when composing a new message"
            ),
            items: groupThreads.map {
                self.item(forRecipient: PickedRecipient.for(groupThread: $0))
            }
        )
    }
}

// MARK: - Selecting Recipients

private extension RecipientPickerViewController {

    private func tryToSelectRecipient(_ recipient: PickedRecipient) {
        if let address = recipient.address, address.isLocalAddress, shouldHideLocalRecipient {
            owsFailDebug("Trying to select recipient that shouldn't be visible")
            return
        }
        if shouldUseAsyncSelection {
            prepareToSelectRecipient(recipient)
        } else {
            didPrepareToSelectRecipient(recipient)
        }
    }

    private func prepareToSelectRecipient(_ recipient: PickedRecipient) {
        guard let delegate = delegate else { return }
        ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: false) { modal in
            firstly {
                delegate.recipientPicker(self, prepareToSelectRecipient: recipient)
            }.done(on: DispatchQueue.main) { [weak self] _ in
                modal.dismiss {
                    self?.didPrepareToSelectRecipient(recipient)
                }
            }.catch(on: DispatchQueue.main) { error in
                owsFailDebugUnlessNetworkFailure(error)
                modal.dismiss {
                    OWSActionSheets.showErrorAlert(message: error.userErrorDescription)
                }
            }
        }
    }

    private func didPrepareToSelectRecipient(_ recipient: PickedRecipient) {
        AssertIsOnMainThread()

        guard let delegate = delegate else { return }

        let recipientPickerRecipientState = delegate.recipientPicker(self, getRecipientState: recipient)
        guard recipientPickerRecipientState == .canBeSelected else {
            showErrorAlert(recipientPickerRecipientState: recipientPickerRecipientState)
            return
        }

        delegate.recipientPicker(self, didSelectRecipient: recipient)
    }

    private func showErrorAlert(recipientPickerRecipientState: RecipientPickerRecipientState) {
        let errorMessage: String
        switch recipientPickerRecipientState {
        case .duplicateGroupMember:
            errorMessage = OWSLocalizedString(
                "GROUPS_ERROR_MEMBER_ALREADY_IN_GROUP",
                comment: "Error message indicating that a member can't be added to a group because they are already in the group."
            )
        case .userAlreadyInBlocklist:
            errorMessage = OWSLocalizedString(
                "BLOCK_LIST_ERROR_USER_ALREADY_IN_BLOCKLIST",
                comment: "Error message indicating that a user can't be blocked because they are already blocked."
            )
        case .conversationAlreadyInBlocklist:
            errorMessage = OWSLocalizedString(
                "BLOCK_LIST_ERROR_CONVERSATION_ALREADY_IN_BLOCKLIST",
                comment: "Error message indicating that a conversation can't be blocked because they are already blocked."
            )
        case .canBeSelected, .unknownError:
            owsFailDebug("Unexpected value.")
            errorMessage = OWSLocalizedString(
                "RECIPIENT_PICKER_ERROR_USER_CANNOT_BE_SELECTED",
                comment: "Error message indicating that a user can't be selected."
            )
        }
        OWSActionSheets.showErrorAlert(message: errorMessage)
    }
}

// MARK: - No Contacts

extension RecipientPickerViewController {

    private func createNoSignalContactsView() -> UIView {
        let heroImageView = UIImageView(image: .init(named: "uiEmptyContact"))
        heroImageView.layer.minificationFilter = .trilinear
        heroImageView.layer.magnificationFilter = .trilinear
        let heroSize = CGFloat.scaleFromIPhone5To7Plus(100, 150)
        heroImageView.autoSetDimensions(to: CGSize(square: heroSize))

        let titleLabel = UILabel()
        titleLabel.text = OWSLocalizedString(
            "EMPTY_CONTACTS_LABEL_LINE1",
            comment: "Full width label displayed when attempting to compose message"
        )
        titleLabel.textColor = Theme.primaryTextColor
        titleLabel.font = .semiboldFont(ofSize: .scaleFromIPhone5To7Plus(17, 20))
        titleLabel.textAlignment = .center
        titleLabel.lineBreakMode = .byWordWrapping
        titleLabel.numberOfLines = 0

        let subtitleLabel = UILabel()
        subtitleLabel.text = OWSLocalizedString(
            "EMPTY_CONTACTS_LABEL_LINE2",
            comment: "Full width label displayed when attempting to compose message"
        )
        subtitleLabel.textColor = Theme.secondaryTextAndIconColor
        subtitleLabel.font = .regularFont(ofSize: .scaleFromIPhone5To7Plus(12, 14))
        subtitleLabel.textAlignment = .center
        subtitleLabel.lineBreakMode = .byWordWrapping
        subtitleLabel.numberOfLines = 0

        let headerStack = UIStackView(arrangedSubviews: [
            heroImageView,
            titleLabel,
            subtitleLabel
        ])
        headerStack.setCustomSpacing(30, after: heroImageView)
        headerStack.setCustomSpacing(15, after: titleLabel)
        headerStack.axis = .vertical
        headerStack.alignment = .center

        let buttonStack = UIStackView()
        buttonStack.axis = .vertical
        buttonStack.alignment = .fill
        buttonStack.spacing = 16

        func addButton(
            title: String,
            selector: Selector,
            accessibilityIdentifierName: String,
            icon: ThemeIcon,
            innerIconSize: CGFloat
        ) {
            let button = UIButton(type: .custom)
            button.addTarget(self, action: selector, for: .touchUpInside)
            button.accessibilityIdentifier = UIView.accessibilityIdentifier(
                in: self,
                name: accessibilityIdentifierName
            )
            buttonStack.addArrangedSubview(button)

            let iconView = OWSTableItem.buildIconInCircleView(
                icon: icon,
                iconSize: AvatarBuilder.standardAvatarSizePoints,
                innerIconSize: innerIconSize,
                iconTintColor: Theme.accentBlueColor
            )
            iconView.backgroundColor = tableViewController.cellBackgroundColor

            let label = UILabel()
            label.text = title
            label.font = .regularFont(ofSize: 17)
            label.textColor = Theme.primaryTextColor
            label.lineBreakMode = .byTruncatingTail

            let hStack = UIStackView(arrangedSubviews: [iconView, label])
            hStack.axis = .horizontal
            hStack.alignment = .center
            hStack.spacing = 12
            hStack.isUserInteractionEnabled = false
            button.addSubview(hStack)
            hStack.autoPinEdgesToSuperviewEdges()
        }

        if shouldShowNewGroup {
            addButton(
                title: OWSLocalizedString(
                    "NEW_GROUP_BUTTON",
                    comment: "Label for the 'create new group' button."
                ),
                selector: #selector(newGroupButtonPressed),
                accessibilityIdentifierName: "newGroupButton",
                icon: .composeNewGroupLarge,
                innerIconSize: 35
            )
        }

        if allowsAddByAddress {
            addButton(
                title: OWSLocalizedString(
                    "NO_CONTACTS_SEARCH_BY_USERNAME",
                    comment: "Label for a button that lets users search for contacts by username"
                ),
                selector: #selector(hideBackgroundView),
                accessibilityIdentifierName: "searchByPhoneNumberButton",
                icon: .composeFindByUsernameLarge,
                innerIconSize: 40
            )

            addButton(
                title: OWSLocalizedString(
                    "NO_CONTACTS_SEARCH_BY_PHONE_NUMBER",
                    comment: "Label for a button that lets users search for contacts by phone number"
                ),
                selector: #selector(hideBackgroundView),
                accessibilityIdentifierName: "searchByPhoneNumberButton",
                icon: .composeFindByPhoneNumberLarge,
                innerIconSize: 42
            )
        }

        if shouldShowInvites {
            addButton(
                title: OWSLocalizedString(
                    "INVITE_FRIENDS_CONTACT_TABLE_BUTTON",
                    comment: "Label for the cell that presents the 'invite contacts' workflow."
                ),
                selector: #selector(presentInviteFlow),
                accessibilityIdentifierName: "inviteContactsButton",
                icon: .composeInviteLarge,
                innerIconSize: 38
            )
        }

        let stackView = UIStackView(arrangedSubviews: [headerStack, buttonStack])
        stackView.axis = .vertical
        stackView.alignment = .center
        stackView.spacing = 50
        stackView.isLayoutMarginsRelativeArrangement = true
        stackView.layoutMargins = .init(margin: 20)

        let result = UIView()
        result.backgroundColor = tableViewController.tableBackgroundColor
        result.addSubview(stackView)
        stackView.autoPinWidthToSuperview()
        stackView.autoVCenterInSuperview()
        return result
    }

    /// Checks if we should show the dedicated "no contacts" view.
    ///
    /// If you don't have any contacts, there's a special UX we'll show to the
    /// user that looks a bit nicer than a (mostly) empty table view; that UX
    /// doesn't look anything like a normal table view. If you dismiss that
    /// view, we'll switch to a normal table view with a row that says "You have
    /// no contacts on Signal." This method controls whether or not we show this
    /// special UX to the user.
    ///
    /// However, it also works closely in tandem with `noContactsTableSection`
    /// and `contactAccessReminderSection`. If this method returns true, those
    /// sections can't possibly be shown. If they should be visible, this method
    /// must return false. The former is shown in place of the list of contacts,
    /// and it's either a loading spinner or the "You have no contacts on
    /// Signal." row. The latter is shown at the very top of the recipient
    /// picker and may contain a banner if the user has disabled access to their
    /// contacts. So, if the user doesn't have any contacts but has also
    /// prevented Signal from accessing their contacts, we don't show the
    /// special UX and instead allow the banner to be visible.
    private func shouldNoContactsModeBeActive() -> Bool {
        switch contactsManagerImpl.editingAuthorization {
        case .denied, .restricted:
            // Return false so `contactAccessReminderSection` is invoked.
            return false
        case .notAllowed where shouldShowContactAccessNotAllowedReminderItemWithSneakyTransaction():
            // Return false so `contactAccessReminderSection` is invoked.
            return false
        case .authorized where !contactsManagerImpl.hasLoadedSystemContacts:
            // Return false so `noContactsTableSection` can show a spinner.
            return false
        case .authorized, .notAllowed:
            if !signalConnections.isEmpty {
                // Return false if we have any contacts; we want to show them!
                return false
            }
            if preferences.hasDeclinedNoContactsView {
                // Return false if the user has explicitly told us to hide the UX.
                return false
            }
            return true
        }
    }

    private func showContactAppropriateViews() {
        isNoContactsModeActive = shouldNoContactsModeBeActive()
    }

    /// Returns a section when there's no contacts to show.
    ///
    /// Works closely with `shouldNoContactsModeBeActive` and therefore might
    /// not be invoked even if the user has no contacts.
    private func noContactsTableSection() -> OWSTableSection {
        switch contactsManagerImpl.editingAuthorization {
        case .denied, .restricted:
            return OWSTableSection()
        case .authorized where !contactsManagerImpl.hasLoadedSystemContacts:
            return OWSTableSection(items: [loadingContactsTableItem()])
        case .authorized, .notAllowed:
            return OWSTableSection(items: [noContactsTableItem()])
        }
    }

    /// Returns a section with a banner at the top of the picker.
    ///
    /// Works closely with `shouldNoContactsModeBeActive`.
    private func contactAccessReminderSection() -> OWSTableSection? {
        let tableItem: OWSTableItem
        switch contactsManagerImpl.editingAuthorization {
        case .denied:
            tableItem = contactAccessDeniedReminderItem()
        case .restricted:
            // TODO: We don't show a reminder when the user isn't allowed to give
            // contacts permission. Should we?
            return nil
        case .authorized:
            return nil
        case .notAllowed:
            guard shouldShowContactAccessNotAllowedReminderItemWithSneakyTransaction() else {
                return nil
            }
            tableItem = contactAccessNotAllowedReminderItem()
        }
        return OWSTableSection(items: [tableItem])
    }

    private func noContactsTableItem() -> OWSTableItem {
        return OWSTableItem.softCenterLabel(
            withText: OWSLocalizedString(
                "SETTINGS_BLOCK_LIST_NO_CONTACTS",
                comment: "A label that indicates the user has no Signal contacts that they haven't blocked."
            )
        )
    }

    private func loadingContactsTableItem() -> OWSTableItem {
        let cell = OWSTableItem.newCell()

        let activityIndicatorView = UIActivityIndicatorView(style: .medium)
        cell.contentView.addSubview(activityIndicatorView)
        activityIndicatorView.startAnimating()
        activityIndicatorView.autoCenterInSuperview()
        activityIndicatorView.setCompressionResistanceHigh()
        activityIndicatorView.setContentHuggingHigh()

        cell.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "loading")

        let tableItem = OWSTableItem(customCellBlock: { cell })
        tableItem.customRowHeight = 40
        return tableItem
    }

    private func contactAccessDeniedReminderItem() -> OWSTableItem {
        return OWSTableItem(customCellBlock: {
            ContactAccessDeniedReminderTableViewCell {
                CurrentAppContext().openSystemSettings()
            }
        })
    }

    private static let keyValueStore = SDSKeyValueStore(collection: "RecipientPicker.contactAccess")
    private static let showNotAllowedReminderKey = "shouldShowNotAllowedReminder"

    private func shouldShowContactAccessNotAllowedReminderItemWithSneakyTransaction() -> Bool {
        databaseStorage.read {
            Self.keyValueStore.getBool(Self.showNotAllowedReminderKey, defaultValue: true, transaction: $0)
        }
    }

    private func hideShowContactAccessNotAllowedReminderItem() {
        databaseStorage.write {
            Self.keyValueStore.setBool(false, key: Self.showNotAllowedReminderKey, transaction: $0)
        }
        reloadContent()
    }

    private func contactAccessNotAllowedReminderItem() -> OWSTableItem {
        return OWSTableItem(customCellBlock: {
            ContactReminderTableViewCell(
                learnMoreAction: { [weak self] in
                    guard let self else { return }
                    ContactsViewHelper.presentContactAccessNotAllowedLearnMore(from: self)
                },
                dismissAction: { [weak self] in
                    self?.hideShowContactAccessNotAllowedReminderItem()
                }
            )
        })
    }

    @objc
    private func newGroupButtonPressed() {
        delegate?.recipientPickerNewGroupButtonWasPressed()
    }

    @objc
    private func hideBackgroundView() {
        self.preferences.setHasDeclinedNoContactsView(true)
        showContactAppropriateViews()
    }

    @objc
    private func presentInviteFlow() {
        let inviteFlow = InviteFlow(presentingViewController: self)
        self.inviteFlow = inviteFlow
        inviteFlow.present(isAnimated: true, completion: nil)
    }
}

// MARK: - Contacts, Connections, & Groups

extension RecipientPickerViewController {
    private func contactsSection() -> [OWSTableSection] {
        guard !signalConnections.isEmpty else {
            return [ noContactsTableSection() ]
        }

        // All contacts in one section
        guard shouldShowAlphabetSlider else {
            return [OWSTableSection(
                title: OWSLocalizedString(
                    "COMPOSE_MESSAGE_CONTACT_SECTION_TITLE",
                    comment: "Table section header for contact listing when composing a new message"
                ),
                items: signalConnections.map { item(forRecipient: PickedRecipient.for(address: $0.address)) }
            )]
        }

        var collatedSignalConnections = collation.sectionTitles.map { _ in return [ComparableDisplayName]() }
        for signalConnection in signalConnections {
            let section = collation.section(
                for: CollatableComparableDisplayName(signalConnection),
                collationStringSelector: #selector(CollatableComparableDisplayName.collationString)
            )
            guard section >= 0 else {
                continue
            }
            collatedSignalConnections[section].append(signalConnection)
        }

        let contactSections = collatedSignalConnections.enumerated().map { index, signalConnections in
            // Don't show empty sections.
            // To accomplish this we add a section with a blank title rather than omitting the section altogether,
            // in order for section indexes to match up correctly
            if signalConnections.isEmpty {
                return OWSTableSection()
            }

            return OWSTableSection(
                title: collation.sectionTitles[index].uppercased(),
                items: signalConnections.map { item(forRecipient: PickedRecipient.for(address: $0.address)) }
            )
        }

        return contactSections
    }

    private func contactsSections(for searchResults: RecipientSearchResultSet) -> [OWSTableSection] {
        AssertIsOnMainThread()

        var sections = [OWSTableSection]()

        // Contacts, with blocked contacts and hidden recipients removed.
        var matchedAccountPhoneNumbers = Set<String>()
        var contactsSectionItems = [OWSTableItem]()
        databaseStorage.read { tx in
            for recipientAddress in searchResults.contactResults.map({ $0.recipientAddress }) {
                if let phoneNumber = recipientAddress.phoneNumber {
                    matchedAccountPhoneNumbers.insert(phoneNumber)
                }

                contactsSectionItems.append(item(forRecipient: PickedRecipient.for(address: recipientAddress)))
            }
        }

        if !contactsSectionItems.isEmpty {
            sections.append(OWSTableSection(
                title: OWSLocalizedString(
                    "COMPOSE_MESSAGE_CONTACT_SECTION_TITLE",
                    comment: "Table section header for contact listing when composing a new message"
                ),
                items: contactsSectionItems
            ))
        }

        if let groupSection = groupSection(for: searchResults) {
            sections.append(groupSection)
        }

        if let findByNumberSection = findByNumberSection(for: searchResults, skipping: matchedAccountPhoneNumbers) {
            sections.append(findByNumberSection)
        }

        if let usernameSection = findByUsernameSection(for: searchResults) {
            sections.append(usernameSection)
        }

        guard !sections.isEmpty else {
            // No Search Results
             return [
                OWSTableSection(items: [
                    OWSTableItem.softCenterLabel(withText: OWSLocalizedString(
                        "SETTINGS_BLOCK_LIST_NO_SEARCH_RESULTS",
                        comment: "A label that indicates the user's search has no matching results."
                    ))
                ])
            ]
        }

        return sections
    }

    private func item(forRecipient recipient: PickedRecipient) -> OWSTableItem {
        switch recipient.identifier {
        case .address(let address):
            return OWSTableItem(
                dequeueCellBlock: { [weak self] tableView in
                    self?.addressCell(for: address, recipient: recipient, tableView: tableView) ?? UITableViewCell()
                },
                actionBlock: { [weak self] in
                    self?.tryToSelectRecipient(recipient)
                },
                contextMenuActionProvider: recipientContextMenuHelper.actionProvider(address: address)
            )
        case .group(let groupThread):
            return OWSTableItem(
                customCellBlock: { [weak self] in
                    self?.groupCell(for: groupThread, recipient: recipient) ?? UITableViewCell()
                },
                actionBlock: { [weak self] in
                    self?.tryToSelectRecipient(recipient)
                },
                contextMenuActionProvider: recipientContextMenuHelper.actionProvider(groupThread: groupThread)
            )
        }
    }

    private func addressCell(for address: SignalServiceAddress, recipient: PickedRecipient, tableView: UITableView) -> UITableViewCell? {
        guard let cell = tableView.dequeueReusableCell(ContactTableViewCell.self) else { return nil }
        if let delegate, delegate.recipientPicker(self, getRecipientState: recipient) != .canBeSelected {
            cell.selectionStyle = .none
        }
        databaseStorage.read { transaction in
            let configuration = ContactCellConfiguration(address: address, localUserDisplayMode: .noteToSelf)
            if let delegate {
                if let accessoryView = delegate.recipientPicker(self, accessoryViewForRecipient: recipient, transaction: transaction) {
                    configuration.accessoryView = accessoryView
                } else {
                    let accessoryMessage = delegate.recipientPicker(self, accessoryMessageForRecipient: recipient, transaction: transaction)
                    configuration.accessoryMessage = accessoryMessage
                }
                if let attributedSubtitle = delegate.recipientPicker(self, attributedSubtitleForRecipient: recipient, transaction: transaction) {
                    configuration.attributedSubtitle = attributedSubtitle
                }

                configuration.allowUserInteraction = delegate.recipientPicker(self, shouldAllowUserInteractionForRecipient: recipient, transaction: transaction)

                let isSystemContact = contactsManager.fetchSignalAccount(for: address, transaction: transaction) != nil
                configuration.shouldShowContactIcon = isSystemContact
            }
            cell.configure(configuration: configuration, transaction: transaction)
        }
        return cell
    }

    private func groupCell(for groupThread: TSGroupThread, recipient: PickedRecipient) -> UITableViewCell? {
        let cell = GroupTableViewCell()

        if let delegate {
            if delegate.recipientPicker(self, getRecipientState: recipient) != .canBeSelected {
                cell.selectionStyle = .none
            }
            databaseStorage.read { tx in
                cell.accessoryMessage = delegate.recipientPicker(self, accessoryMessageForRecipient: recipient, transaction: tx)
                cell.customAccessoryView = delegate.recipientPicker(self, accessoryViewForRecipient: recipient, transaction: tx)?.accessoryView
            }
        }

        cell.configure(thread: groupThread)

        return cell
    }
}

// MARK: - Find by Number

struct PhoneNumberFinder {
    let localNumber: String?
    let contactDiscoveryManager: ContactDiscoveryManager
    let phoneNumberUtil: PhoneNumberUtil

    enum SearchResult {
        /// This e164 has already been validated by libPhoneNumber.
        case valid(validE164: String)

        /// This e164 consists of arbitrary user-provided text that needs to be
        /// validated before fetching it from CDS.
        case maybeValid(maybeValidE164: String)

        var maybeValidE164: String {
            switch self {
            case .valid(validE164: let validE164):
                return validE164
            case .maybeValid(maybeValidE164: let maybeValidE164):
                return maybeValidE164
            }
        }
    }

    /// For a given search term, extract potential phone number matches.
    ///
    /// We consider phone number matches that libPhoneNumber thinks may be
    /// valid, based on a fuzzy matching algorithm and the user's current phone
    /// number. It's possible to receive multiple matches.
    ///
    /// For example, if your current number has the +1 calling code and you
    /// enter "521 555 0100", you'll see three results:
    ///   - +1 521-555-0100
    ///   - +52 15 5501 00
    ///   - +52 55 5010 0
    ///
    /// We also consider arbitrary sequences of digits entered by the user. We
    /// wait to validate these until the user taps them. This improves the UX
    /// and helps make the feature more discoverable.
    ///
    /// - Parameter searchText:
    ///     Arbitrary text provided by the user. It could be "cat", the empty
    ///     string, or something that looks roughly like a phone number. If this
    ///     parameter contains fewer than 3 characters, an empty array is
    ///     returned.
    ///
    /// - Returns: Zero, one, or many matches.
    func parseResults(for searchText: String) -> [SearchResult] {
        guard searchText.count >= 3 else {
            return []
        }

        // Check for valid libPhoneNumber results.
        let uniqueResults = OrderedSet(
            phoneNumberUtil.parsePhoneNumbers(
                userSpecifiedText: searchText,
                localPhoneNumber: localNumber ?? ""
            ).lazy.compactMap { self.validE164(from: $0) }
        )
        if !uniqueResults.isEmpty {
            return uniqueResults.orderedMembers.map { .valid(validE164: $0) }
        }

        // Otherwise, show a potentially-invalid number that we'll validate if the
        // user tries to select it.
        if let maybeValidE164 = parseFakeSearchPhoneNumber(for: searchText) {
            return [.maybeValid(maybeValidE164: maybeValidE164)]
        }

        return []
    }

    private func parseFakeSearchPhoneNumber(for searchText: String) -> String? {
        let filteredValue = searchText.filteredAsE164

        let potentialE164: String
        if filteredValue.hasPrefix("+") {
            potentialE164 = filteredValue
        } else if
            let localNumber,
            let callingCode = phoneNumberUtil.parseE164(localNumber)?.getCallingCode()?.intValue
        {
            potentialE164 = "+\(callingCode)\(filteredValue)"
        } else {
            owsFailDebug("No localNumber")
            return nil
        }

        // Stop showing results after 20 characters. A 3-digit country code (4
        // characters, including "+") and a 15-digit phone number would be 19
        // characters. Allow for one extra accidental character, even though a
        // 20-digit number should always fail to parse.
        guard (3...20).contains(potentialE164.count) else {
            return nil
        }

        // Allow only symbols, digits, and whitespace. The `filterE164()` call
        // above will keep only "+" and ASCII digits, but the user may try to
        // format the number themselves, or they may paste a number formatted
        // elsewhere. If the user types a letter, this result will disappear.

        var allowedCharacters = CharacterSet(charactersIn: "+0123456789")
        allowedCharacters.formUnion(.whitespaces)
        allowedCharacters.formUnion(.punctuationCharacters)  // allow "(", ")", "-", etc.
        guard searchText.rangeOfCharacter(from: allowedCharacters.inverted) == nil else {
            return nil
        }

        return potentialE164
    }

    private func validE164(from phoneNumber: PhoneNumber) -> String? {
        return E164(phoneNumber.toE164())?.stringValue
    }

    enum LookupResult {
        /// The phone number was found on CDS.
        case success(SignalRecipient)

        /// The phone number is valid but doesn't exist on CDS. Perhaps phone number
        /// discovery is disabled, or perhaps the account isn't registered.
        case notFound(validE164: String)

        /// The phone number isn't valid, so we didn't even send a request to CDS to check.
        case notValid(invalidE164: String)
    }

    func lookUp(phoneNumber searchResult: SearchResult) -> Promise<LookupResult> {
        let validE164ToLookUp: String
        switch searchResult {
        case .valid(validE164: let validE164):
            validE164ToLookUp = validE164
        case .maybeValid(maybeValidE164: let maybeValidE164):
            guard
                let phoneNumber = phoneNumberUtil.parsePhoneNumber(userSpecifiedText: maybeValidE164),
                let validE164 = validE164(from: phoneNumber)
            else {
                return .value(.notValid(invalidE164: maybeValidE164))
            }
            validE164ToLookUp = validE164
        }
        return firstly {
            contactDiscoveryManager.lookUp(phoneNumbers: [validE164ToLookUp], mode: .oneOffUserRequest)
        }.map { signalRecipients in
            if let signalRecipient = signalRecipients.first {
                return .success(signalRecipient)
            } else {
                return .notFound(validE164: validE164ToLookUp)
            }
        }
    }
}

extension RecipientPickerViewController {

    private func findByNumberCell(for phoneNumber: String, tableView: UITableView) -> UITableViewCell? {
        guard let cell = tableView.dequeueReusableCell(NonContactTableViewCell.self) else { return nil }
        cell.configureWithPhoneNumber(phoneNumber)
        return cell
    }

    public func findByNumberSection(
        for searchResults: RecipientSearchResultSet,
        skipping alreadyMatchedPhoneNumbers: Set<String>
    ) -> OWSTableSection? {
        let phoneNumberFinder = PhoneNumberFinder(
            localNumber: DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.phoneNumber,
            contactDiscoveryManager: contactDiscoveryManager,
            phoneNumberUtil: phoneNumberUtil
        )
        var phoneNumberResults = phoneNumberFinder.parseResults(for: searchResults.searchText)
        // Don't show phone numbers that are visible in other sections.
        phoneNumberResults.removeAll { alreadyMatchedPhoneNumbers.contains($0.maybeValidE164) }
        // Don't show the user's own number if they can't select it.
        if shouldHideLocalRecipient, let localNumber = phoneNumberFinder.localNumber {
            phoneNumberResults.removeAll { localNumber == $0.maybeValidE164 }
        }
        guard !phoneNumberResults.isEmpty else {
            return nil
        }

        return OWSTableSection(
            title: OWSLocalizedString(
                "COMPOSE_MESSAGE_PHONE_NUMBER_SEARCH_SECTION_TITLE",
                comment: "Table section header for phone number search when composing a new message"
            ),
            items: phoneNumberResults.map { phoneNumberResult in
                return OWSTableItem(
                    dequeueCellBlock: { [weak self] tableView in
                        let e164 = phoneNumberResult.maybeValidE164
                        return self?.findByNumberCell(for: e164, tableView: tableView) ?? UITableViewCell()
                    },
                    actionBlock: { [weak self] in
                        self?.findByNumber(phoneNumberResult, using: phoneNumberFinder)
                    }
                )
            }
        )
    }

    /// Performs a lookup for an unknown number entered by the user.
    ///
    /// - If the number is found, the recipient will be selected. (The
    ///   definition of "selected" depends on whether you're on the Compose
    ///   screen, Add Group Members screen, etc.)
    ///
    /// - If the number isn't found, the behavior depends on `selectionMode`. If
    ///   you're trying to block someone, we'll allow the number to be blocked.
    ///   Otherwise, you'll be told that the number isn't registered.
    ///
    /// - If the number isn't valid, you'll be told that it's not valid.
    ///
    /// - Parameter phoneNumberResult: The search result the user tapped.
    private func findByNumber(_ phoneNumberResult: PhoneNumberFinder.SearchResult, using finder: PhoneNumberFinder) {
        ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: true) { modal in
            firstly {
                finder.lookUp(phoneNumber: phoneNumberResult)
            }.done(on: DispatchQueue.main) { [weak self] lookupResult in
                modal.dismissIfNotCanceled {
                    guard let self = self else { return }
                    self.handlePhoneNumberLookupResult(lookupResult)
                }
            }.catch(on: DispatchQueue.main) { error in
                modal.dismissIfNotCanceled {
                    OWSActionSheets.showErrorAlert(message: error.userErrorDescription)
                }
            }
        }
    }

    private func handlePhoneNumberLookupResult(_ lookupResult: PhoneNumberFinder.LookupResult) {
        switch (selectionMode, lookupResult) {
        case (_, .success(let signalRecipient)):
            // If the lookup was successful, select the recipient.
            tryToSelectRecipient(.for(address: signalRecipient.address))

        case (.blocklist, .notFound(validE164: let validE164)):
            // If we're trying to block an unregistered user, allow it.
            tryToSelectRecipient(.for(address: SignalServiceAddress(phoneNumber: validE164)))

        case (.`default`, .notFound(validE164: let validE164)):
            // Otherwise, if we're trying to contact someone, offer to invite them.
            presentSMSInvitationSheet(for: validE164)

        case (_, .notValid(invalidE164: let invalidE164)):
            // If the number isn't valid, show an error so the user can fix it.
            presentInvalidNumberSheet(for: invalidE164)
        }
    }

    private func presentSMSInvitationSheet(for phoneNumber: String) {
        let actionSheet = ActionSheetController(
            title: OWSLocalizedString(
                "RECIPIENT_PICKER_INVITE_TITLE",
                comment: "Alert title. Shown after selecting a phone number that isn't a Signal user."
            ),
            message: String(
                format: OWSLocalizedString(
                    "RECIPIENT_PICKER_INVITE_MESSAGE",
                    comment: "Alert text. Shown after selecting a phone number that isn't a Signal user."
                ),
                PhoneNumber.bestEffortFormatPartialUserSpecifiedText(toLookLikeAPhoneNumber: phoneNumber)
            )
        )
        actionSheet.addAction(OWSActionSheets.cancelAction)
        actionSheet.addAction(ActionSheetAction(
            title: OWSLocalizedString(
                "RECIPIENT_PICKER_INVITE_ACTION",
                comment: "Button. Shown after selecting a phone number that isn't a Signal user. Tapping the button will open a view that allows the user to send an SMS message to specified phone number."
            ),
            style: .default,
            handler: { [weak self] action in
                guard let self = self else { return }
                guard MFMessageComposeViewController.canSendText() else {
                    OWSActionSheets.showErrorAlert(message: InviteFlow.unsupportedFeatureMessage)
                    return
                }
                let inviteFlow = InviteFlow(presentingViewController: self)
                inviteFlow.sendSMSTo(phoneNumbers: [phoneNumber])
                // We need to keep InviteFlow around until it's completed. We tie its
                // lifetime to this view controller -- while not perfect, this avoids
                // leaking the object.
                self.inviteFlow = inviteFlow
            }
        ))
        presentActionSheet(actionSheet)
    }

    private func presentInvalidNumberSheet(for phoneNumber: String) {
        let actionSheet = ActionSheetController(
            title: OWSLocalizedString(
                "RECIPIENT_PICKER_INVALID_NUMBER_TITLE",
                comment: "Alert title. Shown after selecting a phone number that isn't valid."
            ),
            message: String(
                format: OWSLocalizedString(
                    "RECIPIENT_PICKER_INVALID_NUMBER_MESSAGE",
                    comment: "Alert text. Shown after selecting a phone number that isn't valid."
                ),
                PhoneNumber.bestEffortFormatPartialUserSpecifiedText(toLookLikeAPhoneNumber: phoneNumber)
            )
        )
        actionSheet.addAction(OWSActionSheets.okayAction)
        presentActionSheet(actionSheet)
    }
}

// MARK: - FindByPhoneNumberDelegate
// ^^ This refers to the *separate* "Find by Phone Number" row that you can tap.

extension RecipientPickerViewController: FindByPhoneNumberDelegate {

    public func findByPhoneNumber(
        _ findByPhoneNumber: FindByPhoneNumberViewController,
        didSelectAddress address: SignalServiceAddress
    ) {
        owsAssertDebug(address.isValid)

        tryToSelectRecipient(.for(address: address))
    }
}

extension RecipientPickerViewController: FindByUsernameDelegate {
    func findByUsername(address: SignalServiceAddress) {
        owsAssertDebug(address.isValid)
        tryToSelectRecipient(.for(address: address))
    }

    var shouldShowQRCodeButton: Bool {
        delegate?.shouldShowQRCodeButton ?? false
    }

    func openQRCodeScanner() {
        delegate?.openUsernameQRCodeScanner()
    }
}

// MARK: - Find by Username

extension RecipientPickerViewController {

    private func parsePossibleSearchUsername(for searchText: String) -> String? {
        let username = searchText

        guard let firstCharacter = username.first else {
            // Don't show username results -- the user hasn't searched for anything
            return nil
        }
        guard firstCharacter != "+" else {
            // Don't show username results -- assume this is a phone number
            return nil
        }
        guard !("0"..."9").contains(firstCharacter) else {
            // Don't show username results -- assume this is a phone number
            return nil
        }

        return username
    }

    private func findByUsernameCell(for username: String, tableView: UITableView) -> UITableViewCell? {
        guard let cell = tableView.dequeueReusableCell(NonContactTableViewCell.self) else { return nil }
        cell.configureWithUsername(username)
        return cell
    }

    private func findByUsernameSection(for searchResults: RecipientSearchResultSet) -> OWSTableSection? {
        guard let username = parsePossibleSearchUsername(for: searchResults.searchText) else {
            return nil
        }
        let tableItem = OWSTableItem(
            dequeueCellBlock: { [weak self] tableView in
                self?.findByUsernameCell(for: username, tableView: tableView) ?? UITableViewCell()
            },
            actionBlock: { [weak self] in
                self?.findByUsername(username)
            }
        )
        return OWSTableSection(
            title: OWSLocalizedString(
                "COMPOSE_MESSAGE_USERNAME_SEARCH_SECTION_TITLE",
                comment: "Table section header for username search when composing a new message"
            ),
            items: [tableItem]
        )
    }

    private func findByUsername(_ username: String) {
        databaseStorage.read { tx in
            UsernameQuerier().queryForUsername(
                username: username,
                fromViewController: self,
                tx: tx,
                onSuccess: { [weak self] aci in
                    AssertIsOnMainThread()

                    guard let self else { return }
                    self.tryToSelectRecipient(.for(address: SignalServiceAddress(aci)))
                }
            )
        }
    }
}

// MARK: - ContactAccessDeniedReminderTableViewCell

private class ContactAccessDeniedReminderTableViewCell: UITableViewCell {
    private let tapAction: () -> Void

    init(openSettingsAction: @escaping () -> Void) {
        self.tapAction = openSettingsAction
        super.init(style: .default, reuseIdentifier: nil)

        let label = UILabel()
        contentView.addSubview(label)
        label.autoPinEdgesToSuperviewMargins()
        label.numberOfLines = 0
        label.attributedText = NSAttributedString.composed(of: [
            OWSLocalizedString(
                "COMPOSE_SCREEN_MISSING_CONTACTS_PERMISSION",
                comment: "Multi-line label explaining why compose-screen contact picker is empty."
            ),
            "\n",
            OWSLocalizedString(
                "COMPOSE_SCREEN_MISSING_CONTACTS_CTA",
                comment: "Button to open settings from an empty compose-screen contact picker."
            ).styled(
                with: .font(.dynamicTypeSubheadline.semibold()),
                .alignment(.trailing)
            )
        ]).styled(
            with: .font(.dynamicTypeSubheadline),
            .color(Theme.primaryTextColor)
        )

        addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTap)))
    }

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

    @objc
    private func didTap() {
        tapAction()
    }
}

extension ContactAccessDeniedReminderTableViewCell: CustomBackgroundColorCell {
    func customBackgroundColor(forceDarkMode: Bool) -> UIColor {
        ReminderView.warningBackgroundColor(forceDarkMode: forceDarkMode)
    }

    func customSelectedBackgroundColor(forceDarkMode: Bool) -> UIColor {
        customBackgroundColor(forceDarkMode: forceDarkMode)
    }
}
