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

import Foundation
import LibSignalClient
import SignalCoreKit

public enum BlockMode: UInt {
    case remote
    case restoreFromBackup
    case localShouldLeaveGroups
    case localShouldNotLeaveGroups

    var locallyInitiated: Bool {
        switch self {
        case .remote, .restoreFromBackup:
            return false
        case .localShouldLeaveGroups, .localShouldNotLeaveGroups:
            return true
        }
    }
}

// MARK: -

public class BlockingManager: NSObject {
    private let lock = UnfairLock()
    private var state = State()

    public override init() {
        super.init()
        SwiftSingletons.register(self)
        AppReadiness.runNowOrWhenAppWillBecomeReady {
            self.loadStateOnLaunch()
        }
    }

    public func warmCaches() {
        owsAssertDebug(GRDBSchemaMigrator.areMigrationsComplete)
        loadStateOnLaunch()
    }

    private func loadStateOnLaunch() {
        // Pre-warm our cached state
        databaseStorage.read {
            withCurrentState(transaction: $0) { _ in }
        }
        // Once we're ready to send a message, check to see if we need to sync.
        AppReadiness.runNowOrWhenAppDidBecomeReadyAsync {
            DispatchQueue.global().async {
                self.sendBlockListSyncMessage(force: false)
            }
        }
        observeNotifications()
    }

    fileprivate func withCurrentState<T>(transaction: SDSAnyReadTransaction, _ handler: (State) -> T) -> T {
        lock.withLock {
            state.reloadIfNecessary(transaction)
            return handler(state)
        }
    }

    @discardableResult
    fileprivate func updateCurrentState(transaction: SDSAnyWriteTransaction, wasLocallyInitiated: Bool, _ handler: (inout State) -> Void) -> Bool {
        lock.withLock {
            state.reloadIfNecessary(transaction)
            handler(&state)
            let didUpdate = state.persistIfNecessary(transaction)
            if didUpdate {
                if !wasLocallyInitiated {
                    State.setLastSyncedChangeToken(state.changeToken, transaction: transaction)
                }

                transaction.addAsyncCompletionOffMain {
                    Logger.info("blockListDidChange")
                    if wasLocallyInitiated {
                        self.sendBlockListSyncMessage(force: false)
                    }
                    NotificationCenter.default.postNotificationNameAsync(Self.blockListDidChange, object: nil)
                }
            }
            return didUpdate
        }
    }
}

// MARK: - Public block state accessors

extension BlockingManager {

    // MARK: Readers
    @objc
    public func isAddressBlocked(_ address: SignalServiceAddress, transaction: SDSAnyReadTransaction) -> Bool {
        guard !address.isLocalAddress else {
            return false
        }
        return withCurrentState(transaction: transaction) { state in
            if let phoneNumber = address.phoneNumber, state.blockedPhoneNumbers.contains(phoneNumber) {
                return true
            }
            if let aci = address.serviceId as? Aci, state.blockedAcis.contains(aci) {
                return true
            }
            return false
        }
    }

    @objc
    public func isGroupIdBlocked(_ groupId: Data, transaction: SDSAnyReadTransaction) -> Bool {
        withCurrentState(transaction: transaction) { state in
            state.blockedGroupMap[groupId] != nil
        }
    }

    @objc
    public func blockedAddresses(transaction: SDSAnyReadTransaction) -> Set<SignalServiceAddress> {
        // TODO UUID - optimize this. Maybe blocking manager should store a SignalServiceAddressSet as
        // it's state instead of the two separate sets.
        withCurrentState(transaction: transaction) { state in
            var addressSet = Set<SignalServiceAddress>()
            state.blockedPhoneNumbers.forEach {
                let address = SignalServiceAddress.legacyAddress(serviceId: nil, phoneNumber: $0)
                if address.isValid {
                    addressSet.insert(address)
                }
            }
            state.blockedAcis.forEach {
                addressSet.insert(SignalServiceAddress($0))
            }
            return addressSet
        }
    }

    public func blockedGroupModels(transaction: SDSAnyReadTransaction) -> [TSGroupModel] {
        withCurrentState(transaction: transaction) { state in
            Array(state.blockedGroupMap.values)
        }
    }

    // MARK: Writers

    public func addBlockedAddress(_ address: SignalServiceAddress, blockMode: BlockMode, transaction: SDSAnyWriteTransaction) {
        guard address.isValid else {
            owsFailDebug("Invalid address: \(address).")
            return
        }
        guard !address.isLocalAddress else {
            owsFailDebug("Cannot block the local address")
            return
        }
        updateCurrentState(transaction: transaction, wasLocallyInitiated: blockMode.locallyInitiated) { state in
            let didAdd = state.addBlockedAddress(address)
            if didAdd && blockMode.locallyInitiated {
                storageServiceManager.recordPendingUpdates(updatedAddresses: [address])
            }
        }

        // We will start dropping new stories from the blocked address;
        // delete any existing ones we already have.
        if let aci = address.aci {
            StoryManager.deleteAllStories(forSender: aci, tx: transaction)
        }
        StoryManager.removeAddressFromAllPrivateStoryThreads(address, tx: transaction)
    }

    public func removeBlockedAddress(_ address: SignalServiceAddress, wasLocallyInitiated: Bool, transaction: SDSAnyWriteTransaction) {
        guard address.isValid else {
            owsFailDebug("Invalid address: \(address).")
            return
        }
        guard !address.isLocalAddress else {
            owsFailDebug("Cannot unblock the local address")
            return
        }
        updateCurrentState(transaction: transaction, wasLocallyInitiated: wasLocallyInitiated) { state in
            let didRemove = state.removeBlockedAddress(address)
            if didRemove && wasLocallyInitiated {
                storageServiceManager.recordPendingUpdates(updatedAddresses: [address])
            }
        }
    }

    public func addBlockedGroup(groupModel: TSGroupModel, blockMode: BlockMode, transaction: SDSAnyWriteTransaction) {
        let groupId = groupModel.groupId
        guard GroupManager.isValidGroupIdOfAnyKind(groupId) else {
            owsFailDebug("Invalid group: \(groupId)")
            return
        }

        updateCurrentState(transaction: transaction, wasLocallyInitiated: blockMode.locallyInitiated) { state in
            let didInsert = state.addBlockedGroup(groupModel)
            if didInsert {
                Logger.info("Added blocked groupId: \(groupId.hexadecimalString)")

                if blockMode.locallyInitiated {
                    storageServiceManager.recordPendingUpdates(groupModel: groupModel)
                }

                // Quit the group if we're a member.
                if blockMode == .localShouldLeaveGroups,
                   groupModel.groupMembership.isLocalUserMemberOfAnyKind,
                   let groupThread = TSGroupThread.fetch(groupId: groupId, transaction: transaction),
                   groupThread.isLocalUserMemberOfAnyKind {
                    GroupManager.leaveGroupOrDeclineInviteAsyncWithoutUI(groupThread: groupThread,
                                                                         transaction: transaction,
                                                                         success: nil)
                }
            }
        }
    }

    public func removeBlockedGroup(groupId: Data, wasLocallyInitiated: Bool, transaction: SDSAnyWriteTransaction) {
        guard GroupManager.isValidGroupIdOfAnyKind(groupId) else {
            owsFailDebug("Invalid group: \(groupId)")
            return
        }

        updateCurrentState(transaction: transaction, wasLocallyInitiated: wasLocallyInitiated) { state in
            if let unblockedGroup = state.removeBlockedGroup(groupId) {
                Logger.info("Removed blocked groupId: \(groupId.hexadecimalString)")

                if wasLocallyInitiated {
                    storageServiceManager.recordPendingUpdates(groupModel: unblockedGroup)
                }

                // Refresh unblocked group.
                if let groupThread = TSGroupThread.fetch(groupId: groupId, transaction: transaction) {
                    groupV2Updates.tryToRefreshV2GroupUpToCurrentRevisionAfterMessageProcessingWithoutThrottling(groupThread)
                }
            }
        }
    }

    // MARK: Other convenience access

    public func isThreadBlocked(_ thread: TSThread, transaction: SDSAnyReadTransaction) -> Bool {
        if let contactThread = thread as? TSContactThread {
            return isAddressBlocked(contactThread.contactAddress, transaction: transaction)
        } else if let groupThread = thread as? TSGroupThread {
            return isGroupIdBlocked(groupThread.groupModel.groupId, transaction: transaction)
        } else if thread is TSPrivateStoryThread {
            return false
        } else {
            owsFailDebug("Invalid thread: \(type(of: thread))")
            return false
        }
    }

    public func addBlockedThread(_ thread: TSThread,
                                 blockMode: BlockMode,
                                 transaction: SDSAnyWriteTransaction) {
        if let contactThread = thread as? TSContactThread {
            addBlockedAddress(contactThread.contactAddress, blockMode: blockMode, transaction: transaction)
        } else if let groupThread = thread as? TSGroupThread {
            addBlockedGroup(groupModel: groupThread.groupModel, blockMode: blockMode, transaction: transaction)
        } else {
            owsFailDebug("Invalid thread: \(type(of: thread))")
        }
    }

    public func removeBlockedThread(_ thread: TSThread,
                                    wasLocallyInitiated: Bool,
                                    transaction: SDSAnyWriteTransaction) {
        if let contactThread = thread as? TSContactThread {
            removeBlockedAddress(contactThread.contactAddress,
                                 wasLocallyInitiated: wasLocallyInitiated,
                                 transaction: transaction)
        } else if let groupThread = thread as? TSGroupThread {
            removeBlockedGroup(groupId: groupThread.groupId,
                               wasLocallyInitiated: wasLocallyInitiated,
                               transaction: transaction)
        } else {
            owsFailDebug("Invalid thread: \(type(of: thread))")
        }
    }

    public func addBlockedGroup(groupId: Data, blockMode: BlockMode, transaction: SDSAnyWriteTransaction) {
        // Since we're in a write transaction, current state shouldn't have updated between this read
        // and the following write. I'm just using the `withCurrentState` method here to avoid reenterancy
        // that'd require having a separate helper implementation.
        let groupModelToUse: TSGroupModel? = withCurrentState(transaction: transaction) { state in
            if let existingModel = state.blockedGroupMap[groupId] {
                return existingModel
            } else if let currentThread = TSGroupThread.fetch(groupId: groupId, transaction: transaction) {
                return currentThread.groupModel
            } else {
                return GroupManager.fakeGroupModel(groupId: groupId)
            }
        }

        if let groupModelToUse = groupModelToUse {
            addBlockedGroup(groupModel: groupModelToUse, blockMode: blockMode, transaction: transaction)
        }
    }
}

// MARK: - Syncing

extension BlockingManager {
    public func processIncomingSync(
        blockedPhoneNumbers: Set<String>,
        blockedAcis: Set<Aci>,
        blockedGroupIds: Set<Data>,
        tx transaction: SDSAnyWriteTransaction
    ) {
        Logger.info("")
        transaction.addAsyncCompletionOnMain {
            NotificationCenter.default.post(name: Self.blockedSyncDidComplete, object: nil)
        }

        updateCurrentState(transaction: transaction, wasLocallyInitiated: false) { state in
            // We store the list of blocked groups as GroupModels (not group ids)
            // so that we can display the group names in the block list UI, if
            // possible.
            //
            // * If we have an existing group model, we use it to preserve the group name.
            // * If we can find the group thread, we use it to preserve the group name.
            // * If we only know the group id, we use a "fake" group model with only the group id.
            let newBlockedGroups: [Data: TSGroupModel] = blockedGroupIds.dictionaryMappingToValues { (blockedGroupId: Data) -> TSGroupModel? in
                if let existingModel = state.blockedGroupMap[blockedGroupId] {
                    return existingModel
                } else if let currentThread = TSGroupThread.fetch(groupId: blockedGroupId, transaction: transaction) {
                    return currentThread.groupModel
                } else {
                    return GroupManager.fakeGroupModel(groupId: blockedGroupId)
                }
            }.compactMapValues { $0 }

            state.replace(blockedPhoneNumbers: blockedPhoneNumbers, blockedAcis: blockedAcis, blockedGroups: newBlockedGroups)
        }
    }

    @objc
    public func syncBlockList(completion: @escaping () -> Void) {
        DispatchQueue.global().async {
            self.sendBlockListSyncMessage(force: true)
            completion()
        }
    }

    private func sendBlockListSyncMessage(force: Bool) {
        guard DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered else { return }

        databaseStorage.write { transaction in
            withCurrentState(transaction: transaction) { state in
                // If we're not forcing a sync, then we only sync if our last synced token is stale
                // and we're not in the NSE. We'll leaving syncing to the main app.
                if !force {
                    guard state.needsSync(transaction: transaction) else {
                        return
                    }
                    guard !CurrentAppContext().isNSE else {
                        return
                    }
                }

                let possibleThread = TSContactThread.getOrCreateLocalThread(transaction: transaction)
                guard let thread = possibleThread else {
                    owsFailDebug("Missing thread.")
                    return
                }

                let outgoingChangeToken = state.changeToken
                let message = OWSBlockedPhoneNumbersMessage(
                    thread: thread,
                    phoneNumbers: Array(state.blockedPhoneNumbers),
                    aciStrings: state.blockedAcis.map { $0.serviceIdString },
                    groupIds: Array(state.blockedGroupMap.keys),
                    transaction: transaction
                )

                if TestingFlags.optimisticallyCommitSyncToken {
                    // Tests can opt in to setting this token early. This won't be executed in production.
                    State.setLastSyncedChangeToken(outgoingChangeToken, transaction: transaction)
                }
                let preparedMessage = PreparedOutgoingMessage.preprepared(
                    transientMessageWithoutAttachments: message
                )

                SSKEnvironment.shared.messageSenderJobQueueRef.add(
                    .promise,
                    message: preparedMessage,
                    transaction: transaction
                ).done(on: DispatchQueue.global()) {
                    Logger.info("Successfully sent blocked phone numbers sync message")

                    // Record the last block list which we successfully synced..
                    Self.databaseStorage.write { transaction in
                        State.setLastSyncedChangeToken(outgoingChangeToken, transaction: transaction)
                    }
                }.catch { error in
                    owsFailDebugUnlessNetworkFailure(error)
                }
            }
        }
    }
}

// MARK: - Notifications

extension BlockingManager {
    @objc
    public static let blockListDidChange = Notification.Name("blockListDidChange")
    @objc
    public static let blockedSyncDidComplete = Notification.Name("blockedSyncDidComplete")

    fileprivate func observeNotifications() {
        AssertIsOnMainThread()

        NotificationCenter.default.removeObserver(self)
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(applicationDidBecomeActive),
            name: .OWSApplicationDidBecomeActive,
            object: nil
        )
    }

    @objc
    fileprivate func applicationDidBecomeActive() {
        AppReadiness.runNowOrWhenMainAppDidBecomeReadyAsync {
            DispatchQueue.global().async {
                self.sendBlockListSyncMessage(force: false)
            }
        }
    }
}

// MARK: - Persistence

extension BlockingManager {
    struct State {
        private(set) var isDirty: Bool
        private(set) var changeToken: UInt64
        private(set) var blockedPhoneNumbers: Set<String>
        private(set) var blockedAcis: Set<Aci>
        private(set) var blockedGroupMap: [Data: TSGroupModel]   // GroupId -> GroupModel

        static let invalidChangeToken: UInt64 = 0
        static let initialChangeToken: UInt64 = 1

        fileprivate init() {
            // We're okay initializing with empty data since it'll be reloaded on first access
            // Only non-zero change tokens should ever be stored in the database, so we'll pick up on the mismatch
            isDirty = false
            changeToken = Self.invalidChangeToken
            blockedPhoneNumbers = Set()
            blockedAcis = Set()
            blockedGroupMap = [:]
        }

        func needsSync(transaction readTx: SDSAnyReadTransaction) -> Bool {
            owsAssertDebug(changeToken != Self.invalidChangeToken)

            if let lastSyncedToken = State.fetchLastSyncedChangeToken(readTx) {
                return changeToken != lastSyncedToken

            } else if changeToken > Self.initialChangeToken {
                // If we've made changes and we don't have a last synced token, we must require a sync
                return true

            } else {
                // If we don't have a last synced change token, we can use the existence of one of our
                // old KVS keys as a hint that we may need to sync. If they don't exist this is
                // probably a fresh install and we don't need to sync.
                let hasOldKey = PersistenceKey.Legacy.allCases.contains { key in
                    Self.keyValueStore.hasValue(forKey: key.rawValue, transaction: readTx)
                }
                return hasOldKey
            }
        }

        // MARK: - Mutation

        mutating func replace(
            blockedPhoneNumbers newBlockedPhoneNumbers: Set<String>,
            blockedAcis newBlockedAcis: Set<Aci>,
            blockedGroups newBlockedGroups: [Data: TSGroupModel]
        ) {
            owsAssertDebug(changeToken != Self.invalidChangeToken)

            var blockedAddresses = [SignalServiceAddress]()
            blockedAddresses.append(contentsOf: newBlockedPhoneNumbers.lazy.compactMap { phoneNumber in
                let address = SignalServiceAddress.legacyAddress(serviceId: nil, phoneNumber: phoneNumber)
                return address.isValid ? address : nil
            })
            blockedAddresses.append(contentsOf: newBlockedAcis.map { SignalServiceAddress($0) })

            let oldBlockedPhoneNumbers = self.blockedPhoneNumbers
            let oldBlockedAcis = self.blockedAcis
            let oldBlockedGroupMap = self.blockedGroupMap

            self.blockedPhoneNumbers = Set(blockedAddresses.lazy.compactMap { $0.phoneNumber })
            self.blockedAcis = Set(blockedAddresses.lazy.compactMap { $0.aci })
            self.blockedGroupMap = newBlockedGroups

            isDirty = isDirty || (oldBlockedPhoneNumbers != self.blockedPhoneNumbers)
            isDirty = isDirty || (oldBlockedAcis != self.blockedAcis)
            isDirty = isDirty || (oldBlockedGroupMap != self.blockedGroupMap)
        }

        @discardableResult
        mutating func addBlockedAddress(_ address: SignalServiceAddress) -> Bool {
            owsAssertDebug(changeToken != Self.invalidChangeToken)

            var didInsert = false
            if let phoneNumber = address.phoneNumber {
                let result = blockedPhoneNumbers.insert(phoneNumber)
                didInsert = didInsert || result.inserted
            }
            if let aci = address.aci {
                let result = blockedAcis.insert(aci)
                didInsert = didInsert || result.inserted
            }
            isDirty = isDirty || didInsert
            return didInsert
        }

        @discardableResult
        mutating func removeBlockedAddress(_ address: SignalServiceAddress) -> Bool {
            owsAssertDebug(changeToken != Self.invalidChangeToken)

            var didRemove = false
            if let phoneNumber = address.phoneNumber, blockedPhoneNumbers.contains(phoneNumber) {
                blockedPhoneNumbers.remove(phoneNumber)
                didRemove = true
            }
            if let aci = address.aci, blockedAcis.contains(aci) {
                blockedAcis.remove(aci)
                didRemove = true
            }
            isDirty = isDirty || didRemove
            return didRemove
        }

        @discardableResult
        mutating func addBlockedGroup(_ model: TSGroupModel) -> Bool {
            owsAssertDebug(changeToken != Self.invalidChangeToken)

            var didInsert = false
            if blockedGroupMap[model.groupId] == nil {
                blockedGroupMap[model.groupId] = model
                didInsert = true
            }
            isDirty = didInsert || isDirty
            return didInsert
        }

        @discardableResult
        mutating func removeBlockedGroup(_ groupId: Data) -> TSGroupModel? {
            owsAssertDebug(changeToken != Self.invalidChangeToken)

            let oldValue = blockedGroupMap.removeValue(forKey: groupId)
            isDirty = isDirty || (oldValue != nil)
            return oldValue
        }

        // MARK: Persistence

        static let keyValueStore = SDSKeyValueStore(collection: "kOWSBlockingManager_BlockedPhoneNumbersCollection")

        enum PersistenceKey: String {
            case changeTokenKey = "kOWSBlockingManager_ChangeTokenKey"
            case lastSyncedChangeTokenKey = "kOWSBlockingManager_LastSyncedChangeTokenKey"
            case blockedPhoneNumbersKey = "kOWSBlockingManager_BlockedPhoneNumbersKey"
            case blockedAciStringsKey = "kOWSBlockingManager_BlockedUUIDsKey"
            case blockedGroupMapKey = "kOWSBlockingManager_BlockedGroupMapKey"

            // No longer in use
            enum Legacy: String, CaseIterable {
                case syncedBlockedPhoneNumbersKey = "kOWSBlockingManager_SyncedBlockedPhoneNumbersKey"
                case syncedBlockedUUIDsKey = "kOWSBlockingManager_SyncedBlockedUUIDsKey"
                case syncedBlockedGroupIdsKey = "kOWSBlockingManager_SyncedBlockedGroupIdsKey"
            }
        }

        mutating func reloadIfNecessary(_ transaction: SDSAnyReadTransaction) {
            owsAssertDebug(isDirty == false)

            let databaseChangeToken: UInt64 = Self.keyValueStore.getUInt64(
                PersistenceKey.changeTokenKey.rawValue,
                defaultValue: Self.initialChangeToken,
                transaction: transaction)

            if databaseChangeToken != changeToken {
                func fetchObject<T>(of type: T.Type, key: String, defaultValue: T) -> T {
                    if let storedObject = Self.keyValueStore.getObject(forKey: key, transaction: transaction) {
                        owsAssertDebug(storedObject is T)
                        return (storedObject as? T) ?? defaultValue
                    } else {
                        return defaultValue
                    }
                }
                changeToken = Self.keyValueStore.getUInt64(
                    PersistenceKey.changeTokenKey.rawValue,
                    defaultValue: Self.initialChangeToken,
                    transaction: transaction
                )
                blockedPhoneNumbers = Set(fetchObject(of: [String].self, key: PersistenceKey.blockedPhoneNumbersKey.rawValue, defaultValue: []))
                let blockedAciStrings = fetchObject(of: [String].self, key: PersistenceKey.blockedAciStringsKey.rawValue, defaultValue: [])
                blockedAcis = Set(blockedAciStrings.lazy.compactMap { Aci.parseFrom(aciString: $0) })
                blockedGroupMap = fetchObject(of: [Data: TSGroupModel].self, key: PersistenceKey.blockedGroupMapKey.rawValue, defaultValue: [:])
                isDirty = false
            }
        }

        mutating func persistIfNecessary(_ transaction: SDSAnyWriteTransaction) -> Bool {
            guard changeToken != Self.invalidChangeToken else {
                owsFailDebug("Attempting to persist an unfetched change token. Aborting...")
                return false
            }

            if isDirty {
                let databaseChangeToken = Self.keyValueStore.getUInt64(
                    PersistenceKey.changeTokenKey.rawValue,
                    defaultValue: Self.initialChangeToken,
                    transaction: transaction
                )
                owsAssertDebug(databaseChangeToken == changeToken)

                changeToken = databaseChangeToken + 1
                Self.keyValueStore.setUInt64(changeToken, key: PersistenceKey.changeTokenKey.rawValue, transaction: transaction)
                Self.keyValueStore.setObject(Array(blockedPhoneNumbers), key: PersistenceKey.blockedPhoneNumbersKey.rawValue, transaction: transaction)
                Self.keyValueStore.setObject(blockedAcis.map { $0.serviceIdUppercaseString }, key: PersistenceKey.blockedAciStringsKey.rawValue, transaction: transaction)
                Self.keyValueStore.setObject(blockedGroupMap, key: PersistenceKey.blockedGroupMapKey.rawValue, transaction: transaction)
                isDirty = false
                return true

            } else {
                return false
            }
        }

        static func fetchLastSyncedChangeToken(_ readTx: SDSAnyReadTransaction) -> UInt64? {
            Self.keyValueStore.getUInt64(PersistenceKey.lastSyncedChangeTokenKey.rawValue, transaction: readTx)
        }

        static func setLastSyncedChangeToken(_ newValue: UInt64, transaction writeTx: SDSAnyWriteTransaction) {
            Self.keyValueStore.setUInt64(newValue, key: PersistenceKey.lastSyncedChangeTokenKey.rawValue, transaction: writeTx)
        }
    }
}

// MARK: - Testing Helpers

extension BlockingManager {
    enum TestingFlags {
        // Usually, we wait until after MessageSender finishes sending before commiting our last-sync
        // token. It's easier to just expose a knob for tests to force an early commit than having a test wait
        // for some nonexistent send.
        #if TESTABLE_BUILD
        static var optimisticallyCommitSyncToken = false
        #else
        static let optimisticallyCommitSyncToken = false
        #endif
    }
}

#if TESTABLE_BUILD

extension BlockingManager {
    func _testingOnly_needsSyncMessage(_ readTx: SDSAnyReadTransaction) -> Bool {
        state.needsSync(transaction: readTx)
    }

    func _testingOnly_clearNeedsSyncMessage(_ writeTx: SDSAnyWriteTransaction) {
        State.setLastSyncedChangeToken(state.changeToken, transaction: writeTx)
    }
}

extension BlockingManager.State {
    static func _testing_createEmpty() -> BlockingManager.State {
        return .init()
    }

    mutating func _testingOnly_resetDirtyBit() {
        isDirty = false
    }
}

#endif
