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

import Foundation
import LibSignalClient

public class GroupsV2Impl: GroupsV2, Dependencies {
    private var urlSession: OWSURLSessionProtocol {
        return self.signalService.urlSessionForStorageService()
    }

    private let authCredentialStore: AuthCredentialStore
    private let authCredentialManager: any AuthCredentialManager

    init(
        authCredentialStore: AuthCredentialStore,
        authCredentialManager: any AuthCredentialManager
    ) {
        self.authCredentialStore = authCredentialStore
        self.authCredentialManager = authCredentialManager

        SwiftSingletons.register(self)

        AppReadiness.runNowOrWhenAppWillBecomeReady {
            guard DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered else {
                return
            }

            Task {
                do {
                    try await GroupManager.ensureLocalProfileHasCommitmentIfNecessary()
                } catch {
                    Logger.warn("Local profile update failed with error: \(error)")
                }
            }
        }

        AppReadiness.runNowOrWhenAppDidBecomeReadyAsync {
            Self.enqueueRestoreGroupPass(account: .implicit())
        }

        observeNotifications()
    }

    // MARK: - Notifications

    private func observeNotifications() {
        NotificationCenter.default.addObserver(self,
                                               selector: #selector(reachabilityChanged),
                                               name: SSKReachability.owsReachabilityDidChange,
                                               object: nil)
        NotificationCenter.default.addObserver(self,
                                               selector: #selector(didBecomeActive),
                                               name: .OWSApplicationDidBecomeActive,
                                               object: nil)
    }

    @objc
    private func didBecomeActive() {
        AssertIsOnMainThread()

        AppReadiness.runNowOrWhenAppDidBecomeReadyAsync {
            GroupsV2Impl.enqueueRestoreGroupPass(account: .implicit())
        }
    }

    @objc
    private func reachabilityChanged() {
        AssertIsOnMainThread()

        GroupsV2Impl.enqueueRestoreGroupPass(account: .implicit())
    }

    // MARK: - Create Group

    public func createNewGroupOnService(
        groupModel: TSGroupModelV2,
        disappearingMessageToken: DisappearingMessageToken
    ) async throws {
        let groupV2Params = try groupModel.groupV2Params()

        do {
            let groupProto = try await self.buildProtoToCreateNewGroupOnService(
                groupModel: groupModel,
                disappearingMessageToken: disappearingMessageToken,
                groupV2Params: groupV2Params
            )
            let requestBuilder: RequestBuilder = { authCredential -> GroupsV2Request in
                return try StorageService.buildNewGroupRequest(
                    groupProto: groupProto,
                    groupV2Params: groupV2Params,
                    authCredential: authCredential
                )
            }

            // New-group protos contain a profile key credential for each
            // member. If the proto we're submitting contains a profile key
            // credential that's expired, we'll get back a generic 400.
            // Consequently, if we get a 400 we should attempt to recover
            // (see below).

            _ = try await performServiceRequest(
                requestBuilder: requestBuilder,
                groupId: nil,
                behavior400: .reportForRecovery,
                behavior403: .fail,
                behavior404: .fail
            )
        } catch {
            guard case GroupsV2Error.serviceRequestHitRecoverable400 = error else {
                throw error
            }

            // We likely failed to create the group because one of the profile
            // key credentials we submitted was expired, possibly due to drift
            // between our local clock and the service. We should try again
            // exactly once, forcing a refresh of all the credentials first.

            let groupProto = try await buildProtoToCreateNewGroupOnService(
                groupModel: groupModel,
                disappearingMessageToken: disappearingMessageToken,
                groupV2Params: groupV2Params,
                shouldForceRefreshProfileKeyCredentials: true
            )

            let requestBuilder: RequestBuilder = { authCredential -> GroupsV2Request in
                return try StorageService.buildNewGroupRequest(
                    groupProto: groupProto,
                    groupV2Params: groupV2Params,
                    authCredential: authCredential
                )
            }

            _ = try await performServiceRequest(
                requestBuilder: requestBuilder,
                groupId: nil,
                behavior400: .fail,
                behavior403: .fail,
                behavior404: .fail
            )
        }
    }

    /// Construct the proto to create a new group on the service.
    /// - Parameters:
    ///   - shouldForceRefreshProfileKeyCredentials: Whether we should force-refresh PKCs for the group members.
    private func buildProtoToCreateNewGroupOnService(
        groupModel: TSGroupModelV2,
        disappearingMessageToken: DisappearingMessageToken,
        groupV2Params: GroupV2Params,
        shouldForceRefreshProfileKeyCredentials: Bool = false
    ) async throws -> GroupsProtoGroup {
        guard let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aci else {
            throw OWSAssertionError("Missing localAci.")
        }

        // Gather the ACIs for all full (not invited) members, and get profile key
        // credentials for them. By definition, we cannot get a PKC for the invited
        // members.
        let acis: [Aci] = groupModel.groupMembers.compactMap { address in
            guard let aci = address.aci else {
                owsFailDebug("Address of full member in new group missing ACI.")
                return nil
            }
            return aci
        }

        guard acis.contains(localAci) else {
            throw OWSAssertionError("localUuid is not a member.")
        }

        let profileKeyCredentialMap = try await loadProfileKeyCredentials(
            for: acis,
            forceRefresh: shouldForceRefreshProfileKeyCredentials
        )
        return try GroupsV2Protos.buildNewGroupProto(
            groupModel: groupModel,
            disappearingMessageToken: disappearingMessageToken,
            groupV2Params: groupV2Params,
            profileKeyCredentialMap: profileKeyCredentialMap,
            localAci: localAci
        )
    }

    // MARK: - Update Group

    private struct UpdatedV2Group {
        public let groupThread: TSGroupThread
        public let changeActionsProtoData: Data

        public init(groupThread: TSGroupThread,
                    changeActionsProtoData: Data) {
            self.groupThread = groupThread
            self.changeActionsProtoData = changeActionsProtoData
        }
    }

    // This method updates the group on the service.  This corresponds to:
    //
    // * The local user editing group state (e.g. adding a member).
    // * The local user accepting an invite.
    // * The local user reject an invite.
    // * The local user leaving the group.
    // * etc.
    //
    // Whenever we do this, there's a few follow-on actions that we always want to do (on success):
    //
    // * Update the group in the local database to reflect the update.
    // * Insert "group update info" messages in the conversation history.
    // * Send "group update" messages to other members &  linked devices.
    //
    // We do those things here as well, to DRY them up and to ensure they're always
    // done immediately and in a consistent way.
    private func updateExistingGroupOnService(changes: GroupsV2OutgoingChanges) async throws -> TSGroupThread {

        let groupId = changes.groupId
        let groupSecretParamsData = changes.groupSecretParamsData
        let groupV2Params = try GroupV2Params(groupSecretParamsData: groupSecretParamsData)

        var builtGroupChange: GroupsV2BuiltGroupChange
        let httpResponse: HTTPResponse
        do {
            (builtGroupChange, httpResponse) = try await buildGroupChangeProtoAndTryToUpdateGroupOnService(
                groupId: groupId,
                groupV2Params: groupV2Params,
                changes: changes
            )
        } catch {
            switch error {
            case GroupsV2Error.conflictingChangeOnService:
                // If we failed because a conflicting change has already been
                // committed to the service, we should refresh our local state
                // for the group and try again to apply our changes.

                _ = try await groupV2Updates.tryToRefreshV2GroupUpToCurrentRevisionImmediately(
                    groupId: groupId,
                    groupSecretParamsData: groupSecretParamsData
                ).awaitable()

                (builtGroupChange, httpResponse) = try await buildGroupChangeProtoAndTryToUpdateGroupOnService(
                    groupId: groupId,
                    groupV2Params: groupV2Params,
                    changes: changes
                )
            case GroupsV2Error.serviceRequestHitRecoverable400:
                // We likely got the 400 because we submitted a proto with
                // profile key credentials and one of them was expired, possibly
                // due to drift between our local clock and the service. We
                // should try again exactly once, forcing a refresh of all the
                // credentials first.

                (builtGroupChange, httpResponse) = try await buildGroupChangeProtoAndTryToUpdateGroupOnService(
                    groupId: groupId,
                    groupV2Params: groupV2Params,
                    changes: changes,
                    shouldForceRefreshProfileKeyCredentials: true,
                    forceFailOn400: true
                )
            default:
                throw error
            }
        }

        guard let responseBodyData = httpResponse.responseBodyData else {
            throw OWSAssertionError("Missing data in response body!")
        }

        return try await handleGroupUpdatedOnService(
            responseBodyData: responseBodyData,
            builtGroupChange: builtGroupChange,
            changes: changes,
            groupId: groupId,
            groupV2Params: groupV2Params
        )
    }

    /// Construct a group change proto from the given `changes` for the given
    /// `groupId`, and attempt to commit the group change to the service.
    /// - Parameters:
    ///   - shouldForceRefreshProfileKeyCredentials: Whether we should force-refresh PKCs for any new members while building the proto.
    ///   - forceFailOn400: Whether we should force failure when receiving a 400. If `false`, may instead report expired PKCs.
    private func buildGroupChangeProtoAndTryToUpdateGroupOnService(
        groupId: Data,
        groupV2Params: GroupV2Params,
        changes: GroupsV2OutgoingChanges,
        shouldForceRefreshProfileKeyCredentials: Bool = false,
        forceFailOn400: Bool = false
    ) async throws -> (GroupsV2BuiltGroupChange, HTTPResponse) {
        let (groupThread, dmToken) = try NSObject.databaseStorage.read { tx in
            guard let groupThread = TSGroupThread.fetch(groupId: groupId, transaction: tx) else {
                throw OWSAssertionError("Thread does not exist.")
            }

            let dmConfigurationStore = DependenciesBridge.shared.disappearingMessagesConfigurationStore
            let dmConfiguration = dmConfigurationStore.fetchOrBuildDefault(for: .thread(groupThread), tx: tx.asV2Read)

            return (groupThread, dmConfiguration.asToken)
        }

        guard let groupModel = groupThread.groupModel as? TSGroupModelV2 else {
            throw OWSAssertionError("Invalid group model.")
        }

        let builtGroupChange = try await changes.buildGroupChangeProto(
            currentGroupModel: groupModel,
            currentDisappearingMessageToken: dmToken,
            forceRefreshProfileKeyCredentials: shouldForceRefreshProfileKeyCredentials
        ).awaitable()

        var behavior400: Behavior400 = .fail
        if
            !forceFailOn400,
            builtGroupChange.proto.containsProfileKeyCredentials
        {
            // If the proto we're submitting contains a profile key credential
            // that's expired, we'll get back a generic 400. Consequently, if
            // we're submitting a proto with PKCs, and we get a 400, we should
            // attempt to recover.

            behavior400 = .reportForRecovery
        }

        let requestBuilder: RequestBuilder = { authCredential in
            return try StorageService.buildUpdateGroupRequest(
                groupChangeProto: builtGroupChange.proto,
                groupV2Params: groupV2Params,
                authCredential: authCredential,
                groupInviteLinkPassword: nil
            )
        }

        let response = try await performServiceRequest(
            requestBuilder: requestBuilder,
            groupId: groupId,
            behavior400: behavior400,
            behavior403: .fetchGroupUpdates,
            behavior404: .fail
        )

        return (builtGroupChange, response)
    }

    private func handleGroupUpdatedOnService(
        responseBodyData: Data,
        builtGroupChange: GroupsV2BuiltGroupChange,
        changes: GroupsV2OutgoingChanges,
        groupId: Data,
        groupV2Params: GroupV2Params
    ) async throws -> TSGroupThread {
        let changeActionsProto = try GroupsV2Protos.parseAndVerifyChangeActionsProto(
            responseBodyData,
            ignoreSignature: true
        )

        // Collect avatar state from our change set so that we can
        // avoid downloading any avatars we just uploaded while
        // applying the change set locally.
        let downloadedAvatars = GroupV2DownloadedAvatars.from(changes: changes)

        // We can ignoreSignature because these protos came from the service.
        let groupThread = try await updateGroupWithChangeActions(
            groupId: groupId,
            spamReportingMetadata: .learnedByLocallyInitatedRefresh,
            changeActionsProto: changeActionsProto,
            justUploadedAvatars: downloadedAvatars,
            ignoreSignature: true,
            groupV2Params: groupV2Params
        )
        let updatedV2Group = UpdatedV2Group(groupThread: groupThread, changeActionsProtoData: responseBodyData)

        switch builtGroupChange.groupUpdateMessageBehavior {
        case .sendNothing:
            return updatedV2Group.groupThread
        case .sendUpdateToOtherGroupMembers:
            break
        }

        _ = GroupManager.sendGroupUpdateMessage(
            thread: updatedV2Group.groupThread,
            changeActionsProtoData: updatedV2Group.changeActionsProtoData
        )

        await sendGroupUpdateMessageToRemovedUsers(
            groupThread: updatedV2Group.groupThread,
            groupChangeProto: builtGroupChange.proto,
            changeActionsProtoData: updatedV2Group.changeActionsProtoData,
            groupV2Params: groupV2Params
        )

        return updatedV2Group.groupThread
    }

    private func membersRemovedByChangeActions(
        groupChangeProto: GroupsProtoGroupChangeActions,
        groupV2Params: GroupV2Params
    ) -> [ServiceId] {
        var serviceIds = [ServiceId]()
        for action in groupChangeProto.deleteMembers {
            guard let userId = action.deletedUserID else {
                owsFailDebug("Missing userID.")
                continue
            }
            do {
                serviceIds.append(try groupV2Params.aci(for: userId))
            } catch {
                owsFailDebug("Error: \(error)")
            }
        }
        for action in groupChangeProto.deletePendingMembers {
            guard let userId = action.deletedUserID else {
                owsFailDebug("Missing userID.")
                continue
            }
            do {
                serviceIds.append(try groupV2Params.serviceId(for: userId))
            } catch {
                owsFailDebug("Error: \(error)")
            }
        }
        for action in groupChangeProto.deleteRequestingMembers {
            guard let userId = action.deletedUserID else {
                owsFailDebug("Missing userID.")
                continue
            }
            do {
                serviceIds.append(try groupV2Params.aci(for: userId))
            } catch {
                owsFailDebug("Error: \(error)")
            }
        }
        return serviceIds
    }

    private func sendGroupUpdateMessageToRemovedUsers(
        groupThread: TSGroupThread,
        groupChangeProto: GroupsProtoGroupChangeActions,
        changeActionsProtoData: Data,
        groupV2Params: GroupV2Params
    ) async {
        let serviceIds = membersRemovedByChangeActions(groupChangeProto: groupChangeProto, groupV2Params: groupV2Params)

        guard !serviceIds.isEmpty else {
            return
        }

        guard let groupModel = groupThread.groupModel as? TSGroupModelV2 else {
            owsFailDebug("Invalid groupModel.")
            return
        }

        let plaintextData: Data
        do {
            let groupV2Context = try GroupsV2Protos.buildGroupContextV2Proto(
                groupModel: groupModel,
                changeActionsProtoData: changeActionsProtoData
            )

            let dataBuilder = SSKProtoDataMessage.builder()
            dataBuilder.setGroupV2(groupV2Context)
            dataBuilder.setRequiredProtocolVersion(1)

            let dataProto = try dataBuilder.build()
            let contentBuilder = SSKProtoContent.builder()
            contentBuilder.setDataMessage(dataProto)
            plaintextData = try contentBuilder.buildSerializedData()
        } catch {
            owsFailDebug("Error: \(error)")
            return
        }

        await databaseStorage.awaitableWrite { tx in
            for serviceId in serviceIds {
                let address = SignalServiceAddress(serviceId)
                let contactThread = TSContactThread.getOrCreateThread(withContactAddress: address, transaction: tx)
                let message = OWSStaticOutgoingMessage(thread: contactThread, plaintextData: plaintextData, transaction: tx)
                let preparedMessage = PreparedOutgoingMessage.preprepared(
                    transientMessageWithoutAttachments: message
                )
                SSKEnvironment.shared.messageSenderJobQueueRef.add(message: preparedMessage, transaction: tx)
            }
        }
    }

    // This method can process protos from another client, so there's a possibility
    // the serverGuid may be present and can be passed along to record with the update.
    public func updateGroupWithChangeActions(
        groupId: Data,
        spamReportingMetadata: GroupUpdateSpamReportingMetadata,
        changeActionsProto: GroupsProtoGroupChangeActions,
        ignoreSignature: Bool,
        groupSecretParamsData: Data
    ) async throws -> TSGroupThread {
        let groupV2Params = try GroupV2Params(groupSecretParamsData: groupSecretParamsData)
        return try await _updateGroupWithChangeActions(
            groupId: groupId,
            spamReportingMetadata: spamReportingMetadata,
            changeActionsProto: changeActionsProto,
            justUploadedAvatars: nil,
            ignoreSignature: ignoreSignature,
            groupV2Params: groupV2Params
        )
    }

    private func updateGroupWithChangeActions(
        groupId: Data,
        spamReportingMetadata: GroupUpdateSpamReportingMetadata,
        changeActionsProto: GroupsProtoGroupChangeActions,
        justUploadedAvatars: GroupV2DownloadedAvatars?,
        ignoreSignature: Bool,
        groupV2Params: GroupV2Params
    ) async throws -> TSGroupThread {
        return try await _updateGroupWithChangeActions(
            groupId: groupId,
            spamReportingMetadata: spamReportingMetadata,
            changeActionsProto: changeActionsProto,
            justUploadedAvatars: justUploadedAvatars,
            ignoreSignature: ignoreSignature,
            groupV2Params: groupV2Params
        )
    }

    private func _updateGroupWithChangeActions(
        groupId: Data,
        spamReportingMetadata: GroupUpdateSpamReportingMetadata,
        changeActionsProto: GroupsProtoGroupChangeActions,
        justUploadedAvatars: GroupV2DownloadedAvatars?,
        ignoreSignature: Bool,
        groupV2Params: GroupV2Params
    ) async throws -> TSGroupThread {
        let downloadedAvatars = try await fetchAllAvatarData(
            changeActionsProto: changeActionsProto,
            justUploadedAvatars: justUploadedAvatars,
            ignoreSignature: ignoreSignature,
            groupV2Params: groupV2Params
        )
        return try await NSObject.databaseStorage.awaitableWrite { tx in
            try self.groupV2Updates.updateGroupWithChangeActions(
                groupId: groupId,
                spamReportingMetadata: spamReportingMetadata,
                changeActionsProto: changeActionsProto,
                downloadedAvatars: downloadedAvatars,
                transaction: tx
            )
        }
    }

    // MARK: - Upload Avatar

    public func uploadGroupAvatar(
        avatarData: Data,
        groupSecretParamsData: Data
    ) async throws -> String {
        let groupV2Params = try GroupV2Params(groupSecretParamsData: groupSecretParamsData)
        return try await uploadGroupAvatar(avatarData: avatarData, groupV2Params: groupV2Params)
    }

    private func uploadGroupAvatar(
        avatarData: Data,
        groupV2Params: GroupV2Params
    ) async throws -> String {

        let requestBuilder: RequestBuilder = { (authCredential) in
            try StorageService.buildGroupAvatarUploadFormRequest(
                groupV2Params: groupV2Params,
                authCredential: authCredential
            )
        }

        let groupId = try groupId(forGroupSecretParamsData: groupV2Params.groupSecretParamsData)
        let response = try await performServiceRequest(
            requestBuilder: requestBuilder,
            groupId: groupId,
            behavior400: .fail,
            behavior403: .fetchGroupUpdates,
            behavior404: .fail
        )

        guard let protoData = response.responseBodyData else {
            throw OWSAssertionError("Invalid responseObject.")
        }
        let avatarUploadAttributes = try GroupsProtoAvatarUploadAttributes(serializedData: protoData)
        let uploadForm = try Upload.CDN0.Form.parse(proto: avatarUploadAttributes)
        let encryptedData = try groupV2Params.encryptGroupAvatar(avatarData)
        return try await Upload.CDN0.upload(data: encryptedData, uploadForm: uploadForm)
    }

    // MARK: - Fetch Current Group State

    public func fetchCurrentGroupV2Snapshot(groupModel: TSGroupModelV2) async throws -> GroupV2Snapshot {
        // Collect the avatar state to avoid an unnecessary download in the
        // case where we've just created this group but not yet inserted it
        // into the database.
        let justUploadedAvatars = GroupV2DownloadedAvatars.from(groupModel: groupModel)
        return try await fetchCurrentGroupV2Snapshot(
            groupSecretParamsData: groupModel.secretParamsData,
            justUploadedAvatars: justUploadedAvatars
        )
    }

    public func fetchCurrentGroupV2Snapshot(groupSecretParamsData: Data) async throws -> GroupV2Snapshot {
        return try await fetchCurrentGroupV2Snapshot(
            groupSecretParamsData: groupSecretParamsData,
            justUploadedAvatars: nil
        )
    }

    private func fetchCurrentGroupV2Snapshot(
        groupSecretParamsData: Data,
        justUploadedAvatars: GroupV2DownloadedAvatars?
    ) async throws -> GroupV2Snapshot {
        let groupV2Params = try GroupV2Params(groupSecretParamsData: groupSecretParamsData)
        return try await fetchCurrentGroupV2Snapshot(groupV2Params: groupV2Params, justUploadedAvatars: justUploadedAvatars)
    }

    private func fetchCurrentGroupV2Snapshot(
        groupV2Params: GroupV2Params,
        justUploadedAvatars: GroupV2DownloadedAvatars?
    ) async throws -> GroupV2Snapshot {
        let requestBuilder: RequestBuilder = { (authCredential) in
            try StorageService.buildFetchCurrentGroupV2SnapshotRequest(
                groupV2Params: groupV2Params,
                authCredential: authCredential
            )
        }

        let groupId = try groupId(forGroupSecretParamsData: groupV2Params.groupSecretParamsData)
        let response = try await performServiceRequest(
            requestBuilder: requestBuilder,
            groupId: groupId,
            behavior400: .fail,
            behavior403: .removeFromGroup,
            behavior404: .groupDoesNotExistOnService
        )

        guard let groupProtoData = response.responseBodyData else {
            throw OWSAssertionError("Invalid responseObject.")
        }

        let groupProto = try GroupsProtoGroup(serializedData: groupProtoData)

        // We can ignoreSignature; these protos came from the service.
        let downloadedAvatars = try await fetchAllAvatarData(
            groupProto: groupProto,
            justUploadedAvatars: justUploadedAvatars,
            ignoreSignature: true,
            groupV2Params: groupV2Params
        )

        return try GroupsV2Protos.parse(groupProto: groupProto, downloadedAvatars: downloadedAvatars, groupV2Params: groupV2Params)
    }

    // MARK: - Fetch Group Change Actions

    func fetchGroupChangeActions(
        groupSecretParamsData: Data,
        includeCurrentRevision: Bool
    ) async throws -> GroupChangePage {
        let groupId = try self.groupId(forGroupSecretParamsData: groupSecretParamsData)
        let groupV2Params = try GroupV2Params(groupSecretParamsData: groupSecretParamsData)
        return try await fetchGroupChangeActions(
            groupId: groupId,
            groupV2Params: groupV2Params,
            includeCurrentRevision: includeCurrentRevision
        )
    }

    struct GroupChangePage {
        let changes: [GroupV2Change]
        let earlyEnd: UInt32?

        fileprivate static func parseEarlyEnd(fromGroupRangeHeader header: String?) -> UInt32? {
            guard let header = header else {
                Logger.warn("Missing Content-Range for group update request with 206 response")
                return nil
            }

            let pattern = try! NSRegularExpression(pattern: #"^versions (\d+)-(\d+)/(\d+)$"#)
            guard let match = pattern.firstMatch(in: header, range: header.entireRange) else {
                Logger.warn("Unparsable Content-Range for group update request: \(header)")
                return nil
            }

            guard let earlyEndRange = Range(match.range(at: 1), in: header) else {
                owsFailDebug("Could not translate NSRange to Range<String.Index>")
                return nil
            }

            guard let earlyEndValue = UInt32(header[earlyEndRange]) else {
                Logger.warn("Invalid early-end in Content-Range for group update request: \(header)")
                return nil
            }

            return earlyEndValue
        }
    }

    private func fetchGroupChangeActions(
        groupId: Data,
        groupV2Params: GroupV2Params,
        includeCurrentRevision: Bool
    ) async throws -> GroupChangePage {
        let groupThread = NSObject.databaseStorage.read { transaction in
            TSGroupThread.fetch(groupId: groupId, transaction: transaction)
        }

        let fromRevision: UInt32
        let requireSnapshotForFirstChange: Bool

        if
            let groupThread = groupThread,
            let groupModel = groupThread.groupModel as? TSGroupModelV2,
            groupModel.groupMembership.isLocalUserFullOrInvitedMember
        {
            // We're being told about a group we are aware of and are
            // already a member of. In this case, we can figure out which
            // revision we want to start with from local data.

            if includeCurrentRevision {
                fromRevision = groupModel.revision
                requireSnapshotForFirstChange = true
            } else {
                fromRevision = groupModel.revision + 1
                requireSnapshotForFirstChange = false
            }
        } else {
            // We're being told about a thread we either have never heard
            // of, or don't yet know we're a member of. In this case, we
            // need to ask the service which revision we joined at, and
            // request revisions from there. We should also get the
            // snapshot, since there may be revisions we were not in the
            // group to witness, and we want to make sure that state is
            // reflected.

            fromRevision = try await getRevisionLocalUserWasAddedToGroup(groupId: groupId, groupV2Params: groupV2Params)
            requireSnapshotForFirstChange = true
        }

        let fetchGroupChangesRequestBuilder: RequestBuilder = { authCredential in
            return try StorageService.buildFetchGroupChangeActionsRequest(
                groupV2Params: groupV2Params,
                fromRevision: fromRevision,
                requireSnapshotForFirstChange: requireSnapshotForFirstChange,
                authCredential: authCredential
            )
        }

        // At this stage, we know we are requesting for a revision at which
        // we are a member. Therefore, 403s should be treated as failure.
        let response = try await performServiceRequest(
            requestBuilder: fetchGroupChangesRequestBuilder,
            groupId: groupId,
            behavior400: .fail,
            behavior403: .fail,
            behavior404: .fail
        )
        guard let groupChangesProtoData = response.responseBodyData else {
            throw OWSAssertionError("Invalid responseObject.")
        }
        let earlyEnd: UInt32?
        if response.responseStatusCode == 206 {
            let groupRangeHeader = response.responseHeaders["content-range"]
            earlyEnd = GroupChangePage.parseEarlyEnd(fromGroupRangeHeader: groupRangeHeader)
        } else {
            earlyEnd = nil
        }
        let groupChangesProto = try GroupsProtoGroupChanges(serializedData: groupChangesProtoData)

        // We can ignoreSignature; these protos came from the service.
        let downloadedAvatars = try await fetchAllAvatarData(
            groupChangesProto: groupChangesProto,
            ignoreSignature: true,
            groupV2Params: groupV2Params
        )
        let changes = try GroupsV2Protos.parseChangesFromService(
            groupChangesProto: groupChangesProto,
            downloadedAvatars: downloadedAvatars,
            groupV2Params: groupV2Params
        )
        return GroupChangePage(changes: changes, earlyEnd: earlyEnd)
    }

    private func getRevisionLocalUserWasAddedToGroup(
        groupId: Data,
        groupV2Params: GroupV2Params
    ) async throws -> UInt32 {
        let getJoinedAtRevisionRequestBuilder: RequestBuilder = { authCredential in
            try StorageService.buildGetJoinedAtRevisionRequest(
                groupV2Params: groupV2Params,
                authCredential: authCredential
            )
        }

        // We might get a 403 if we are not a member of the group, e.g. if
        // we are joining via invite link. Passing .ignore means we won't
        // retry, and will allow the "not a member" error to be thrown and
        // propagated upwards.
        let response = try await performServiceRequest(
            requestBuilder: getJoinedAtRevisionRequestBuilder,
            groupId: groupId,
            behavior400: .fail,
            behavior403: .ignore,
            behavior404: .fail
        )

        guard let memberData = response.responseBodyData else {
            throw OWSAssertionError("Response missing body data")
        }

        let memberProto = try GroupsProtoMember(serializedData: memberData)

        guard memberProto.hasJoinedAtRevision else {
            throw OWSAssertionError("Member proto missing joinedAtRevision")
        }

        return memberProto.joinedAtRevision
    }

    // MARK: - Avatar Downloads

    // Before we can apply snapshots/changes from the service, we
    // need to download all avatars they use.  We can skip downloads
    // in a couple of cases:
    //
    // * We just created the group.
    // * We just updated the group and we're applying those changes.
    private func fetchAllAvatarData(
        groupProto: GroupsProtoGroup? = nil,
        groupChangesProto: GroupsProtoGroupChanges? = nil,
        changeActionsProto: GroupsProtoGroupChangeActions? = nil,
        justUploadedAvatars: GroupV2DownloadedAvatars? = nil,
        ignoreSignature: Bool,
        groupV2Params: GroupV2Params
    ) async throws -> GroupV2DownloadedAvatars {

        var downloadedAvatars = GroupV2DownloadedAvatars()

        // Creating or updating a group is a multi-step process
        // that can involve uploading an avatar, updating the
        // group on the service, then updating the local database.
        // We can skip downloading an avatar that we just uploaded
        // using justUploadedAvatars.
        if let justUploadedAvatars = justUploadedAvatars {
            downloadedAvatars.merge(justUploadedAvatars)
        }

        let groupId = try groupId(forGroupSecretParamsData: groupV2Params.groupSecretParamsData)

        // First step - try to skip downloading the current group avatar.
        if
            let groupThread = (NSObject.databaseStorage.read { transaction in
                return TSGroupThread.fetch(groupId: groupId, transaction: transaction)
            }),
            let groupModel = groupThread.groupModel as? TSGroupModelV2
        {
            // Try to add avatar from group model, if any.
            downloadedAvatars.merge(GroupV2DownloadedAvatars.from(groupModel: groupModel))
        }

        let protoAvatarUrlPaths = try await GroupsV2Protos.collectAvatarUrlPaths(
            groupProto: groupProto,
            groupChangesProto: groupChangesProto,
            changeActionsProto: changeActionsProto,
            ignoreSignature: ignoreSignature,
            groupV2Params: groupV2Params
        ).awaitable()

        return try await fetchAvatarData(
            avatarUrlPaths: protoAvatarUrlPaths,
            downloadedAvatars: downloadedAvatars,
            groupV2Params: groupV2Params
        )
    }

    private func fetchAvatarData(
        avatarUrlPaths: [String],
        downloadedAvatars downloadedAvatarsParam: GroupV2DownloadedAvatars,
        groupV2Params: GroupV2Params
    ) async throws -> GroupV2DownloadedAvatars {
        var downloadedAvatars = downloadedAvatarsParam

        let avatars = try await firstly(on: DispatchQueue.global()) { () -> Promise<[(String, Data)]> in
            let undownloadedAvatarUrlPaths = Set(avatarUrlPaths).subtracting(downloadedAvatars.avatarUrlPaths)
            guard !undownloadedAvatarUrlPaths.isEmpty else {
                return Promise.value([])
            }

            // We need to "populate" any group changes that have a
            // avatar with the avatar data.
            var promises = [Promise<(String, Data)>]()
            for avatarUrlPath in undownloadedAvatarUrlPaths {
                let promise = firstly { () -> Promise<Data> in
                    Promise.wrapAsync {
                        try await self.fetchAvatarData(
                            avatarUrlPath: avatarUrlPath,
                            groupV2Params: groupV2Params
                        )
                    }
                }.recover(on: DispatchQueue.global()) { error -> Promise<Data> in
                    if let statusCode = error.httpStatusCode,
                       statusCode == 404 {
                        // Fulfill with empty data if service returns 404 status code.
                        // We don't want the group to be left in an unrecoverable state
                        // if the avatar is missing from the CDN.
                        return .value(Data())
                    }

                    throw error
                }.map(on: DispatchQueue.global()) { (avatarData: Data) -> Data in
                    guard avatarData.count > 0 else {
                        owsFailDebug("Empty avatarData.")
                        return avatarData
                    }
                    do {
                        return try groupV2Params.decryptGroupAvatar(avatarData) ?? Data()
                    } catch {
                        owsFailDebug("Invalid avatar data: \(error)")
                        // Empty avatar data will be discarded below.
                        return Data()
                    }
                }.map(on: DispatchQueue.global()) { (avatarData: Data) -> (String, Data) in
                    return (avatarUrlPath, avatarData)
                }
                promises.append(promise)
            }
            return Promise.when(fulfilled: promises)
        }.awaitable()

        for (avatarUrlPath, avatarData) in avatars {
            guard avatarData.count > 0 else {
                owsFailDebug("Empty avatarData.")
                continue
            }
            guard TSGroupModel.isValidGroupAvatarData(avatarData) else {
                owsFailDebug("Invalid group avatar")
                continue
            }
            downloadedAvatars.set(avatarData: avatarData, avatarUrlPath: avatarUrlPath)
        }
        return downloadedAvatars
    }

    let avatarDownloadQueue: OperationQueue = {
        let operationQueue = OperationQueue()
        operationQueue.name = "AvatarDownload"
        operationQueue.maxConcurrentOperationCount = 3
        return operationQueue
    }()

    private func fetchAvatarData(
        avatarUrlPath: String,
        groupV2Params: GroupV2Params
    ) async throws -> Data {
        let operation = GroupsV2AvatarDownloadOperation(urlPath: avatarUrlPath)
        let promise = operation.promise
        avatarDownloadQueue.addOperation(operation)
        return try await promise.awaitable()
    }

    // MARK: - Generic Group Change

    public func updateGroupV2(
        groupId: Data,
        groupSecretParamsData: Data,
        changesBlock: @escaping (GroupsV2OutgoingChanges) -> Void
    ) async throws -> TSGroupThread {
        let changes = GroupsV2OutgoingChangesImpl(
            groupId: groupId,
            groupSecretParamsData: groupSecretParamsData
        )
        changesBlock(changes)
        return try await updateExistingGroupOnService(changes: changes)
    }

    // MARK: - Rotate Profile Key

    private let profileKeyUpdater = GroupsV2ProfileKeyUpdater()

    public func scheduleAllGroupsV2ForProfileKeyUpdate(transaction: SDSAnyWriteTransaction) {
        profileKeyUpdater.scheduleAllGroupsV2ForProfileKeyUpdate(transaction: transaction)
    }

    public func processProfileKeyUpdates() {
        profileKeyUpdater.processProfileKeyUpdates()
    }

    public func updateLocalProfileKeyInGroup(groupId: Data, transaction: SDSAnyWriteTransaction) {
        profileKeyUpdater.updateLocalProfileKeyInGroup(groupId: groupId, transaction: transaction)
    }

    // MARK: - Perform Request

    private typealias RequestBuilder = (AuthCredentialWithPni) async throws -> GroupsV2Request

    /// Represents how we should respond to 400 status codes.
    enum Behavior400 {
        case fail
        case reportForRecovery
    }

    /// Represents how we should respond to 403 status codes.
    private enum Behavior403 {
        case fail
        case removeFromGroup
        case fetchGroupUpdates
        case ignore
        case reportInvalidOrBlockedGroupLink
        case localUserIsNotARequestingMember
    }

    /// Represents how we should respond to 404 status codes.
    private enum Behavior404 {
        case fail
        case groupDoesNotExistOnService
    }

    /// Make a request to the GV2 service, produced by the given
    /// `requestBuilder`. Specifies how to respond if the request results in
    /// certain errors.
    private func performServiceRequest(
        requestBuilder: @escaping RequestBuilder,
        groupId: Data?,
        behavior400: Behavior400,
        behavior403: Behavior403,
        behavior404: Behavior404,
        remainingRetries: UInt = 3
    ) async throws -> HTTPResponse {
        guard let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction else {
            throw OWSAssertionError("Missing localIdentifiers.")
        }

        let authCredential = try await authCredentialManager.fetchGroupAuthCredential(localIdentifiers: localIdentifiers)
        let request = try await requestBuilder(authCredential)

        do {
            return try await performServiceRequestAttempt(request: request)
        } catch {
            let retryIfPossible = { (error: Error) async throws -> HTTPResponse in
                if remainingRetries > 0 {
                    return try await self.performServiceRequest(
                        requestBuilder: requestBuilder,
                        groupId: groupId,
                        behavior400: behavior400,
                        behavior403: behavior403,
                        behavior404: behavior404,
                        remainingRetries: remainingRetries - 1
                    )
                } else {
                    throw error
                }
            }

            return try await self.tryRecoveryFromServiceRequestFailure(
                error: error,
                retryBlock: retryIfPossible,
                groupId: groupId,
                behavior400: behavior400,
                behavior403: behavior403,
                behavior404: behavior404
            )
        }
    }

    /// Upon error from performing a service request, attempt to recover based
    /// on the error and our 4XX behaviors.
    private func tryRecoveryFromServiceRequestFailure(
        error: Error,
        retryBlock: (Error) async throws -> HTTPResponse,
        groupId: Data?,
        behavior400: Behavior400,
        behavior403: Behavior403,
        behavior404: Behavior404
    ) async throws -> HTTPResponse {
        // Fall through to retry if retry-able,
        // otherwise reject immediately.
        if let statusCode = error.httpStatusCode {
            switch statusCode {
            case 400:
                switch behavior400 {
                case .fail:
                    owsFailDebug("Unexpected 400.")
                case .reportForRecovery:
                    throw GroupsV2Error.serviceRequestHitRecoverable400
                }

                throw error
            case 401:
                // Retry auth errors after retrieving new temporal credentials.
                await self.databaseStorage.awaitableWrite { tx in
                    self.authCredentialStore.removeAllGroupAuthCredentials(tx: tx.asV2Write)
                }
                return try await retryBlock(error)
            case 403:
                // 403 indicates that we are no longer in the group for
                // many (but not all) group v2 service requests.
                switch behavior403 {
                case .fail:
                    // We should never receive 403 when creating groups.
                    owsFailDebug("Unexpected 403.")
                case .ignore:
                    // We may get a 403 when fetching change actions if
                    // they are not yet a member - for example, if they are
                    // joining via an invite link.
                    owsAssertDebug(groupId != nil, "Expecting a groupId for this path")
                case .removeFromGroup:
                    guard let groupId = groupId else {
                        owsFailDebug("GroupId must be set to remove from group")
                        break
                    }
                    // If we receive 403 when trying to fetch group state,
                    // we have left the group, been removed from the group
                    // or had our invite revoked and we should make sure
                    // group state in the database reflects that.
                    await self.databaseStorage.awaitableWrite { transaction in
                        GroupManager.handleNotInGroup(groupId: groupId, transaction: transaction)
                    }

                case .fetchGroupUpdates:
                    guard let groupId = groupId else {
                        owsFailDebug("GroupId must be set to fetch group updates")
                        break
                    }
                    // Service returns 403 if client tries to perform an
                    // update for which it is not authorized (e.g. add a
                    // new member if membership access is admin-only).
                    // The local client can't assume that 403 means they
                    // are not in the group. Therefore we "update group
                    // to latest" to check for and handle that case (see
                    // previous case).
                    self.tryToUpdateGroupToLatest(groupId: groupId)

                case .reportInvalidOrBlockedGroupLink:
                    owsAssertDebug(groupId == nil, "groupId should not be set in this code path.")

                    if error.httpResponseHeaders?.containsBan == true {
                        throw GroupsV2Error.localUserBlockedFromJoining
                    } else {
                        throw GroupsV2Error.expiredGroupInviteLink
                    }

                case .localUserIsNotARequestingMember:
                    owsAssertDebug(groupId == nil, "groupId should not be set in this code path.")
                    throw GroupsV2Error.localUserIsNotARequestingMember
                }

                throw GroupsV2Error.localUserNotInGroup
            case 404:
                // 404 indicates that the group does not exist on the
                // service for some (but not all) group v2 service requests.

                switch behavior404 {
                case .fail:
                    throw error
                case .groupDoesNotExistOnService:
                    Logger.warn("Error: \(error)")
                    throw GroupsV2Error.groupDoesNotExistOnService
                }
            case 409:
                // Group update conflict. The caller may be able to recover by
                // retrying, using the change set and the most recent state
                // from the service.
                throw GroupsV2Error.conflictingChangeOnService
            default:
                // Unexpected status code.
                throw error
            }
        } else if error.isNetworkFailureOrTimeout {
            // Retry on network failure.
            return try await retryBlock(error)
        } else {
            // Unexpected error.
            throw error
        }
    }

    private func performServiceRequestAttempt(request: GroupsV2Request) async throws -> HTTPResponse {

        let urlSession = self.urlSession
        urlSession.failOnError = false

        Logger.info("Making group request: \(request.method) \(request.urlString)")

        do {
            let response = try await urlSession.dataTaskPromise(
                request.urlString,
                method: request.method,
                headers: request.headers.headers,
                body: request.bodyData
            ).awaitable()

            let statusCode = response.responseStatusCode
            let hasValidStatusCode = [200, 206].contains(statusCode)
            guard hasValidStatusCode else {
                throw OWSAssertionError("Invalid status code: \(statusCode)")
            }

            // NOTE: responseObject may be nil; not all group v2 responses have bodies.
            Logger.info("Request succeeded: \(request.method) \(request.urlString)")

            return response
        } catch {
            if error.isNetworkFailureOrTimeout {
                throw error
            }

            if let statusCode = error.httpStatusCode {
                if [400, 401, 403, 404, 409].contains(statusCode) {
                    // These status codes will be handled by performServiceRequest.
                    Logger.warn("Request error: \(error)")
                    throw error
                }
            }

            Logger.warn("Request failed: \(request.method) \(request.urlString)")
            owsFailDebug("Request error: \(error)")
            throw error
        }
    }

    private func tryToUpdateGroupToLatest(groupId: Data) {
        guard let groupThread = (databaseStorage.read { transaction in
            TSGroupThread.fetch(groupId: groupId, transaction: transaction)
        }) else {
            owsFailDebug("Missing group thread.")
            return
        }
        guard let groupModelV2 = groupThread.groupModel as? TSGroupModelV2 else {
            owsFailDebug("Invalid group model.")
            return
        }
        let groupUpdateMode = GroupUpdateMode.upToCurrentRevisionAfterMessageProcessWithThrottling
        firstly {
            self.groupV2Updates.tryToRefreshV2GroupThread(groupId: groupId,
                                                          spamReportingMetadata: .learnedByLocallyInitatedRefresh,
                                                          groupSecretParamsData: groupModelV2.secretParamsData,
                                                          groupUpdateMode: groupUpdateMode)
        }.catch { error in
            if case GroupsV2Error.localUserNotInGroup = error {
                Logger.warn("Error: \(error)")
            } else {
                owsFailDebugUnlessNetworkFailure(error)
            }
        }
    }

    // MARK: - ProfileKeyCredentials

    /// Fetches and returnes the profile key credential for each passed ACI. If
    /// any are missing, returns an error.
    public func loadProfileKeyCredentials(
        for acis: [Aci],
        forceRefresh: Bool
    ) async throws -> ProfileKeyCredentialMap {
        try await tryToFetchProfileKeyCredentials(
            for: acis,
            ignoreMissingProfiles: false,
            forceRefresh: forceRefresh
        )

        let acis = Set(acis)

        let credentialMap = self.loadPresentProfileKeyCredentials(for: acis)

        guard acis.symmetricDifference(credentialMap.keys).isEmpty else {
            throw OWSAssertionError("Missing requested keys from credential map!")
        }

        return credentialMap
    }

    /// Makes a best-effort to fetch the profile key credential for each passed
    /// ACI. If a profile exists for the user but the credential cannot be
    /// fetched (e.g., the ACI is not a contact of ours), skips it. Optionally
    /// ignores "missing profile" errors during fetch.
    public func tryToFetchProfileKeyCredentials(
        for acis: [Aci],
        ignoreMissingProfiles: Bool,
        forceRefresh: Bool
    ) async throws {
        let acis = Set(acis)

        let acisToFetch: Set<Aci>
        if forceRefresh {
            acisToFetch = acis
        } else {
            acisToFetch = acis.subtracting(loadPresentProfileKeyCredentials(for: acis).keys)
        }

        let profileFetcher = SSKEnvironment.shared.profileFetcherRef

        try await withThrowingTaskGroup(of: Void.self) { taskGroup in
            for aciToFetch in acisToFetch {
                taskGroup.addTask {
                    do {
                        _ = try await profileFetcher.fetchProfile(for: aciToFetch)
                    } catch ProfileRequestError.notFound where ignoreMissingProfiles {
                        // this is fine
                    }
                }
            }
            try await taskGroup.waitForAll()
        }
    }

    private func loadPresentProfileKeyCredentials(for acis: Set<Aci>) -> ProfileKeyCredentialMap {
        databaseStorage.read { transaction in
            var credentialMap = ProfileKeyCredentialMap()

            for aci in acis {
                do {
                    if let credential = try self.versionedProfilesSwift.validProfileKeyCredential(
                        for: aci,
                        transaction: transaction
                    ) {
                        credentialMap[aci] = credential
                    }
                } catch {
                    owsFailDebug("Error loading profile key credential: \(error)")
                }
            }

            return credentialMap
        }
    }

    public func hasProfileKeyCredential(
        for address: SignalServiceAddress,
        transaction: SDSAnyReadTransaction
    ) -> Bool {
        do {
            guard let serviceId = address.serviceId else {
                throw OWSAssertionError("Missing ACI.")
            }
            guard let aci = serviceId as? Aci else {
                return false
            }
            return try self.versionedProfilesSwift.validProfileKeyCredential(
                for: aci,
                transaction: transaction
            ) != nil
        } catch let error {
            owsFailDebug("Error getting profile key credential: \(error)")
            return false
        }
    }

    // MARK: - Protos

    public func masterKeyData(forGroupModel groupModel: TSGroupModelV2) throws -> Data {
        return try GroupsV2Protos.masterKeyData(forGroupModel: groupModel)
    }

    public func buildGroupContextV2Proto(groupModel: TSGroupModelV2,
                                         changeActionsProtoData: Data?) throws -> SSKProtoGroupContextV2 {
        return try GroupsV2Protos.buildGroupContextV2Proto(groupModel: groupModel, changeActionsProtoData: changeActionsProtoData)
    }

    public func parseAndVerifyChangeActionsProto(_ changeProtoData: Data,
                                                 ignoreSignature: Bool) throws -> GroupsProtoGroupChangeActions {
        return try GroupsV2Protos.parseAndVerifyChangeActionsProto(changeProtoData,
                                                                   ignoreSignature: ignoreSignature)
    }

    // MARK: - Restore Groups

    public func isGroupKnownToStorageService(
        groupModel: TSGroupModelV2,
        transaction: SDSAnyReadTransaction
    ) -> Bool {
        GroupsV2Impl.isGroupKnownToStorageService(groupModel: groupModel, transaction: transaction)
    }

    public func groupRecordPendingStorageServiceRestore(
        masterKeyData: Data,
        transaction: SDSAnyReadTransaction
    ) -> StorageServiceProtoGroupV2Record? {
        GroupsV2Impl.enqueuedGroupRecordForRestore(masterKeyData: masterKeyData, transaction: transaction)
    }

    public func restoreGroupFromStorageServiceIfNecessary(
        groupRecord: StorageServiceProtoGroupV2Record,
        account: AuthedAccount,
        transaction: SDSAnyWriteTransaction
    ) {
        GroupsV2Impl.enqueueGroupRestore(groupRecord: groupRecord, account: account, transaction: transaction)
    }

    // MARK: - Groups Secrets

    public func generateGroupSecretParamsData() throws -> Data {
        let groupSecretParams = try GroupSecretParams.generate()
        let bytes = groupSecretParams.serialize()
        return bytes.asData
    }

    public func groupSecretParamsData(forMasterKeyData masterKeyData: Data) throws -> Data {
        let groupMasterKey = try GroupMasterKey(contents: [UInt8](masterKeyData))
        let groupSecretParams = try GroupSecretParams.deriveFromMasterKey(groupMasterKey: groupMasterKey)
        return groupSecretParams.serialize().asData
    }

    public func groupId(forGroupSecretParamsData groupSecretParamsData: Data) throws -> Data {
        let groupSecretParams = try GroupSecretParams(contents: [UInt8](groupSecretParamsData))
        return try groupSecretParams.getPublicParams().getGroupIdentifier().serialize().asData
    }

    public func v2GroupId(forV1GroupId v1GroupId: Data) -> Data? {
        guard GroupManager.isValidGroupId(v1GroupId, groupsVersion: .V1) else {
            owsFailDebug("Invalid V2 group ID!")
            return nil
        }

        do {
            let masterKey: Data = try { () throws -> Data in
                let infoString = "GV2 Migration"
                guard let keyBytes = try infoString.utf8.withContiguousStorageIfAvailable({ ptr in
                    try hkdf(
                        outputLength: GroupMasterKey.SIZE,
                        inputKeyMaterial: v1GroupId,
                        salt: [],
                        info: ptr
                    )
                }) else {
                    owsFail("Failed to compute key bytes!")
                }

                return Data(keyBytes)
            }()

            let v2GroupSecretParams = try groupSecretParamsData(forMasterKeyData: masterKey)
            return try groupId(forGroupSecretParamsData: v2GroupSecretParams)
        } catch let error {
            owsFailDebug("Error computing V2 group ID: \(error)")
            return nil
        }
    }

    public func groupV2ContextInfo(forMasterKeyData masterKeyData: Data?) throws -> GroupV2ContextInfo {
        guard let masterKeyData = masterKeyData else {
            throw OWSAssertionError("Missing masterKeyData.")
        }
        let groupSecretParamsData = try self.groupSecretParamsData(forMasterKeyData: masterKeyData)
        let groupId = try self.groupId(forGroupSecretParamsData: groupSecretParamsData)
        guard GroupManager.isValidGroupId(groupId, groupsVersion: .V2) else {
            throw OWSAssertionError("Invalid groupId.")
        }
        return GroupV2ContextInfo(masterKeyData: masterKeyData,
                                  groupSecretParamsData: groupSecretParamsData,
                                  groupId: groupId)
    }

    public func isValidGroupV2MasterKey(_ masterKeyData: Data) -> Bool {
        return masterKeyData.count == GroupMasterKey.SIZE
    }

    // MARK: - Group Links

    public func groupInviteLink(forGroupModelV2 groupModelV2: TSGroupModelV2) throws -> URL {
        guard let inviteLinkPassword = groupModelV2.inviteLinkPassword,
              !inviteLinkPassword.isEmpty else {
            throw OWSAssertionError("Missing password.")
        }
        let masterKey = try GroupsV2Protos.masterKeyData(forGroupModel: groupModelV2)

        var contentsV1Builder = GroupsProtoGroupInviteLinkGroupInviteLinkContentsV1.builder()
        contentsV1Builder.setGroupMasterKey(masterKey)
        contentsV1Builder.setInviteLinkPassword(inviteLinkPassword)

        var builder = GroupsProtoGroupInviteLink.builder()
        builder.setContents(GroupsProtoGroupInviteLinkOneOfContents.contentsV1(contentsV1Builder.buildInfallibly()))
        let protoData = try builder.buildSerializedData()

        let protoBase64Url = protoData.asBase64Url

        let urlString = "https://signal.group/#\(protoBase64Url)"
        guard let url = URL(string: urlString) else {
            throw OWSAssertionError("Could not construct url.")
        }
        return url
    }

    public func parseGroupInviteLink(_ url: URL) -> GroupInviteLinkInfo? {
        guard GroupManager.isPossibleGroupInviteLink(url) else {
            return nil
        }
        guard let protoBase64Url = url.fragment,
              !protoBase64Url.isEmpty else {
            owsFailDebug("Missing encoded data.")
            return nil
        }
        do {
            let protoData = try Data.data(fromBase64Url: protoBase64Url)
            let proto = try GroupsProtoGroupInviteLink(serializedData: protoData)
            guard let protoContents = proto.contents else {
                owsFailDebug("Missing proto contents.")
                return nil
            }
            switch protoContents {
            case .contentsV1(let contentsV1):
                guard let masterKey = contentsV1.groupMasterKey,
                      !masterKey.isEmpty else {
                    owsFailDebug("Invalid masterKey.")
                    return nil
                }
                guard let inviteLinkPassword = contentsV1.inviteLinkPassword,
                      !inviteLinkPassword.isEmpty else {
                    owsFailDebug("Invalid inviteLinkPassword.")
                    return nil
                }
                return GroupInviteLinkInfo(masterKey: masterKey, inviteLinkPassword: inviteLinkPassword)
            }
        } catch {
            owsFailDebug("Error: \(error)")
            return nil
        }
    }

    private let groupInviteLinkPreviewCache = LRUCache<Data, GroupInviteLinkPreview>(maxSize: 5,
                                                                                     shouldEvacuateInBackground: true)

    private func groupInviteLinkPreviewCacheKey(groupSecretParamsData: Data) -> Data {
        groupSecretParamsData
    }

    public func cachedGroupInviteLinkPreview(groupSecretParamsData: Data) -> GroupInviteLinkPreview? {
        let cacheKey = groupInviteLinkPreviewCacheKey(groupSecretParamsData: groupSecretParamsData)
        return groupInviteLinkPreviewCache.object(forKey: cacheKey)
    }

    // inviteLinkPassword is not necessary if we're already a member or have a pending request.
    public func fetchGroupInviteLinkPreview(
        inviteLinkPassword: Data?,
        groupSecretParamsData: Data,
        allowCached: Bool
    ) async throws -> GroupInviteLinkPreview {
        let cacheKey = groupInviteLinkPreviewCacheKey(groupSecretParamsData: groupSecretParamsData)

        if
            allowCached,
            let groupInviteLinkPreview = groupInviteLinkPreviewCache.object(forKey: cacheKey)
        {
            return groupInviteLinkPreview
        }

        let groupV2Params = try GroupV2Params(groupSecretParamsData: groupSecretParamsData)

        let requestBuilder: RequestBuilder = { (authCredential) in
            try StorageService.buildFetchGroupInviteLinkPreviewRequest(
                inviteLinkPassword: inviteLinkPassword,
                groupV2Params: groupV2Params,
                authCredential: authCredential
            )
        }

        do {
            let behavior403: Behavior403 = (
                inviteLinkPassword != nil
                ? .reportInvalidOrBlockedGroupLink
                : .localUserIsNotARequestingMember
            )
            let response = try await performServiceRequest(
                requestBuilder: requestBuilder,
                groupId: nil,
                behavior400: .fail,
                behavior403: behavior403,
                behavior404: .fail
            )
            guard let protoData = response.responseBodyData else {
                throw OWSAssertionError("Invalid responseObject.")
            }
            let groupInviteLinkPreview = try GroupsV2Protos.parseGroupInviteLinkPreview(protoData, groupV2Params: groupV2Params)

            groupInviteLinkPreviewCache.setObject(groupInviteLinkPreview, forKey: cacheKey)

            await updatePlaceholderGroupModelUsingInviteLinkPreview(
                groupSecretParamsData: groupSecretParamsData,
                isLocalUserRequestingMember: groupInviteLinkPreview.isLocalUserRequestingMember
            )

            return groupInviteLinkPreview
        } catch {
            if case GroupsV2Error.localUserIsNotARequestingMember = error {
                await self.updatePlaceholderGroupModelUsingInviteLinkPreview(
                    groupSecretParamsData: groupSecretParamsData,
                    isLocalUserRequestingMember: false
                )
            }
            throw error
        }
    }

    public func fetchGroupInviteLinkAvatar(
        avatarUrlPath: String,
        groupSecretParamsData: Data
    ) async throws -> Data {
        let groupV2Params = try GroupV2Params(groupSecretParamsData: groupSecretParamsData)
        let downloadedAvatars = try await fetchAvatarData(
            avatarUrlPaths: [avatarUrlPath],
            downloadedAvatars: GroupV2DownloadedAvatars(),
            groupV2Params: groupV2Params
        )
        return try downloadedAvatars.avatarData(for: avatarUrlPath)
    }

    public func joinGroupViaInviteLink(
        groupId: Data,
        groupSecretParamsData: Data,
        inviteLinkPassword: Data,
        groupInviteLinkPreview: GroupInviteLinkPreview,
        avatarData: Data?
    ) async throws -> TSGroupThread {
        let groupV2Params = try GroupV2Params(groupSecretParamsData: groupSecretParamsData)
        return try await Promises.performWithImmediateRetry {
            Promise.wrapAsync {
                try await self.joinGroupViaInviteLinkAttempt(
                    groupId: groupId,
                    inviteLinkPassword: inviteLinkPassword,
                    groupV2Params: groupV2Params,
                    groupInviteLinkPreview: groupInviteLinkPreview,
                    avatarData: avatarData
                )
            }
        }.awaitable()
    }

    private func joinGroupViaInviteLinkAttempt(
        groupId: Data,
        inviteLinkPassword: Data,
        groupV2Params: GroupV2Params,
        groupInviteLinkPreview: GroupInviteLinkPreview,
        avatarData: Data?
    ) async throws -> TSGroupThread {

        // There are many edge cases around joining groups via invite links.
        //
        // * We might have previously been a member or not.
        // * We might previously have requested to join and been denied.
        // * The group might or might not already exist in the database.
        // * We might already be a full member.
        // * We might already have a pending invite (in which case we should
        //   accept that invite rather than request to join).
        // * The invite link may have been rescinded.

        do {
            // Check if...
            //
            // * We're already in the group.
            // * We already have a pending invite. If so, use it.
            //
            // Note: this will typically fail.
            return try await joinGroupViaInviteLinkUsingAlternateMeans(
                groupId: groupId,
                inviteLinkPassword: inviteLinkPassword,
                groupV2Params: groupV2Params
            )
        } catch {
            guard !error.isNetworkFailureOrTimeout else {
                throw error
            }
            Logger.warn("Error: \(error)")
            return try await self.joinGroupViaInviteLinkUsingPatch(
                groupId: groupId,
                inviteLinkPassword: inviteLinkPassword,
                groupV2Params: groupV2Params,
                groupInviteLinkPreview: groupInviteLinkPreview,
                avatarData: avatarData
            )
        }
    }

    private func joinGroupViaInviteLinkUsingAlternateMeans(
        groupId: Data,
        inviteLinkPassword: Data,
        groupV2Params: GroupV2Params
    ) async throws -> TSGroupThread {

        // First try to fetch latest group state from service.
        // This will fail for users trying to join via group link
        // who are not yet in the group.
        let groupThread = try await groupV2Updates.tryToRefreshV2GroupUpToCurrentRevisionImmediately(
            groupId: groupId,
            groupSecretParamsData: groupV2Params.groupSecretParamsData
        ).awaitable()

        guard let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction else {
            throw OWSAssertionError("Missing localAci.")
        }
        guard let groupModelV2 = groupThread.groupModel as? TSGroupModelV2 else {
            throw OWSAssertionError("Invalid group model.")
        }
        let groupMembership = groupModelV2.groupMembership
        if groupMembership.isFullMember(localIdentifiers.aci) ||
            groupMembership.isRequestingMember(localIdentifiers.aci) {
            // We're already in the group.
            return groupThread
        } else if groupMembership.isInvitedMember(localIdentifiers.aci) {
            // We're already invited by ACI; try to join by accepting the invite.
            // That will make us a full member; requesting to join via
            // the invite link might make us a requesting member.
            return try await GroupManager.localAcceptInviteToGroupV2(groupModel: groupModelV2).awaitable()
        } else if
            let pni = localIdentifiers.pni,
            groupMembership.isInvitedMember(pni)
        {
            // We're already invited by PNI; try to join by accepting the invite.
            // That will make us a full member; requesting to join via
            // the invite link might make us a requesting member.
            return try await GroupManager.localAcceptInviteToGroupV2(groupModel: groupModelV2).awaitable()
        } else {
            throw GroupsV2Error.localUserNotInGroup
        }
    }

    private func joinGroupViaInviteLinkUsingPatch(
        groupId: Data,
        inviteLinkPassword: Data,
        groupV2Params: GroupV2Params,
        groupInviteLinkPreview: GroupInviteLinkPreview,
        avatarData: Data?
    ) async throws -> TSGroupThread {

        let revisionForPlaceholderModel = AtomicOptional<UInt32>(nil, lock: .sharedGlobal)

        let requestBuilder: RequestBuilder = { (authCredential) in
            let groupChangeProto = try await self.buildChangeActionsProtoToJoinGroupLink(
                groupId: groupId,
                inviteLinkPassword: inviteLinkPassword,
                groupV2Params: groupV2Params,
                revisionForPlaceholderModel: revisionForPlaceholderModel
            )
            return try StorageService.buildUpdateGroupRequest(
                groupChangeProto: groupChangeProto,
                groupV2Params: groupV2Params,
                authCredential: authCredential,
                groupInviteLinkPassword: inviteLinkPassword
            )
        }

        do {
            let response = try await performServiceRequest(
                requestBuilder: requestBuilder,
                groupId: groupId,
                behavior400: .fail,
                behavior403: .reportInvalidOrBlockedGroupLink,
                behavior404: .fail
            )

            guard let changeActionsProtoData = response.responseBodyData else {
                throw OWSAssertionError("Invalid responseObject.")
            }
            // The PATCH request that adds us to the group (as a full or requesting member)
            // only return the "change actions" proto data, but not a full snapshot
            // so we need to separately GET the latest group state and update the database.
            //
            // Download and update database with the group state.
            do {
                _ = try await groupV2UpdatesImpl.tryToRefreshV2GroupUpToCurrentRevisionImmediately(
                    groupId: groupId,
                    groupSecretParamsData: groupV2Params.groupSecretParamsData,
                    groupModelOptions: .didJustAddSelfViaGroupLink
                ).awaitable()
            } catch {
                throw GroupsV2Error.requestingMemberCantLoadGroupState
            }

            guard let groupThread = NSObject.databaseStorage.read(block: { tx in
                TSGroupThread.fetch(groupId: groupId, transaction: tx)
            }) else {
                throw OWSAssertionError("Missing group thread.")
            }

            _ = GroupManager.sendGroupUpdateMessage(
                thread: groupThread,
                changeActionsProtoData: changeActionsProtoData
            )
            return groupThread
        } catch {
            // We create a placeholder in a couple of different scenarios:
            //
            // * We successfully request to join a group via group invite link.
            //   Afterward we do not have access to group state on the service.
            // * The GroupInviteLinkPreview indicates that we are already a
            //   requesting member of the group but the group does not yet exist
            //   in the database.
            var shouldCreatePlaceholder = false
            if case GroupsV2Error.localUserIsAlreadyRequestingMember = error {
                shouldCreatePlaceholder = true
            } else if case GroupsV2Error.requestingMemberCantLoadGroupState = error {
                shouldCreatePlaceholder = true
            }
            guard shouldCreatePlaceholder else {
                throw error
            }

            let groupThread = try await createPlaceholderGroupForJoinRequest(
                groupId: groupId,
                inviteLinkPassword: inviteLinkPassword,
                groupV2Params: groupV2Params,
                groupInviteLinkPreview: groupInviteLinkPreview,
                avatarData: avatarData,
                revisionForPlaceholderModel: revisionForPlaceholderModel
            )

            let isJoinRequestPlaceholder: Bool
            if let groupModel = groupThread.groupModel as? TSGroupModelV2 {
                isJoinRequestPlaceholder = groupModel.isJoinRequestPlaceholder
            } else {
                isJoinRequestPlaceholder = false
            }
            guard !isJoinRequestPlaceholder else {
                // There's no point in sending a group update for a placeholder
                // group, since we don't know who to send it to.
                return groupThread
            }

            _ = GroupManager.sendGroupUpdateMessage(thread: groupThread, changeActionsProtoData: nil)
            return groupThread
        }
    }

    private func createPlaceholderGroupForJoinRequest(
        groupId: Data,
        inviteLinkPassword: Data,
        groupV2Params: GroupV2Params,
        groupInviteLinkPreview: GroupInviteLinkPreview,
        avatarData: Data?,
        revisionForPlaceholderModel: AtomicOptional<UInt32>
    ) async throws -> TSGroupThread {
        // We might be creating a placeholder for a revision that we just
        // created or for one we learned about from a GroupInviteLinkPreview.
        guard let revision = revisionForPlaceholderModel.get() else {
            throw OWSAssertionError("Missing revisionForPlaceholderModel.")
        }
        return try await databaseStorage.awaitableWrite { (transaction) throws -> TSGroupThread in
            guard let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction.asV2Read) else {
                throw OWSAssertionError("Missing localIdentifiers.")
            }

            if let groupThread = TSGroupThread.fetch(groupId: groupId, transaction: transaction) {
                // The group already existing in the database; make sure
                // that we are a requesting member.
                guard let oldGroupModel = groupThread.groupModel as? TSGroupModelV2 else {
                    throw OWSAssertionError("Invalid groupModel.")
                }
                let oldGroupMembership = oldGroupModel.groupMembership
                if oldGroupModel.revision >= revision && oldGroupMembership.isRequestingMember(localIdentifiers.aci) {
                    // No need to update database, group state is already acceptable.
                    return groupThread
                }
                var builder = oldGroupModel.asBuilder
                builder.isJoinRequestPlaceholder = true
                builder.groupV2Revision = max(revision, oldGroupModel.revision)
                var membershipBuilder = oldGroupMembership.asBuilder
                membershipBuilder.remove(localIdentifiers.aci)
                membershipBuilder.addRequestingMember(localIdentifiers.aci)
                builder.groupMembership = membershipBuilder.build()
                let newGroupModel = try builder.build()

                groupThread.update(with: newGroupModel, transaction: transaction)

                let dmConfigurationStore = DependenciesBridge.shared.disappearingMessagesConfigurationStore
                let dmToken = dmConfigurationStore.fetchOrBuildDefault(for: .thread(groupThread), tx: transaction.asV2Read).asToken
                GroupManager.insertGroupUpdateInfoMessage(
                    groupThread: groupThread,
                    oldGroupModel: oldGroupModel,
                    newGroupModel: newGroupModel,
                    oldDisappearingMessageToken: dmToken,
                    newDisappearingMessageToken: dmToken,
                    newlyLearnedPniToAciAssociations: [:],
                    groupUpdateSource: .localUser(originalSource: .aci(localIdentifiers.aci)),
                    localIdentifiers: localIdentifiers,
                    spamReportingMetadata: .createdByLocalAction,
                    transaction: transaction
                )

                return groupThread
            } else {
                // Create a placeholder group.
                var builder = TSGroupModelBuilder()
                builder.groupId = groupId
                builder.name = groupInviteLinkPreview.title
                builder.descriptionText = groupInviteLinkPreview.descriptionText
                builder.groupAccess = GroupAccess(members: GroupAccess.defaultForV2.members,
                                                  attributes: GroupAccess.defaultForV2.attributes,
                                                  addFromInviteLink: groupInviteLinkPreview.addFromInviteLinkAccess)
                builder.groupsVersion = .V2
                builder.groupV2Revision = revision
                builder.groupSecretParamsData = groupV2Params.groupSecretParamsData
                builder.inviteLinkPassword = inviteLinkPassword
                builder.isJoinRequestPlaceholder = true

                // The "group invite link" UI might not have downloaded
                // the avatar. That's fine; this is just a placeholder
                // model.
                if let avatarData = avatarData,
                   let avatarUrlPath = groupInviteLinkPreview.avatarUrlPath {
                    builder.avatarData = avatarData
                    builder.avatarUrlPath = avatarUrlPath
                }

                var membershipBuilder = GroupMembership.Builder()
                membershipBuilder.addRequestingMember(localIdentifiers.aci)
                builder.groupMembership = membershipBuilder.build()

                let groupModel = try builder.buildAsV2()
                let groupThread = DependenciesBridge.shared.threadStore.createGroupThread(
                    groupModel: groupModel, tx: transaction.asV2Write
                )

                let dmConfigurationStore = DependenciesBridge.shared.disappearingMessagesConfigurationStore
                let dmToken = dmConfigurationStore.fetchOrBuildDefault(for: .thread(groupThread), tx: transaction.asV2Read).asToken
                GroupManager.insertGroupUpdateInfoMessageForNewGroup(
                    localIdentifiers: localIdentifiers,
                    spamReportingMetadata: .createdByLocalAction,
                    groupThread: groupThread,
                    groupModel: groupModel,
                    disappearingMessageToken: dmToken,
                    groupUpdateSource: .localUser(originalSource: .aci(localIdentifiers.aci)),
                    transaction: transaction
                )

                return groupThread
            }
        }
    }

    private func buildChangeActionsProtoToJoinGroupLink(
        groupId: Data,
        inviteLinkPassword: Data,
        groupV2Params: GroupV2Params,
        revisionForPlaceholderModel: AtomicOptional<UInt32>
    ) async throws -> GroupsProtoGroupChangeActions {

        guard let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aci else {
            throw OWSAssertionError("Missing localAci.")
        }

        // We re-fetch the GroupInviteLinkPreview with every attempt in order to get the latest:
        //
        // * revision
        // * addFromInviteLinkAccess
        // * local user's request status.
        let groupInviteLinkPreview = try await fetchGroupInviteLinkPreview(
            inviteLinkPassword: inviteLinkPassword,
            groupSecretParamsData: groupV2Params.groupSecretParamsData,
            allowCached: false
        )

        guard !groupInviteLinkPreview.isLocalUserRequestingMember else {
            // Use the current revision when creating a placeholder group.
            revisionForPlaceholderModel.set(groupInviteLinkPreview.revision)
            throw GroupsV2Error.localUserIsAlreadyRequestingMember
        }

        let profileKeyCredentialMap = try await loadProfileKeyCredentials(for: [localAci], forceRefresh: false)

        guard let localProfileKeyCredential = profileKeyCredentialMap[localAci] else {
            throw OWSAssertionError("Missing localProfileKeyCredential.")
        }

        var actionsBuilder = GroupsProtoGroupChangeActions.builder()

        let oldRevision = groupInviteLinkPreview.revision
        let newRevision = oldRevision + 1
        Logger.verbose("Revision: \(oldRevision) -> \(newRevision)")
        actionsBuilder.setRevision(newRevision)

        // Use the new revision when creating a placeholder group.
        revisionForPlaceholderModel.set(newRevision)

        switch groupInviteLinkPreview.addFromInviteLinkAccess {
        case .any:
            let role = TSGroupMemberRole.`normal`
            var actionBuilder = GroupsProtoGroupChangeActionsAddMemberAction.builder()
            actionBuilder.setAdded(
                try GroupsV2Protos.buildMemberProto(
                    profileKeyCredential: localProfileKeyCredential,
                    role: role.asProtoRole,
                    groupV2Params: groupV2Params
                ))
            actionsBuilder.addAddMembers(actionBuilder.buildInfallibly())
        case .administrator:
            var actionBuilder = GroupsProtoGroupChangeActionsAddRequestingMemberAction.builder()
            actionBuilder.setAdded(
                try GroupsV2Protos.buildRequestingMemberProto(
                    profileKeyCredential: localProfileKeyCredential,
                    groupV2Params: groupV2Params
                ))
            actionsBuilder.addAddRequestingMembers(actionBuilder.buildInfallibly())
        default:
            throw OWSAssertionError("Invalid addFromInviteLinkAccess.")
        }

        return actionsBuilder.buildInfallibly()
    }

    public func cancelMemberRequests(groupModel: TSGroupModelV2) async throws -> TSGroupThread {
        let groupV2Params = try groupModel.groupV2Params()

        var newRevision: UInt32?
        do {
            newRevision = try await cancelMemberRequestsUsingPatch(
                groupId: groupModel.groupId,
                groupV2Params: groupV2Params,
                inviteLinkPassword: groupModel.inviteLinkPassword
            )
        } catch {
            switch error {
            case GroupsV2Error.localUserBlockedFromJoining, GroupsV2Error.localUserIsNotARequestingMember:
                // In both of these cases, our request has already been removed. We can proceed with updating the model.
                break
            default:
                // Otherwise, we don't recover and let the error propogate
                throw error
            }
        }

        return try await updateGroupRemovingMemberRequest(groupId: groupModel.groupId, newRevision: newRevision)
    }

    private func updateGroupRemovingMemberRequest(
        groupId: Data,
        newRevision proposedRevision: UInt32?
    ) async throws -> TSGroupThread {
        return try await NSObject.databaseStorage.awaitableWrite { transaction -> TSGroupThread in
            guard let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction.asV2Read) else {
                throw OWSAssertionError("Missing localIdentifiers.")
            }
            guard let groupThread = TSGroupThread.fetch(groupId: groupId, transaction: transaction) else {
                throw OWSAssertionError("Missing groupThread.")
            }
            // The group already existing in the database; make sure
            // that we are a requesting member.
            guard let oldGroupModel = groupThread.groupModel as? TSGroupModelV2 else {
                throw OWSAssertionError("Invalid groupModel.")
            }
            let oldGroupMembership = oldGroupModel.groupMembership
            var newRevision = oldGroupModel.revision + 1
            if let proposedRevision = proposedRevision {
                if oldGroupModel.revision >= proposedRevision {
                    // No need to update database, group state is already acceptable.
                    owsAssertDebug(!oldGroupMembership.isMemberOfAnyKind(localIdentifiers.aci))
                    return groupThread
                }
                newRevision = max(newRevision, proposedRevision)
            }

            var builder = oldGroupModel.asBuilder
            builder.isJoinRequestPlaceholder = true
            builder.groupV2Revision = newRevision

            var membershipBuilder = oldGroupMembership.asBuilder
            membershipBuilder.remove(localIdentifiers.aci)
            builder.groupMembership = membershipBuilder.build()
            let newGroupModel = try builder.build()

            groupThread.update(with: newGroupModel, transaction: transaction)

            let dmConfigurationStore = DependenciesBridge.shared.disappearingMessagesConfigurationStore
            let dmToken = dmConfigurationStore.fetchOrBuildDefault(for: .thread(groupThread), tx: transaction.asV2Read).asToken
            GroupManager.insertGroupUpdateInfoMessage(
                groupThread: groupThread,
                oldGroupModel: oldGroupModel,
                newGroupModel: newGroupModel,
                oldDisappearingMessageToken: dmToken,
                newDisappearingMessageToken: dmToken,
                newlyLearnedPniToAciAssociations: [:],
                groupUpdateSource: .localUser(originalSource: .aci(localIdentifiers.aci)),
                localIdentifiers: localIdentifiers,
                spamReportingMetadata: .createdByLocalAction,
                transaction: transaction
            )

            return groupThread
        }
    }

    private func cancelMemberRequestsUsingPatch(
        groupId: Data,
        groupV2Params: GroupV2Params,
        inviteLinkPassword: Data?
    ) async throws -> UInt32 {

        let revisionForPlaceholderModel = AtomicOptional<UInt32>(nil, lock: .sharedGlobal)

        // We re-fetch the GroupInviteLinkPreview with every attempt in order to get the latest:
        //
        // * revision
        // * addFromInviteLinkAccess
        // * local user's request status.
        let groupInviteLinkPreview = try await fetchGroupInviteLinkPreview(
            inviteLinkPassword: inviteLinkPassword,
            groupSecretParamsData: groupV2Params.groupSecretParamsData,
            allowCached: false
        )

        let requestBuilder: RequestBuilder = { (authCredential) in
            let groupChangeProto = try self.buildChangeActionsProtoToCancelMemberRequest(
                groupInviteLinkPreview: groupInviteLinkPreview,
                groupV2Params: groupV2Params,
                revisionForPlaceholderModel: revisionForPlaceholderModel
            )
            return try StorageService.buildUpdateGroupRequest(
                groupChangeProto: groupChangeProto,
                groupV2Params: groupV2Params,
                authCredential: authCredential,
                groupInviteLinkPassword: inviteLinkPassword
            )
        }

        _ = try await performServiceRequest(
            requestBuilder: requestBuilder,
            groupId: groupId,
            behavior400: .fail,
            behavior403: .fail,
            behavior404: .fail
        )

        guard let revision = revisionForPlaceholderModel.get() else {
            throw OWSAssertionError("Missing revisionForPlaceholderModel.")
        }
        return revision
    }

    private func buildChangeActionsProtoToCancelMemberRequest(
        groupInviteLinkPreview: GroupInviteLinkPreview,
        groupV2Params: GroupV2Params,
        revisionForPlaceholderModel: AtomicOptional<UInt32>
    ) throws -> GroupsProtoGroupChangeActions {
        guard let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aci else {
            throw OWSAssertionError("Missing localAci.")
        }
        let oldRevision = groupInviteLinkPreview.revision
        let newRevision = oldRevision + 1
        revisionForPlaceholderModel.set(newRevision)

        var actionsBuilder = GroupsProtoGroupChangeActions.builder()
        actionsBuilder.setRevision(newRevision)

        var actionBuilder = GroupsProtoGroupChangeActionsDeleteRequestingMemberAction.builder()
        let userId = try groupV2Params.userId(for: localAci)
        actionBuilder.setDeletedUserID(userId)
        actionsBuilder.addDeleteRequestingMembers(actionBuilder.buildInfallibly())

        return actionsBuilder.buildInfallibly()
    }

    public func tryToUpdatePlaceholderGroupModelUsingInviteLinkPreview(
        groupModel: TSGroupModelV2,
        removeLocalUserBlock: @escaping (SDSAnyWriteTransaction) -> Void
    ) async throws {
        guard groupModel.isJoinRequestPlaceholder else {
            owsFailDebug("Invalid group model.")
            return
        }

        do {
            let groupV2Params = try groupModel.groupV2Params()
            _ = try await fetchGroupInviteLinkPreview(
                inviteLinkPassword: groupModel.inviteLinkPassword,
                groupSecretParamsData: groupV2Params.groupSecretParamsData,
                allowCached: false
            )
        } catch {
            switch error {
            case GroupsV2Error.localUserIsNotARequestingMember, GroupsV2Error.localUserBlockedFromJoining:
                // Expected if our request has been cancelled or we're banned. In this
                // scenario, we should remove ourselves from the local group (in which
                // we will be stored as a requesting member).
                await NSObject.databaseStorage.awaitableWrite { transaction in
                    removeLocalUserBlock(transaction)
                }
            default:
                owsFailDebug("Error: \(error)")
            }
        }
    }

    private func updatePlaceholderGroupModelUsingInviteLinkPreview(
        groupSecretParamsData: Data,
        isLocalUserRequestingMember: Bool
    ) async {
        do {
            let groupId = try self.groupId(forGroupSecretParamsData: groupSecretParamsData)
            try await NSObject.databaseStorage.awaitableWrite { transaction in
                guard let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction.asV2Read) else {
                    throw OWSAssertionError("Missing localIdentifiers.")
                }
                guard let groupThread = TSGroupThread.fetch(groupId: groupId, transaction: transaction) else {
                    // Thread not yet in database.
                    return
                }
                guard let oldGroupModel = groupThread.groupModel as? TSGroupModelV2 else {
                    throw OWSAssertionError("Invalid groupModel.")
                }
                guard oldGroupModel.isJoinRequestPlaceholder else {
                    // Not a placeholder model; no need to update.
                    return
                }
                guard isLocalUserRequestingMember != groupThread.isLocalUserRequestingMember else {
                    // Nothing to change.
                    return
                }
                let oldGroupMembership = oldGroupModel.groupMembership
                var builder = oldGroupModel.asBuilder

                var membershipBuilder = oldGroupMembership.asBuilder
                membershipBuilder.remove(localIdentifiers.aci)
                if isLocalUserRequestingMember {
                    membershipBuilder.addRequestingMember(localIdentifiers.aci)
                }
                builder.groupMembership = membershipBuilder.build()
                let newGroupModel = try builder.build()

                groupThread.update(with: newGroupModel, transaction: transaction)

                let dmConfigurationStore = DependenciesBridge.shared.disappearingMessagesConfigurationStore
                let dmToken = dmConfigurationStore.fetchOrBuildDefault(for: .thread(groupThread), tx: transaction.asV2Read).asToken
                // groupUpdateSource is unknown; we don't know who did the update.
                GroupManager.insertGroupUpdateInfoMessage(
                    groupThread: groupThread,
                    oldGroupModel: oldGroupModel,
                    newGroupModel: newGroupModel,
                    oldDisappearingMessageToken: dmToken,
                    newDisappearingMessageToken: dmToken,
                    newlyLearnedPniToAciAssociations: [:],
                    groupUpdateSource: .unknown,
                    localIdentifiers: localIdentifiers,
                    spamReportingMetadata: .createdByLocalAction,
                    transaction: transaction
                )
            }
        } catch {
            owsFailDebug("Error: \(error)")
        }
    }

    public func fetchGroupExternalCredentials(groupModel: TSGroupModelV2) async throws -> GroupsProtoGroupExternalCredential {
        let requestBuilder: RequestBuilder = { authCredential in
            try StorageService.buildFetchGroupExternalCredentials(
                groupV2Params: try groupModel.groupV2Params(),
                authCredential: authCredential
            )
        }

        let response = try await performServiceRequest(
            requestBuilder: requestBuilder,
            groupId: groupModel.groupId,
            behavior400: .fail,
            behavior403: .fetchGroupUpdates,
            behavior404: .fail
        )

        guard let groupProtoData = response.responseBodyData else {
            throw OWSAssertionError("Invalid responseObject.")
        }
        return try GroupsProtoGroupExternalCredential(serializedData: groupProtoData)
    }
}

fileprivate extension OWSHttpHeaders {
    private static let forbiddenKey: String = "X-Signal-Forbidden-Reason"
    private static let forbiddenValue: String = "banned"

    var containsBan: Bool {
        value(forHeader: Self.forbiddenKey) == Self.forbiddenValue
    }
}

// MARK: - What's in the change actions?

private extension GroupsProtoGroupChangeActions {
    var containsProfileKeyCredentials: Bool {
        // When adding a member, we include their profile key credential.
        let isAddingMembers = !addMembers.isEmpty

        // When promoting an invited member, we include the profile key for
        // their ACI.
        // Note: in practice the only user we'll promote is ourself, when
        // accepting an invite.
        let isPromotingPni = !promotePniPendingMembers.isEmpty
        let isPromotingAci = !promotePendingMembers.isEmpty

        return isAddingMembers || isPromotingPni || isPromotingAci
    }
}
