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

import Contacts
import Foundation
import LibSignalClient
import SignalCoreKit

extension Notification.Name {
    public static let OWSContactsManagerSignalAccountsDidChange = Notification.Name("OWSContactsManagerSignalAccountsDidChangeNotification")
    public static let OWSContactsManagerContactsDidChange = Notification.Name("OWSContactsManagerContactsDidChangeNotification")
}

@objc
public enum RawContactAuthorizationStatus: UInt {
    case notDetermined, denied, restricted, authorized
}

public enum ContactAuthorizationForEditing {
    case notAllowed, denied, restricted, authorized
}

public enum ContactAuthorizationForSharing {
    case notDetermined, denied, authorized
}

@objc
public class OWSContactsManager: NSObject, ContactsManagerProtocol {
    let swiftValues: OWSContactsManagerSwiftValues
    let systemContactsFetcher: SystemContactsFetcher
    let keyValueStore: SDSKeyValueStore
    public var isEditingAllowed: Bool {
        TSAccountManagerObjcBridge.isPrimaryDeviceWithMaybeTransaction
    }
    /// Must call `requestSystemContactsOnce` before accessing this method
    public var editingAuthorization: ContactAuthorizationForEditing {
        guard isEditingAllowed else {
            return .notAllowed
        }
        switch systemContactsFetcher.rawAuthorizationStatus {
        case .notDetermined:
            owsFailDebug("should have called `requestOnce` before checking authorization status.")
            fallthrough
        case .denied:
            return .denied
        case .restricted:
            return .restricted
        case .authorized:
            return .authorized
        }
    }
    public var sharingAuthorization: ContactAuthorizationForSharing {
        switch self.systemContactsFetcher.rawAuthorizationStatus {
        case .notDetermined:
            return .notDetermined
        case .denied, .restricted:
            return .denied
        case .authorized:
            return .authorized
        }
    }
    /// Whether or not we've fetched system contacts on this launch.
    ///
    /// This property is set to true even if the user doesn't have any system
    /// contacts.
    ///
    /// This property is only valid if the user has granted contacts access.
    /// Otherwise, it's value is undefined.
    public private(set) var hasLoadedSystemContacts: Bool = false

    public init(swiftValues: OWSContactsManagerSwiftValues) {
        keyValueStore = SDSKeyValueStore(collection: "OWSContactsManagerCollection")
        systemContactsFetcher = SystemContactsFetcher()
        self.swiftValues = swiftValues
        super.init()
        systemContactsFetcher.delegate = self

        SwiftSingletons.register(self)
    }

    // Request systems contacts and start syncing changes. The user will see an alert
    // if they haven't previously.
    public func requestSystemContactsOnce(completion: (((any Error)?) -> Void)? = nil) {
        AssertIsOnMainThread()

        guard isEditingAllowed else {
            if let completion = completion {
                Logger.warn("Editing contacts isn't available on linked devices.")
                completion(OWSError(error: .genericFailure, description: OWSLocalizedString("ERROR_DESCRIPTION_UNKNOWN_ERROR", comment: "Worst case generic error message"), isRetryable: false))
            }
            return
        }
        systemContactsFetcher.requestOnce(completion: completion)
    }

    /// Ensure's the app has the latest contacts, but won't prompt the user for contact
    /// access if they haven't granted it.
    public func fetchSystemContactsOnceIfAlreadyAuthorized() {
        guard isEditingAllowed else {
            return
        }
        systemContactsFetcher.fetchOnceIfAlreadyAuthorized()
    }

    /// This variant will fetch system contacts if contact access has already been granted,
    /// but not prompt for contact access. Also, it will always notify delegates, even if
    /// contacts haven't changed, and will clear out any stale cached SignalAccounts
    public func userRequestedSystemContactsRefresh() -> AnyPromise {
        guard isEditingAllowed else {
            owsFailDebug("Editing contacts isn't available on linked devices.")
            let promise = AnyPromise()
            promise.reject(OWSError(error: .assertionFailure, description: OWSLocalizedString("ERROR_DESCRIPTION_UNKNOWN_ERROR", comment: "Worst case generic error message"), isRetryable: false))
            return promise
        }
        return AnyPromise(future: { (future: AnyFuture) in
            self.systemContactsFetcher.userRequestedRefresh { (error: (any Error)?) in
                if let error = error {
                    Logger.error("refreshing contacts failed with error: \(error)")
                    future.reject(error: error)
                } else {
                    future.resolve(value: NSNumber(value: 1))
                }
            }
        })
    }
}

// MARK: - SystemContactsFetcherDelegate

extension OWSContactsManager: SystemContactsFetcherDelegate {

    func systemContactsFetcher(_ systemContactsFetcher: SystemContactsFetcher, hasAuthorizationStatus authorizationStatus: RawContactAuthorizationStatus) {
        guard isEditingAllowed else {
            owsFailDebug("Syncing contacts isn't available on linked devices.")
            return
        }
        switch authorizationStatus {
        case .restricted, .denied:
            self.updateContacts(nil, isUserRequested: false)
        case .notDetermined, .authorized:
            break
        }
    }

    func systemContactsFetcher(_ systemContactsFetcher: SystemContactsFetcher, updatedContacts contacts: [SystemContact], isUserRequested: Bool) {
        guard isEditingAllowed else {
            owsFailDebug("Syncing contacts isn't available on linked devices.")
            return
        }
        updateContacts(contacts, isUserRequested: isUserRequested)
    }

    public func displayNameString(for address: SignalServiceAddress, transaction: SDSAnyReadTransaction) -> String {
        displayName(for: address, tx: transaction).resolvedValue()
    }

    public func shortDisplayNameString(for address: SignalServiceAddress, transaction: SDSAnyReadTransaction) -> String {
        displayName(for: address, tx: transaction).resolvedValue(useShortNameIfAvailable: true)
    }
}

// MARK: - OWSContactsMangerSwiftValues

public class OWSContactsManagerSwiftValues {
    fileprivate let avatarBlurringCache = LowTrustCache()
    fileprivate let cnContactCache = LRUCache<String, CNContact>(maxSize: 50, shouldEvacuateInBackground: true)
    fileprivate let isInWhitelistedGroupWithLocalUserCache = AtomicDictionary<ServiceId, Bool>([:], lock: .init())
    fileprivate let hasWhitelistedGroupMemberCache = AtomicDictionary<Data, Bool>([:], lock: .init())
    fileprivate let systemContactsCache = SystemContactsCache()
    fileprivate let unknownThreadWarningCache = LowTrustCache()

    fileprivate let intersectionQueue = DispatchQueue(label: "org.signal.contacts.intersection")
    fileprivate let skipContactAvatarBlurByServiceIdStore = SDSKeyValueStore(collection: "OWSContactsManager.skipContactAvatarBlurByUuidStore")
    fileprivate let skipGroupAvatarBlurByGroupIdStore = SDSKeyValueStore(collection: "OWSContactsManager.skipGroupAvatarBlurByGroupIdStore")

    fileprivate let usernameLookupManager: UsernameLookupManager
    fileprivate let recipientDatabaseTable: any RecipientDatabaseTable
    fileprivate let nicknameManager: any NicknameManager

    public init(
        usernameLookupManager: UsernameLookupManager,
        recipientDatabaseTable: any RecipientDatabaseTable,
        nicknameManager: any NicknameManager
    ) {
        self.usernameLookupManager = usernameLookupManager
        self.recipientDatabaseTable = recipientDatabaseTable
        self.nicknameManager = nicknameManager
    }
}

// MARK: -

private class SystemContactsCache {
    let fetchedSystemContacts = AtomicOptional<FetchedSystemContacts>(nil, lock: .init())
}

// MARK: -

private class LowTrustCache {
    let contactCache = AtomicSet<ServiceId>(lock: .sharedGlobal)
    let groupCache = AtomicSet<Data>(lock: .sharedGlobal)

    func contains(groupThread: TSGroupThread) -> Bool {
        groupCache.contains(groupThread.groupId)
    }

    func contains(contactThread: TSContactThread) -> Bool {
        contains(address: contactThread.contactAddress)
    }

    func contains(address: SignalServiceAddress) -> Bool {
        guard let serviceId = address.serviceId else {
            return false
        }
        return contactCache.contains(serviceId)
    }

    func add(groupThread: TSGroupThread) {
        groupCache.insert(groupThread.groupId)
    }

    func add(contactThread: TSContactThread) {
        add(address: contactThread.contactAddress)
    }

    func add(address: SignalServiceAddress) {
        guard let serviceId = address.serviceId else {
            return
        }
        contactCache.insert(serviceId)
    }
}

// MARK: -

extension OWSContactsManager: ContactManager {
    // MARK: Low Trust

    private func isLowTrustThread(_ thread: TSThread, lowTrustCache: LowTrustCache, tx: SDSAnyReadTransaction) -> Bool {
        if let contactThread = thread as? TSContactThread {
            return isLowTrustContact(contactThread: contactThread, lowTrustCache: lowTrustCache, tx: tx)
        } else if let groupThread = thread as? TSGroupThread {
            return isLowTrustGroup(groupThread: groupThread, lowTrustCache: lowTrustCache, tx: tx)
        } else {
            owsFailDebug("Invalid thread.")
            return false
        }
    }

    private func isLowTrustContact(address: SignalServiceAddress, lowTrustCache: LowTrustCache, tx: SDSAnyReadTransaction) -> Bool {
        if lowTrustCache.contains(address: address) {
            return false
        }
        guard let contactThread = TSContactThread.getWithContactAddress(address, transaction: tx) else {
            lowTrustCache.add(address: address)
            return false
        }
        return isLowTrustContact(contactThread: contactThread, lowTrustCache: lowTrustCache, tx: tx)
    }

    private func isLowTrustContact(contactThread: TSContactThread, lowTrustCache: LowTrustCache, tx: SDSAnyReadTransaction) -> Bool {
        let address = contactThread.contactAddress
        if lowTrustCache.contains(address: address) {
            return false
        }
        if !contactThread.hasPendingMessageRequest(transaction: tx) {
            lowTrustCache.add(address: address)
            return false
        }
        // ...and not in a whitelisted group with the locar user.
        if isInWhitelistedGroupWithLocalUser(otherAddress: address, tx: tx) {
            lowTrustCache.add(address: address)
            return false
        }
        // We can skip avatar blurring if the user has explicitly waived the blurring.
        if
            lowTrustCache === swiftValues.avatarBlurringCache,
            let storeKey = address.serviceId?.serviceIdUppercaseString,
            swiftValues.skipContactAvatarBlurByServiceIdStore.getBool(storeKey, defaultValue: false, transaction: tx)
        {
            lowTrustCache.add(address: address)
            return false
        }
        return true
    }

    private func isLowTrustGroup(groupThread: TSGroupThread, lowTrustCache: LowTrustCache, tx: SDSAnyReadTransaction) -> Bool {
        if lowTrustCache.contains(groupThread: groupThread) {
            return false
        }
        if !groupThread.hasPendingMessageRequest(transaction: tx) {
            lowTrustCache.add(groupThread: groupThread)
            return false
        }
        // We can skip "unknown thread warnings" if a group has members which are trusted.
        if lowTrustCache === swiftValues.unknownThreadWarningCache, hasWhitelistedGroupMember(groupThread: groupThread, tx: tx) {
            lowTrustCache.add(groupThread: groupThread)
            return false
        }
        return true
    }

    private func isInWhitelistedGroupWithLocalUser(otherAddress: SignalServiceAddress, tx: SDSAnyReadTransaction) -> Bool {
        let cache = swiftValues.isInWhitelistedGroupWithLocalUserCache
        if let cacheKey = otherAddress.serviceId, let cachedValue = cache[cacheKey] {
            return cachedValue
        }
        let result: Bool = {
            guard let localAddress = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: tx.asV2Read)?.aciAddress else {
                owsFailDebug("Missing localAddress.")
                return false
            }
            let otherGroupThreadIds = TSGroupThread.groupThreadIds(with: otherAddress, transaction: tx)
            guard !otherGroupThreadIds.isEmpty else {
                return false
            }
            let localGroupThreadIds = TSGroupThread.groupThreadIds(with: localAddress, transaction: tx)
            let groupThreadIds = Set(otherGroupThreadIds).intersection(localGroupThreadIds)
            for groupThreadId in groupThreadIds {
                guard let groupThread = TSGroupThread.anyFetchGroupThread(uniqueId: groupThreadId, transaction: tx) else {
                    owsFailDebug("Missing group thread")
                    continue
                }
                if profileManager.isGroupId(inProfileWhitelist: groupThread.groupId, transaction: tx) {
                    return true
                }
            }
            return false
        }()
        if let cacheKey = otherAddress.serviceId {
            cache[cacheKey] = result
        }
        return result
    }

    private func hasWhitelistedGroupMember(groupThread: TSGroupThread, tx: SDSAnyReadTransaction) -> Bool {
        let cache = swiftValues.hasWhitelistedGroupMemberCache
        let cacheKey = groupThread.groupId
        if let cachedValue = cache[cacheKey] {
            return cachedValue
        }
        let result: Bool = {
            for groupMember in groupThread.groupMembership.fullMembers {
                if profileManager.isUser(inProfileWhitelist: groupMember, transaction: tx) {
                    return true
                }
            }
            return false
        }()
        cache[cacheKey] = result
        return result
    }

    public func shouldShowUnknownThreadWarning(thread: TSThread, transaction: SDSAnyReadTransaction) -> Bool {
        isLowTrustThread(thread, lowTrustCache: swiftValues.unknownThreadWarningCache, tx: transaction)
    }

    // MARK: - Avatar Blurring

    public func shouldBlurContactAvatar(address: SignalServiceAddress, transaction: SDSAnyReadTransaction) -> Bool {
        isLowTrustContact(address: address, lowTrustCache: swiftValues.avatarBlurringCache, tx: transaction)
    }

    public func shouldBlurContactAvatar(contactThread: TSContactThread, transaction: SDSAnyReadTransaction) -> Bool {
        isLowTrustContact(contactThread: contactThread, lowTrustCache: swiftValues.avatarBlurringCache, tx: transaction)
    }

    public func shouldBlurGroupAvatar(groupThread: TSGroupThread, transaction: SDSAnyReadTransaction) -> Bool {
        if nil == groupThread.groupModel.avatarHash {
            // DO NOT add to the cache.
            return false
        }

        if !isLowTrustGroup(
            groupThread: groupThread,
            lowTrustCache: swiftValues.avatarBlurringCache,
            tx: transaction
        ) {
            return false
        }

        // We can skip avatar blurring if the user has explicitly waived the blurring.
        if swiftValues.skipGroupAvatarBlurByGroupIdStore.getBool(
            groupThread.groupId.hexadecimalString,
            defaultValue: false,
            transaction: transaction)
        {
            swiftValues.avatarBlurringCache.add(groupThread: groupThread)
            return false
        }

        return true
    }

    public static let skipContactAvatarBlurDidChange = NSNotification.Name("skipContactAvatarBlurDidChange")
    public static let skipContactAvatarBlurAddressKey = "skipContactAvatarBlurAddressKey"
    public static let skipGroupAvatarBlurDidChange = NSNotification.Name("skipGroupAvatarBlurDidChange")
    public static let skipGroupAvatarBlurGroupUniqueIdKey = "skipGroupAvatarBlurGroupUniqueIdKey"

    public func doNotBlurContactAvatar(address: SignalServiceAddress, transaction tx: SDSAnyWriteTransaction) {
        guard let serviceId = address.serviceId else {
            owsFailDebug("Missing ServiceId for user.")
            return
        }
        let storeKey = serviceId.serviceIdUppercaseString
        let shouldSkipBlur = swiftValues.skipContactAvatarBlurByServiceIdStore.getBool(storeKey, defaultValue: false, transaction: tx)
        guard !shouldSkipBlur else {
            owsFailDebug("Value did not change.")
            return
        }
        swiftValues.skipContactAvatarBlurByServiceIdStore.setBool(true, key: storeKey, transaction: tx)
        if let contactThread = TSContactThread.getWithContactAddress(address, transaction: tx) {
            databaseStorage.touch(thread: contactThread, shouldReindex: false, transaction: tx)
        }
        tx.addAsyncCompletionOffMain {
            NotificationCenter.default.postNotificationNameAsync(
                Self.skipContactAvatarBlurDidChange,
                object: nil,
                userInfo: [
                    Self.skipContactAvatarBlurAddressKey: address
                ]
            )
        }
    }

    public func doNotBlurGroupAvatar(groupThread: TSGroupThread, transaction: SDSAnyWriteTransaction) {
        let groupId = groupThread.groupId
        let groupUniqueId = groupThread.uniqueId
        guard !swiftValues.skipGroupAvatarBlurByGroupIdStore.getBool(
            groupId.hexadecimalString,
            defaultValue: false,
            transaction: transaction
        ) else {
            owsFailDebug("Value did not change.")
            return
        }
        swiftValues.skipGroupAvatarBlurByGroupIdStore.setBool(
            true,
            key: groupId.hexadecimalString,
            transaction: transaction
        )
        databaseStorage.touch(thread: groupThread, shouldReindex: false, transaction: transaction)

        transaction.addAsyncCompletionOffMain {
            NotificationCenter.default.postNotificationNameAsync(Self.skipGroupAvatarBlurDidChange,
                                                                 object: nil,
                                                                 userInfo: [
                                                                    Self.skipGroupAvatarBlurGroupUniqueIdKey: groupUniqueId
                                                                 ])
        }
    }

    public func blurAvatar(_ image: UIImage) -> UIImage? {
        do {
            return try image.withGaussianBlur(radius: 16, resizeToMaxPixelDimension: 100)
        } catch {
            owsFailDebug("Error: \(error)")
            return nil
        }
    }

    // MARK: - Avatars

    public func avatarImage(forAddress address: SignalServiceAddress?,
                            shouldValidate: Bool,
                            transaction: SDSAnyReadTransaction) -> UIImage? {
        guard let imageData = avatarImageData(forAddress: address,
                                              shouldValidate: shouldValidate,
                                              transaction: transaction) else {
            return nil
        }
        guard let image = UIImage(data: imageData) else {
            owsFailDebug("Invalid image.")
            return nil
        }
        return image
    }

    public func avatarImageData(forAddress address: SignalServiceAddress?,
                                shouldValidate: Bool,
                                transaction: SDSAnyReadTransaction) -> Data? {
        guard let address = address,
              address.isValid else {
                  owsFailDebug("Missing or invalid address.")
                  return nil
              }

        if SSKPreferences.preferContactAvatars(transaction: transaction) {
            return (systemContactOrSyncedImageData(forAddress: address,
                                                   shouldValidate: shouldValidate,
                                                   transaction: transaction)
                    ?? profileAvatarImageData(forAddress: address,
                                              shouldValidate: shouldValidate,
                                              transaction: transaction))
        } else {
            return (profileAvatarImageData(forAddress: address,
                                           shouldValidate: shouldValidate,
                                           transaction: transaction)
                    ?? systemContactOrSyncedImageData(forAddress: address,
                                                      shouldValidate: shouldValidate,
                                                      transaction: transaction))
        }
    }

    private func profileAvatarImageData(
        forAddress address: SignalServiceAddress?,
        shouldValidate: Bool,
        transaction: SDSAnyReadTransaction
    ) -> Data? {
        func validateIfNecessary(_ imageData: Data) -> Data? {
            guard shouldValidate else {
                return imageData
            }
            guard imageData.ows_isValidImage else {
                owsFailDebug("Invalid image data.")
                return nil
            }
            return imageData
        }

        guard let address = address,
              address.isValid else {
                  owsFailDebug("Missing or invalid address.")
                  return nil
              }

        if let avatarData = profileManagerImpl.profileAvatarData(for: address, transaction: transaction),
           let validData = validateIfNecessary(avatarData) {
            return validData
        }

        return nil
    }

    private func systemContactOrSyncedImageData(
        forAddress address: SignalServiceAddress?,
        shouldValidate: Bool,
        transaction: SDSAnyReadTransaction
    ) -> Data? {
        func validateIfNecessary(_ imageData: Data) -> Data? {
            guard shouldValidate else {
                return imageData
            }
            guard imageData.ows_isValidImage else {
                owsFailDebug("Invalid image data.")
                return nil
            }
            return imageData
        }

        guard let address, address.isValid else {
            owsFailDebug("Missing or invalid address.")
            return nil
        }

        guard !address.isLocalAddress else {
            // Never use system contact or synced image data for the local user
            return nil
        }

        if
            let phoneNumber = address.phoneNumber,
            let signalAccount = self.fetchSignalAccount(forPhoneNumber: phoneNumber, transaction: transaction),
            let cnContactId = signalAccount.cnContactId,
            let avatarData = self.avatarData(for: cnContactId),
            let validData = validateIfNecessary(avatarData)
        {
            return validData
        }
        return nil
    }

    // MARK: - Intersection

    private func buildContactAvatarHash(for systemContact: SystemContact) -> Data? {
        return autoreleasepool {
            let cnContactId = systemContact.cnContactId
            guard let contactAvatarData = avatarData(for: cnContactId) else {
                return nil
            }
            guard let contactAvatarHash = Cryptography.computeSHA256Digest(contactAvatarData) else {
                owsFailDebug("Could not digest contactAvatarData.")
                return nil
            }
            return contactAvatarHash
        }
    }

    private func discoverableRecipient(for canonicalPhoneNumber: CanonicalPhoneNumber, tx: SDSAnyReadTransaction) -> SignalRecipient? {
        let recipientDatabaseTable = DependenciesBridge.shared.recipientDatabaseTable
        for phoneNumber in [canonicalPhoneNumber.rawValue] + canonicalPhoneNumber.alternatePhoneNumbers() {
            let recipient = recipientDatabaseTable.fetchRecipient(phoneNumber: phoneNumber.stringValue, transaction: tx.asV2Read)
            guard let recipient, recipient.isPhoneNumberDiscoverable else {
                continue
            }
            return recipient
        }
        return nil
    }

    private func buildSignalAccounts(
        for fetchedSystemContacts: FetchedSystemContacts,
        transaction: SDSAnyReadTransaction
    ) -> [SignalAccount] {
        var discoverableRecipients = [CanonicalPhoneNumber: (SignalRecipient, ServiceId)]()
        var discoverablePhoneNumberCounts = [String: Int]()
        for (phoneNumber, contactRef) in fetchedSystemContacts.phoneNumberToContactRef {
            guard let signalRecipient = discoverableRecipient(for: phoneNumber, tx: transaction) else {
                // Not discoverable.
                continue
            }
            guard let serviceId = signalRecipient.aci ?? signalRecipient.pni else {
                owsFailDebug("Can't be discoverable without an ACI or PNI.")
                continue
            }
            discoverableRecipients[phoneNumber] = (signalRecipient, serviceId)
            discoverablePhoneNumberCounts[contactRef.cnContactId, default: 0] += 1
        }
        var signalAccounts = [SignalAccount]()
        for (phoneNumber, contactRef) in fetchedSystemContacts.phoneNumberToContactRef {
            guard let (signalRecipient, serviceId) = discoverableRecipients[phoneNumber] else {
                continue
            }
            guard let discoverablePhoneNumberCount = discoverablePhoneNumberCounts[contactRef.cnContactId] else {
                owsFailDebug("Couldn't find relatedPhoneNumbers")
                continue
            }
            guard let systemContact = fetchedSystemContacts.cnContactIdToContact[contactRef.cnContactId] else {
                owsFailDebug("Couldn't find systemContact")
                continue
            }
            let multipleAccountLabelText = Contact.uniquePhoneNumberLabel(
                userProvidedLabel: contactRef.userProvidedLabel,
                discoverablePhoneNumberCount: discoverablePhoneNumberCount
            )
            let contactAvatarHash = buildContactAvatarHash(for: systemContact)
            let signalAccount = SignalAccount(
                recipientPhoneNumber: signalRecipient.phoneNumber?.stringValue,
                recipientServiceId: serviceId,
                multipleAccountLabelText: multipleAccountLabelText,
                cnContactId: systemContact.cnContactId,
                givenName: systemContact.firstName,
                familyName: systemContact.lastName,
                nickname: systemContact.nickname,
                fullName: systemContact.fullName,
                contactAvatarHash: contactAvatarHash
            )
            signalAccounts.append(signalAccount)
        }
        return signalAccounts
    }

    private func buildSignalAccountsAndUpdatePersistedState(for fetchedSystemContacts: FetchedSystemContacts) {
        assertOnQueue(swiftValues.intersectionQueue)

        let (oldSignalAccounts, newSignalAccounts) = databaseStorage.read { transaction in
            let oldSignalAccounts = SignalAccount.anyFetchAll(transaction: transaction)
            let newSignalAccounts = buildSignalAccounts(for: fetchedSystemContacts, transaction: transaction)
            return (oldSignalAccounts, newSignalAccounts)
        }
        let oldSignalAccountsMap: [String?: SignalAccount] = Dictionary(
            oldSignalAccounts.lazy.map { ($0.recipientPhoneNumber, $0) },
            uniquingKeysWith: { _, new in new }
        )
        var newSignalAccountsMap = [String: SignalAccount]()

        var signalAccountChanges: [(remove: SignalAccount?, insert: SignalAccount?)] = []
        for newSignalAccount in newSignalAccounts {
            guard let phoneNumber = newSignalAccount.recipientPhoneNumber else {
                owsFailDebug("Can't have a system contact without a phone number.")
                continue
            }

            // The user might have multiple entries in their address book with the same phone number.
            if newSignalAccountsMap[phoneNumber] != nil {
                Logger.warn("Ignoring redundant signal account")
                continue
            }

            let oldSignalAccountToKeep: SignalAccount?
            let oldSignalAccount = oldSignalAccountsMap[phoneNumber]
            switch oldSignalAccount {
            case .none:
                oldSignalAccountToKeep = nil

            case .some(let oldSignalAccount) where oldSignalAccount.hasSameContent(newSignalAccount) && !oldSignalAccount.hasDeprecatedRepresentation:
                // Same content, no need to update.
                oldSignalAccountToKeep = oldSignalAccount

            case .some:
                oldSignalAccountToKeep = nil
            }

            if let oldSignalAccount = oldSignalAccountToKeep {
                newSignalAccountsMap[phoneNumber] = oldSignalAccount
            } else {
                newSignalAccountsMap[phoneNumber] = newSignalAccount
                signalAccountChanges.append((oldSignalAccount, newSignalAccount))
            }
        }

        // Clean up orphans.
        for signalAccount in oldSignalAccounts {
            if let phoneNumber = signalAccount.recipientPhoneNumber, newSignalAccountsMap[phoneNumber]?.uniqueId == signalAccount.uniqueId {
                // Don't clean up SignalAccounts that aren't changing.
                continue
            }
            // Clean up instances that have been replaced by another instance or are no
            // longer in the system contacts.
            signalAccountChanges.append((signalAccount, nil))
        }

        // Update cached SignalAccounts on disk
        databaseStorage.write { tx in
            for (signalAccountToRemove, signalAccountToInsert) in signalAccountChanges {
                let oldSignalAccount = signalAccountToRemove.flatMap {
                    SignalAccount.anyFetch(uniqueId: $0.uniqueId, transaction: tx)
                }
                let newSignalAccount = signalAccountToInsert

                oldSignalAccount?.anyRemove(transaction: tx)
                newSignalAccount?.anyInsert(transaction: tx)

                updatePhoneNumberVisibilityIfNeeded(
                    oldSignalAccount: oldSignalAccount,
                    newSignalAccount: newSignalAccount,
                    tx: tx.asV2Write
                )
            }

            if !signalAccountChanges.isEmpty {
                Logger.info("Updated \(signalAccountChanges.count) SignalAccounts; now have \(newSignalAccountsMap.count) total")
            }

            // Add system contacts to the profile whitelist immediately so that they do
            // not see the "message request" UI.
            profileManager.addUsers(
                toProfileWhitelist: newSignalAccountsMap.values.map { $0.recipientAddress },
                userProfileWriter: .systemContactsFetch,
                transaction: tx
            )
        }

        // Once we've persisted new SignalAccount state, we should let
        // StorageService know.
        updateStorageServiceForSystemContactsFetch(
            allSignalAccountsBeforeFetch: oldSignalAccountsMap,
            allSignalAccountsAfterFetch: newSignalAccountsMap
        )

        let didChangeAnySignalAccount = !signalAccountChanges.isEmpty
        DispatchQueue.main.async {
            // Post a notification if something changed or this is the first load since launch.
            let shouldNotify = didChangeAnySignalAccount || !self.hasLoadedSystemContacts
            self.hasLoadedSystemContacts = true
            self.didUpdateSignalAccounts(shouldNotify: shouldNotify)
        }
    }

    /// Updates StorageService records for any Signal contacts associated with
    /// a system contact that has been added, removed, or modified in a
    /// relevant way. Has no effect when we are a linked device.
    private func updateStorageServiceForSystemContactsFetch(
        allSignalAccountsBeforeFetch: [String?: SignalAccount],
        allSignalAccountsAfterFetch: [String: SignalAccount]
    ) {
        let tsAccountManager = DependenciesBridge.shared.tsAccountManager
        guard tsAccountManager.registrationStateWithMaybeSneakyTransaction.isPrimaryDevice ?? false else {
            return
        }

        var phoneNumbersToUpdateInStorageService = [String]()

        var allSignalAccountsBeforeFetch = allSignalAccountsBeforeFetch
        for (phoneNumber, newSignalAccount) in allSignalAccountsAfterFetch {
            let oldSignalAccount = allSignalAccountsBeforeFetch.removeValue(forKey: phoneNumber)
            if let oldSignalAccount, newSignalAccount.hasSameName(oldSignalAccount) {
                // No Storage Service-relevant changes were made.
                continue
            }
            phoneNumbersToUpdateInStorageService.append(phoneNumber)
        }
        // Anything left in ...BeforeFetch was removed.
        phoneNumbersToUpdateInStorageService.append(
            contentsOf: allSignalAccountsBeforeFetch.keys.lazy.compactMap { $0 }
        )

        let updatedAccountIds = databaseStorage.read { tx in
            return phoneNumbersToUpdateInStorageService.compactMap {
                let recipientDatabaseTable = DependenciesBridge.shared.recipientDatabaseTable
                return recipientDatabaseTable.fetchRecipient(phoneNumber: $0, transaction: tx.asV2Read)?.uniqueId
            }
        }

        storageServiceManager.recordPendingUpdates(updatedAccountIds: updatedAccountIds)
    }

    private func updatePhoneNumberVisibilityIfNeeded(
        oldSignalAccount: SignalAccount?,
        newSignalAccount: SignalAccount?,
        tx: DBWriteTransaction
    ) {
        let aciToUpdate = SignalAccount.aciForPhoneNumberVisibilityUpdate(
            oldAccount: oldSignalAccount,
            newAccount: newSignalAccount
        )
        guard let aciToUpdate else {
            return
        }
        let recipient = swiftValues.recipientDatabaseTable.fetchRecipient(serviceId: aciToUpdate, transaction: tx)
        guard let recipient else {
            return
        }
        // Tell the cache to refresh its state for this recipient. It will check
        // whether or not the number should be visible based on this state and the
        // state of system contacts.
        signalServiceAddressCache.updateRecipient(recipient, tx: tx)
    }

    public func didUpdateSignalAccounts(transaction: SDSAnyWriteTransaction) {
        transaction.addTransactionFinalizationBlock(forKey: "OWSContactsManager.didUpdateSignalAccounts") { _ in
            self.didUpdateSignalAccounts(shouldNotify: true)
        }
    }

    private func didUpdateSignalAccounts(shouldNotify: Bool) {
        if shouldNotify {
            NotificationCenter.default.postNotificationNameAsync(.OWSContactsManagerSignalAccountsDidChange, object: nil)
        }
    }

    private enum Constants {
        static let nextFullIntersectionDate = "OWSContactsManagerKeyNextFullIntersectionDate2"
        static let lastKnownContactPhoneNumbers = "OWSContactsManagerKeyLastKnownContactPhoneNumbers"
        static let didIntersectAddressBook = "didIntersectAddressBook"
    }

    func updateContacts(_ addressBookContacts: [SystemContact]?, isUserRequested: Bool) {
        swiftValues.intersectionQueue.async { self._updateContacts(addressBookContacts, isUserRequested: isUserRequested) }
    }

    private func fetchPriorIntersectionPhoneNumbers(tx: SDSAnyReadTransaction) -> Set<String>? {
        keyValueStore.getObject(forKey: Constants.lastKnownContactPhoneNumbers, transaction: tx) as? Set<String>
    }

    private func setPriorIntersectionPhoneNumbers(_ phoneNumbers: Set<String>, tx: SDSAnyWriteTransaction) {
        keyValueStore.setObject(phoneNumbers, key: Constants.lastKnownContactPhoneNumbers, transaction: tx)
    }

    private enum IntersectionMode {
        /// It's time for the regularly-scheduled full intersection.
        case fullIntersection

        /// It's not time for the regularly-scheduled full intersection. Only check
        /// new phone numbers.
        case deltaIntersection(priorPhoneNumbers: Set<String>)
    }

    private func fetchIntersectionMode(isUserRequested: Bool, tx: SDSAnyReadTransaction) -> IntersectionMode {
        if isUserRequested {
            return .fullIntersection
        }
        let nextFullIntersectionDate = keyValueStore.getDate(Constants.nextFullIntersectionDate, transaction: tx)
        guard let nextFullIntersectionDate, nextFullIntersectionDate.isAfterNow else {
            return .fullIntersection
        }
        guard let priorPhoneNumbers = fetchPriorIntersectionPhoneNumbers(tx: tx) else {
            // We don't know the prior phone numbers, so do a `.fullIntersection`.
            return .fullIntersection
        }
        return .deltaIntersection(priorPhoneNumbers: priorPhoneNumbers)
    }

    private func _updateContacts(_ addressBookContacts: [SystemContact]?, isUserRequested: Bool) {
        let tsAccountManager = DependenciesBridge.shared.tsAccountManager
        let localNumber = tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.phoneNumber
        let fetchedSystemContacts = FetchedSystemContacts.parseContacts(
            addressBookContacts ?? [],
            phoneNumberUtil: NSObject.phoneNumberUtil,
            localPhoneNumber: localNumber
        )
        setFetchedSystemContacts(fetchedSystemContacts)

        intersectContacts(
            fetchedSystemContacts: fetchedSystemContacts,
            localNumber: localNumber,
            isUserRequested: isUserRequested
        )
    }

    private func intersectContacts(
        fetchedSystemContacts: FetchedSystemContacts,
        localNumber: String?,
        isUserRequested: Bool
    ) {
        let systemContactPhoneNumbers = fetchedSystemContacts.phoneNumberToContactRef.keys

        let (intersectionMode, signalRecipientPhoneNumbers) = databaseStorage.read { tx in
            let intersectionMode = fetchIntersectionMode(isUserRequested: isUserRequested, tx: tx)
            let signalRecipientPhoneNumbers = SignalRecipient.fetchAllPhoneNumbers(tx: tx)
            return (intersectionMode, signalRecipientPhoneNumbers)
        }

        var phoneNumbersToIntersect = Set(signalRecipientPhoneNumbers.keys)
        phoneNumbersToIntersect.formUnion(systemContactPhoneNumbers.lazy.map { $0.rawValue.stringValue })
        phoneNumbersToIntersect.formUnion(systemContactPhoneNumbers.lazy.flatMap { $0.alternatePhoneNumbers().map { $0.stringValue } })
        if case .deltaIntersection(let priorPhoneNumbers) = intersectionMode {
            phoneNumbersToIntersect.subtract(priorPhoneNumbers)
        }
        if let localNumber {
            phoneNumbersToIntersect.remove(localNumber)
        }

        switch intersectionMode {
        case .fullIntersection:
            Logger.info("Performing full intersection for \(phoneNumbersToIntersect.count) phone numbers.")
        case .deltaIntersection:
            Logger.info("Performing delta intersection for \(phoneNumbersToIntersect.count) phone numbers.")
        }

        let intersectionPromise = intersectContacts(phoneNumbersToIntersect, retryDelaySeconds: 1)
        intersectionPromise.done(on: swiftValues.intersectionQueue) { intersectedRecipients in
            // Mark it as complete. If the app crashes after this transaction, we'll
            // avoid a redundant (expensive) intersection when we retry.
            self.databaseStorage.write { tx in
                self.didFinishIntersection(
                    mode: intersectionMode,
                    phoneNumbers: phoneNumbersToIntersect,
                    tx: tx
                )
            }

            // Save names to the database before generating notifications.
            self.buildSignalAccountsAndUpdatePersistedState(for: fetchedSystemContacts)

            self.databaseStorage.write { tx in
                self.postJoinNotificationsIfNeeded(
                    addressBookPhoneNumbers: systemContactPhoneNumbers,
                    phoneNumberRegistrationStatus: signalRecipientPhoneNumbers,
                    intersectedRecipients: intersectedRecipients,
                    tx: tx
                )
                self.unhideRecipientsIfNeeded(
                    addressBookPhoneNumbers: systemContactPhoneNumbers,
                    tx: tx.asV2Write
                )
            }
        }.catch(on: swiftValues.intersectionQueue) { error in
            owsFailDebug("Couldn't intersect contacts: \(error)")
        }
    }

    private func postJoinNotificationsIfNeeded(
        addressBookPhoneNumbers: some Sequence<CanonicalPhoneNumber>,
        phoneNumberRegistrationStatus: [String: Bool],
        intersectedRecipients: some Sequence<SignalRecipient>,
        tx: SDSAnyWriteTransaction
    ) {
        let didIntersectAtLeastOnce = keyValueStore.getBool(Constants.didIntersectAddressBook, defaultValue: false, transaction: tx)
        guard didIntersectAtLeastOnce else {
            // This is the first address book intersection. Don't post notifications,
            // but mark the flag so that we post notifications next time.
            keyValueStore.setBool(true, key: Constants.didIntersectAddressBook, transaction: tx)
            return
        }
        guard Self.preferences.shouldNotifyOfNewAccounts(transaction: tx) else {
            return
        }
        let phoneNumbers = Set(addressBookPhoneNumbers.lazy.map { $0.rawValue.stringValue })
        for signalRecipient in intersectedRecipients {
            guard let phoneNumber = signalRecipient.phoneNumber, phoneNumber.isDiscoverable else {
                continue  // Can't happen.
            }
            guard phoneNumbers.contains(phoneNumber.stringValue) else {
                continue  // Not in the address book -- no notification.
            }
            guard phoneNumberRegistrationStatus[phoneNumber.stringValue] != true else {
                continue  // They were already registered -- no notification.
            }
            NewAccountDiscovery.postNotification(for: signalRecipient, tx: tx)
        }
    }

    /// We cannot hide a contact that is in our address book.
    /// As a result, when a contact that was hidden is added to the address book,
    /// we must unhide them.
    private func unhideRecipientsIfNeeded(
        addressBookPhoneNumbers: some Sequence<CanonicalPhoneNumber>,
        tx: DBWriteTransaction
    ) {
        let recipientHidingManager = DependenciesBridge.shared.recipientHidingManager
        let phoneNumbers = Set(addressBookPhoneNumbers.lazy.map { $0.rawValue.stringValue })
        for hiddenRecipient in recipientHidingManager.hiddenRecipients(tx: tx) {
            guard let phoneNumber = hiddenRecipient.phoneNumber else {
                continue // We can't unhide because of the address book w/o a phone number.
            }
            guard phoneNumber.isDiscoverable else {
                continue // Not discoverable -- no unhiding.
            }
            guard phoneNumbers.contains(phoneNumber.stringValue) else {
                continue  // Not in the address book -- no unhiding.
            }
            DependenciesBridge.shared.recipientHidingManager.removeHiddenRecipient(
                hiddenRecipient,
                wasLocallyInitiated: true,
                tx: tx
            )
        }
    }

    private func didFinishIntersection(
        mode intersectionMode: IntersectionMode,
        phoneNumbers: Set<String>,
        tx: SDSAnyWriteTransaction
    ) {
        switch intersectionMode {
        case .fullIntersection:
            setPriorIntersectionPhoneNumbers(phoneNumbers, tx: tx)
            let nextFullIntersectionDate = Date(timeIntervalSinceNow: RemoteConfig.cdsSyncInterval)
            keyValueStore.setDate(nextFullIntersectionDate, key: Constants.nextFullIntersectionDate, transaction: tx)

        case .deltaIntersection:
            // If a user has a "flaky" address book (perhaps it's a network-linked
            // directory that goes in and out of existence), we could get thrashing
            // between what the last known set is, causing us to re-intersect contacts
            // many times within the debounce interval. So while we're doing
            // incremental intersections, we *accumulate*, rather than replace, the set
            // of recently intersected contacts.
            let priorPhoneNumbers = fetchPriorIntersectionPhoneNumbers(tx: tx) ?? []
            setPriorIntersectionPhoneNumbers(priorPhoneNumbers.union(phoneNumbers), tx: tx)
        }
    }

    private func intersectContacts(
        _ phoneNumbers: Set<String>,
        retryDelaySeconds: TimeInterval
    ) -> Promise<Set<SignalRecipient>> {
        owsAssertDebug(retryDelaySeconds > 0)

        if phoneNumbers.isEmpty {
            return .value([])
        }
        return contactDiscoveryManager.lookUp(
            phoneNumbers: phoneNumbers,
            mode: .contactIntersection
        ).recover(on: DispatchQueue.global()) { (error) -> Promise<Set<SignalRecipient>> in
            var retryAfter: TimeInterval = retryDelaySeconds

            if let cdsError = error as? ContactDiscoveryError {
                guard cdsError.code != ContactDiscoveryError.Kind.rateLimit.rawValue else {
                    Logger.error("Contact intersection hit rate limit with error: \(error)")
                    return Promise(error: error)
                }
                guard cdsError.retrySuggested else {
                    Logger.error("Contact intersection error suggests not to retry. Aborting without rescheduling.")
                    return Promise(error: error)
                }
                if let retryAfterDate = cdsError.retryAfterDate {
                    retryAfter = max(retryAfter, retryAfterDate.timeIntervalSinceNow)
                }
            }

            // TODO: Abort if another contact intersection succeeds in the meantime.
            Logger.warn("Contact intersection failed with error: \(error). Rescheduling.")
            return Guarantee.after(seconds: retryAfter).then(on: DispatchQueue.global()) {
                self.intersectContacts(phoneNumbers, retryDelaySeconds: retryDelaySeconds * 2)
            }
        }
    }

    private static let unknownAddressFetchDateMap = AtomicDictionary<Aci, Date>(lock: .sharedGlobal)

    @objc(fetchProfileForUnknownAddress:)
    func fetchProfile(forUnknownAddress address: SignalServiceAddress) {
        // We only consider ACIs b/c PNIs will never give us a name other than "Unknown".
        guard let aci = address.serviceId as? Aci else {
            return
        }
        let minFetchInterval = kMinuteInterval * 30
        if
            let lastFetchDate = Self.unknownAddressFetchDateMap[aci],
            abs(lastFetchDate.timeIntervalSinceNow) < minFetchInterval
        {
            return
        }

        Self.unknownAddressFetchDateMap[aci] = Date()

        let profileFetcher = SSKEnvironment.shared.profileFetcherRef
        _ = profileFetcher.fetchProfileSync(for: aci, options: [.opportunistic])
    }

    // MARK: - System Contacts

    private func setFetchedSystemContacts(_ fetchedSystemContacts: FetchedSystemContacts) {
        swiftValues.systemContactsCache.fetchedSystemContacts.set(fetchedSystemContacts)
        swiftValues.cnContactCache.removeAllObjects()
        NotificationCenter.default.postNotificationNameAsync(.OWSContactsManagerContactsDidChange, object: nil)
    }

    public func cnContact(withId cnContactId: String?) -> CNContact? {
        guard let cnContactId else {
            return nil
        }
        if let cnContact = swiftValues.cnContactCache[cnContactId] {
            return cnContact
        }
        let cnContact = systemContactsFetcher.fetchCNContact(contactId: cnContactId)
        if let cnContact {
            swiftValues.cnContactCache[cnContactId] = cnContact
        }
        return cnContact
    }

    public func cnContactId(for phoneNumber: String) -> String? {
        guard let phoneNumber = E164(phoneNumber) else {
            return nil
        }
        let fetchedSystemContacts = swiftValues.systemContactsCache.fetchedSystemContacts.get()
        let canonicalPhoneNumber = CanonicalPhoneNumber(nonCanonicalPhoneNumber: phoneNumber)
        return fetchedSystemContacts?.phoneNumberToContactRef[canonicalPhoneNumber]?.cnContactId
    }

    // MARK: - Display Names

    private func displayNamesRefinery(
        for addresses: [SignalServiceAddress],
        transaction: SDSAnyReadTransaction
    ) -> Refinery<SignalServiceAddress, DisplayName> {
        let tx = transaction.asV2Read
        return .init(addresses).refine { addresses -> [DisplayName?] in
            return addresses.map { address -> DisplayName? in
                swiftValues.recipientDatabaseTable.fetchRecipient(address: address, tx: tx)
                    .flatMap { swiftValues.nicknameManager.fetchNickname(for: $0, tx: tx) }
                    .flatMap(ProfileName.init(nicknameRecord:))
                    .map(DisplayName.nickname(_:))
            }
        }.refine { addresses -> [DisplayName?] in
            // Prefer a saved name from system contacts, if available.
            return systemContactNames(for: addresses, tx: transaction)
                .map { $0.map { .systemContactName($0) } }
        }.refine { addresses -> [DisplayName?] in
            return profileManager.fetchUserProfiles(for: Array(addresses), tx: transaction)
                .map { $0?.nameComponents.map { .profileName($0) } }
        }.refine { addresses -> [DisplayName?] in
            return addresses.map { $0.e164.map { .phoneNumber($0) } }
        }.refine { addresses -> [DisplayName?] in
            return swiftValues.usernameLookupManager.fetchUsernames(
                forAddresses: addresses,
                transaction: transaction.asV2Read
            ).map { $0.map { .username($0) } }
        }.refine { addresses in
            let recipientDatabaseTable = DependenciesBridge.shared.recipientDatabaseTable
            return addresses.lazy.map { address -> DisplayName in
                let signalRecipient = recipientDatabaseTable.fetchRecipient(
                    address: address,
                    tx: transaction.asV2Read
                )
                if let signalRecipient, !signalRecipient.isRegistered {
                    return .deletedAccount
                } else {
                    self.fetchProfile(forUnknownAddress: address)
                    return .unknown
                }
            } as [DisplayName?]
        }
    }

    public func displayNamesByAddress(
        for addresses: [SignalServiceAddress],
        transaction: SDSAnyReadTransaction
    ) -> [SignalServiceAddress: DisplayName] {
        Dictionary(displayNamesRefinery(for: addresses, transaction: transaction))
    }

    public func displayNames(for addresses: [SignalServiceAddress], tx: SDSAnyReadTransaction) -> [DisplayName] {
        displayNamesRefinery(for: addresses, transaction: tx).values.map { $0! }
    }

    private func systemContactNames(for addresses: some Sequence<SignalServiceAddress>, tx: SDSAnyReadTransaction) -> [DisplayName.SystemContactName?] {
        let phoneNumbers = addresses.map { $0.phoneNumber }
        var compactedResult = systemContactNames(for: phoneNumbers.compacted(), tx: tx).makeIterator()
        return phoneNumbers.map { $0 != nil ? compactedResult.next()! : nil }
    }

    public func leaseCacheSize(_ cacheSize: Int) -> ModelReadCacheSizeLease {
        return modelReadCaches.signalAccountReadCache.leaseCacheSize(cacheSize)
    }

    public func fetchSignalAccounts(
        for phoneNumbers: [String],
        transaction: SDSAnyReadTransaction
    ) -> [SignalAccount?] {
        return modelReadCaches.signalAccountReadCache.getSignalAccounts(
            phoneNumbers: phoneNumbers,
            transaction: transaction
        )
    }

    public func shortestDisplayName(
        forGroupMember groupMember: SignalServiceAddress,
        inGroup groupModel: TSGroupModel,
        transaction: SDSAnyReadTransaction
    ) -> String {
        let displayName = self.displayName(for: groupMember, tx: transaction)
        let fullName = displayName.resolvedValue()
        let shortName = displayName.resolvedValue(useShortNameIfAvailable: true)
        guard fullName != shortName else {
            return fullName
        }

        // Try to return just the short name unless the group contains another
        // member with the same short name.

        for otherMember in groupModel.groupMembership.fullMembers {
            guard otherMember != groupMember else { continue }

            // Use the full name if the member's short name matches
            // another member's full or short name.
            let otherDisplayName = self.displayName(for: otherMember, tx: transaction)
            guard otherDisplayName.resolvedValue() != shortName else { return fullName }
            guard otherDisplayName.resolvedValue(useShortNameIfAvailable: true) != shortName else { return fullName }
        }

        return shortName
    }
}

// MARK: - ContactManager

extension ContactManager {
    public func nameForAddress(
        _ address: SignalServiceAddress,
        localUserDisplayMode: LocalUserDisplayMode,
        short: Bool,
        transaction: SDSAnyReadTransaction
    ) -> NSAttributedString {
        return { () -> String in
            if address.isLocalAddress {
                switch localUserDisplayMode {
                case .noteToSelf:
                    return MessageStrings.noteToSelf
                case .asLocalUser:
                    return CommonStrings.you
                case .asUser:
                    break
                }
            }
            let displayName = self.displayName(for: address, tx: transaction)
            return displayName.resolvedValue(useShortNameIfAvailable: short)
        }().asAttributedString
    }

    public func displayName(for thread: TSThread, transaction: SDSAnyReadTransaction) -> String {
        return displayName(for: thread, tx: transaction)?.resolvedValue() ?? ""
    }

    public func displayName(for thread: TSThread, tx: SDSAnyReadTransaction) -> ThreadDisplayName? {
        if thread.isNoteToSelf {
            return .noteToSelf
        }
        switch thread {
        case let thread as TSContactThread:
            return .contactThread(displayName(for: thread.contactAddress, tx: tx))
        case let thread as TSGroupThread:
            return .groupThread(thread.groupNameOrDefault)
        default:
            owsFailDebug("Unexpected thread type: \(type(of: thread))")
            return nil
        }
    }

    public func sortSignalServiceAddresses(
        _ addresses: some Sequence<SignalServiceAddress>,
        transaction: SDSAnyReadTransaction
    ) -> [SignalServiceAddress] {
        return sortedComparableNames(for: addresses, tx: transaction).map { $0.address }
    }

    public func sortedComparableNames(
        for addresses: some Sequence<SignalServiceAddress>,
        tx: SDSAnyReadTransaction
    ) -> [ComparableDisplayName] {
        let addresses = Array(addresses)
        let displayNames = self.displayNames(for: addresses, tx: tx)
        let config = DisplayName.ComparableValue.Config.current()
        return zip(addresses, displayNames).map { (address, displayName) in
            return ComparableDisplayName(
                address: address,
                displayName: displayName,
                config: config
            )
        }.sorted(by: <)
    }
}

// MARK: - ThreadDisplayName

public enum ThreadDisplayName {
    case noteToSelf
    case contactThread(DisplayName)
    case groupThread(String)

    public func resolvedValue() -> String {
        switch self {
        case .noteToSelf:
            return MessageStrings.noteToSelf
        case .contactThread(let displayName):
            return displayName.resolvedValue()
        case .groupThread(let groupName):
            return groupName
        }
    }
}
