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

import Foundation
import PassKit
import LibSignalClient

public enum OneTimeBadgeLevel: Hashable {
    case boostBadge
    case giftBadge(OWSGiftBadge.Level)

    public var rawValue: UInt64 {
        switch self {
        case .boostBadge:
            return 1
        case .giftBadge(let level):
            return level.rawLevel
        }
    }
}

public enum SubscriptionBadgeIds: String, CaseIterable {
    case low = "R_LOW"
    case med = "R_MED"
    case high = "R_HIGH"

    public static func contains(_ id: String) -> Bool {
        return Self(rawValue: id) != nil
    }
}

public enum BoostBadgeIds: String {
    case boost = "BOOST"

    public static func contains(_ id: String) -> Bool {
        return Self(rawValue: id) != nil
    }
}

public enum GiftBadgeIds: String {
    case gift = "GIFT"

    public static func contains(_ id: String) -> Bool {
        return Self(rawValue: id) != nil
    }
}

/// Represents a *recurring* subscription, associated with a subscriber ID and
/// fetched from the service using that ID.
public struct Subscription: Equatable {
    public struct ChargeFailure: Equatable {
        /// The error code reported by the server.
        ///
        /// If nil, we know there was a charge failure but don't know the code. This is unusual,
        /// but can happen if the server sends an invalid response.
        public let code: String?

        public init() {
            code = nil
        }

        public init(code: String) {
            self.code = code
        }

        public init(jsonDictionary: [String: Any]) {
            code = try? ParamParser(dictionary: jsonDictionary).optional(key: "code")
        }
    }

    /// The state of the subscription as understood by the backend
    ///
    /// A subscription will be in the `active` state as long as the current
    /// subscription payment has been successfully processed by the payment
    /// processor.
    ///
    /// - Note
    /// Signal servers get a callback when a subscription is going to renew. If
    /// the user hasn't performed a "subscription keep-alive in ~30-45 days, the
    /// server will, upon getting that callback, cancel the subscription.
    public enum SubscriptionStatus: String {
        case unknown
        case trialing = "trialing"
        case incomplete = "incomplete"
        case incompleteExpired = "incomplete_expired"
        case unpaid = "unpaid"

        /// Indicates the subscription has been paid successfully for the
        /// current period, and all is well.
        case active = "active"

        /// Indicates the subscription has been unrecoverably canceled. This may
        /// be due to terminal failures while renewing (in which case the charge
        /// failure should be populated), or due to inactivity (in which case
        /// there will be no charge failure, as Signal servers canceled the
        /// subscription artificially).
        case canceled = "canceled"

        /// Indicates the subscription failed to renew, but the payment
        /// processor is planning to retry the renewal. If the future renewal
        /// succeeds, the subscription will go back to being "active". Continued
        /// renewal failures will result in the subscription being canceled.
        ///
        /// - Note
        /// Retries are not predictable, but are expected to happen on the scale
        /// of days, for up to circa two weeks.
        case pastDue = "past_due"
    }

    public let level: UInt
    public let amount: FiatMoney
    public let endOfCurrentPeriod: TimeInterval
    public let billingCycleAnchor: TimeInterval
    public let active: Bool
    public let cancelAtEndOfPeriod: Bool
    public let status: SubscriptionStatus
    public let paymentProcessor: DonationPaymentProcessor
    /// - Note
    /// This will never be `.applePay`, since the server treats Apple Pay
    /// payments like credit card payments.
    public let paymentMethod: DonationPaymentMethod?

    /// Whether the payment for this subscription is actively processing, and
    /// has not yet succeeded nor failed.
    public let isPaymentProcessing: Bool

    /// Indicates that payment for this subscription failed.
    public let chargeFailure: ChargeFailure?

    public var debugDescription: String {
        [
            "Subscription",
            "End of current period: \(endOfCurrentPeriod)",
            "Billing cycle anchor: \(billingCycleAnchor)",
            "Cancel at end of period?: \(cancelAtEndOfPeriod)",
            "Status: \(status)",
            "Charge failure: \(chargeFailure.debugDescription)"
        ].joined(separator: ". ")
    }

    public init(subscriptionDict: [String: Any], chargeFailureDict: [String: Any]?) throws {
        let params = ParamParser(dictionary: subscriptionDict)
        level = try params.required(key: "level")
        let currencyCode: Currency.Code = try {
            let raw: String = try params.required(key: "currency")
            return raw.uppercased()
        }()
        amount = FiatMoney(
            currencyCode: currencyCode,
            value: try {
                let integerValue: Int64 = try params.required(key: "amount")
                let decimalValue = Decimal(integerValue)
                if DonationUtilities.zeroDecimalCurrencyCodes.contains(currencyCode) {
                    return decimalValue
                } else {
                    return decimalValue / 100
                }
            }()
        )
        endOfCurrentPeriod = try params.required(key: "endOfCurrentPeriod")
        billingCycleAnchor = try params.required(key: "billingCycleAnchor")
        active = try params.required(key: "active")
        cancelAtEndOfPeriod = try params.required(key: "cancelAtPeriodEnd")
        status = SubscriptionStatus(rawValue: try params.required(key: "status")) ?? .unknown

        let processorString: String = try params.required(key: "processor")
        if let paymentProcessor = DonationPaymentProcessor(rawValue: processorString) {
            self.paymentProcessor = paymentProcessor
        } else {
            throw OWSAssertionError("Unexpected payment processor: \(processorString)")
        }

        let paymentMethodString: String? = try params.optional(key: "paymentMethod")
        if let paymentMethod = paymentMethodString.map({ DonationPaymentMethod(serverRawValue: $0) }) {
            self.paymentMethod = paymentMethod
        } else {
            owsFailDebug("[Donations] Unrecognized payment method while parsing subscription: \(paymentMethodString ?? "nil")")
            self.paymentMethod = nil
        }

        isPaymentProcessing = try params.required(key: "paymentProcessing")

        if let chargeFailureDict = chargeFailureDict {
            chargeFailure = ChargeFailure(jsonDictionary: chargeFailureDict)
        } else {
            chargeFailure = nil
        }
    }
}

public extension Notification.Name {
    static let hasExpiredGiftBadgeDidChangeNotification = NSNotification.Name("hasExpiredGiftBadgeDidChangeNotification")
}

@objc
public class SubscriptionManagerImpl: NSObject {

    @objc
    public override init() {
        super.init()

        SwiftSingletons.register(self)

        AppReadiness.runNowOrWhenAppWillBecomeReady {
            Self.warmCaches()
        }

        AppReadiness.runNowOrWhenAppDidBecomeReadyAsync {
            DispatchQueue.global().async {
                Self.performMigrationToStorageServiceIfNecessary()
                Self.performSubscriptionKeepAliveIfNecessary()
            }
        }
    }

    private static func warmCaches() {
        let value = databaseStorage.read { displayBadgesOnProfile(transaction: $0) }
        displayBadgesOnProfileCache.set(value)
    }

    private static func performMigrationToStorageServiceIfNecessary() {
        let hasMigratedToStorageService = databaseStorage.read { transaction in
            subscriptionKVS.getBool(hasMigratedToStorageServiceKey, defaultValue: false, transaction: transaction)
        }

        guard !hasMigratedToStorageService else { return }

        Logger.info("[Donations] Migrating to storage service")

        databaseStorage.write { transaction in
            subscriptionKVS.setBool(true, key: hasMigratedToStorageServiceKey, transaction: transaction)

            let localProfile = profileManagerImpl.localUserProfile()
            let displayBadgesOnProfile = localProfile.badges.allSatisfy { badge in
                badge.isVisible ?? {
                    owsFailDebug("Local user badges should always have a non-nil visibility flag")
                    return true
                }()
            }

            setDisplayBadgesOnProfile(displayBadgesOnProfile, transaction: transaction)
        }

        storageServiceManager.recordPendingLocalAccountUpdates()
    }

    public static var jobQueue: ReceiptCredentialRedemptionJobQueue { smJobQueues.receiptCredentialJobQueue }

    /// - Note
    /// This collection name is reused by other subscription-related stores. For
    /// example, see ``ReceiptCredentialResultStore``.
    private static let subscriptionKVS = SDSKeyValueStore(collection: "SubscriptionKeyValueStore")

    fileprivate static let subscriberIDKey = "subscriberID"
    fileprivate static let subscriberCurrencyCodeKey = "subscriberCurrencyCode"
    fileprivate static let lastSubscriptionExpirationKey = "subscriptionExpiration"
    fileprivate static let lastSubscriptionHeartbeatKey = "subscriptionHeartbeat"
    fileprivate static let userManuallyCancelledSubscriptionKey = "userManuallyCancelledSubscriptionKey"
    fileprivate static let displayBadgesOnProfileKey = "displayBadgesOnProfileKey"
    fileprivate static let knownUserSubscriptionBadgeIDsKey = "knownUserSubscriptionBadgeIDsKey"
    fileprivate static let knownUserBoostBadgeIDsKey = "knownUserBoostBadgeIDsKey"
    fileprivate static let knownUserGiftBadgeIDsKey = "knownUserGiftBageIDsKey"
    fileprivate static let mostRecentlyExpiredBadgeIDKey = "mostRecentlyExpiredBadgeIDKey"
    fileprivate static let mostRecentlyExpiredGiftBadgeIDKey = "mostRecentlyExpiredGiftBadgeIDKey"
    fileprivate static let showExpirySheetOnHomeScreenKey = "showExpirySheetOnHomeScreenKey"
    fileprivate static let mostRecentSubscriptionPaymentMethodKey = "mostRecentSubscriptionPaymentMethod"
    fileprivate static let hasMigratedToStorageServiceKey = "hasMigratedToStorageServiceKey"

    // MARK: Current subscription status

    public class func currentProfileSubscriptionBadges() -> [OWSUserProfileBadgeInfo] {
        let snapshot = profileManagerImpl.localProfileSnapshot(shouldIncludeAvatar: false)
        let profileBadges = snapshot.profileBadgeInfo ?? []
        return profileBadges.compactMap { (badge: OWSUserProfileBadgeInfo) -> OWSUserProfileBadgeInfo? in
            guard SubscriptionBadgeIds.contains(badge.badgeId) else { return nil }
            return badge
        }
    }

    public class func getCurrentSubscriptionStatus(for subscriberID: Data) -> Promise<Subscription?> {
        let request = OWSRequestFactory.subscriptionGetCurrentSubscriptionLevelRequest(subscriberID: subscriberID)
        return firstly {
            networkManager.makePromise(request: request)
        }.map(on: DispatchQueue.global()) { response in
            let statusCode = response.responseStatusCode

            if statusCode != 200 {
                throw OWSAssertionError("Got bad response code \(statusCode).")
            }

            if let json = response.responseBodyJson as? [String: Any] {
                guard let parser = ParamParser(responseObject: json) else {
                    throw OWSAssertionError("Missing or invalid response.")
                }

                guard let subscriptionDict: [String: Any] = try parser.optional(key: "subscription") else {
                    return nil
                }
                let chargeFailureDict: [String: Any]? = try? parser.optional(key: "chargeFailure")

                return try Subscription(subscriptionDict: subscriptionDict,
                                        chargeFailureDict: chargeFailureDict)
            } else {
                return nil
            }
        }
    }

    // MARK: Subscription management

    /// Perform processor-agnostic steps to set up a new subscription, before
    /// payment has been authorized.
    ///
    /// - Returns: The new subscriber ID.
    public class func prepareNewSubscription(currencyCode: Currency.Code) -> Promise<Data> {
        firstly {
            Logger.info("[Donations] Setting up new subscription")

            return setupNewSubscriberID()
        }.map(on: DispatchQueue.sharedUserInitiated) { subscriberID -> Data in
            Logger.info("[Donations] Caching params after setting up new subscription")

            databaseStorage.write { transaction in
                self.setUserManuallyCancelledSubscription(false, transaction: transaction)
                self.setSubscriberID(subscriberID, transaction: transaction)
                self.setSubscriberCurrencyCode(currencyCode, transaction: transaction)
                self.setMostRecentlyExpiredBadgeID(badgeID: nil, transaction: transaction)
                self.setShowExpirySheetOnHomeScreenKey(show: false, transaction: transaction)
            }

            self.storageServiceManager.recordPendingLocalAccountUpdates()

            return subscriberID
        }
    }

    /// Finalize a new subscription, after payment has been authorized with the
    /// given processor.
    public class func finalizeNewSubscription(
        forSubscriberId subscriberId: Data,
        paymentType: RecurringSubscriptionPaymentType,
        subscription: SubscriptionLevel,
        currencyCode: Currency.Code
    ) -> Promise<Subscription> {
        firstly { () -> Promise<Void> in
            Logger.info("[Donations] Setting default payment method on service")

            switch paymentType {
            case let .ideal(setupIntentId):
                return setDefaultIDEALPaymentMethod(
                    for: subscriberId,
                    setupIntentId: setupIntentId
                )
            case
                    .applePay(let paymentMethodId),
                    .creditOrDebitCard(let paymentMethodId),
                    .paypal(let paymentMethodId),
                    .sepa(let paymentMethodId):
                return setDefaultPaymentMethod(
                    for: subscriberId,
                    using: paymentType.paymentProcessor,
                    paymentMethodId: paymentMethodId
                )
            }
        }.then(on: DispatchQueue.sharedUserInitiated) { _ -> Promise<Subscription> in
            Logger.info("[Donations] Selecting subscription level on service")

            databaseStorage.write { transaction in
                Self.setMostRecentSubscriptionPaymentMethod(
                    paymentMethod: paymentType.paymentMethod,
                    transaction: transaction
                )
            }

            return setSubscription(
                for: subscriberId,
                subscription: subscription,
                currencyCode: currencyCode
            )
        }
    }

    /// Update the subscription level for the given subscriber ID.
    public class func updateSubscriptionLevel(
        for subscriberID: Data,
        to subscription: SubscriptionLevel,
        currencyCode: Currency.Code
    ) -> Promise<Subscription> {
        Logger.info("[Donations] Updating subscription level")

        return setSubscription(
            for: subscriberID,
            subscription: subscription,
            currencyCode: currencyCode
        )
    }

    /// Cancel a subscription for the given subscriber ID.
    public class func cancelSubscription(for subscriberID: Data) -> Promise<Void> {
        Logger.info("[Donations] Cancelling subscription")

        return firstly(on: DispatchQueue.global())  {
            // Fetch the latest subscription state
            self.getCurrentSubscriptionStatus(for: subscriberID)
        }.then(on: DispatchQueue.global()) { subscription in
            guard let subscription else {
                return Promise.value(())
            }

            // Check the subscription is in a state that can be cancelled
            // If the state isn't in active or pastDue, skip deleting the
            // subscription on the backend, and continue to clearing out the
            // local subscription information.
            switch subscription.status {
            case .active, .pastDue:
                break
            case .canceled, .incomplete, .incompleteExpired, .trialing, .unpaid, .unknown:
                return Promise.value(())
            }

            let request = OWSRequestFactory.deleteSubscriberID(subscriberID)
            return firstly(on: DispatchQueue.global()) {
                networkManager.makePromise(request: request)
            }.map(on: DispatchQueue.global()) { response in
                switch response.responseStatusCode {
                case 200, 404:
                    break
                default:
                    throw OWSAssertionError("Got bad response code \(response.responseStatusCode).")
                }
            }.done(on: DispatchQueue.global()) {
                Logger.info("[Donations] Deleted remote subscription.")
            }
        }.done(on: DispatchQueue.global()) {
            databaseStorage.write { transaction in
                self.setSubscriberID(nil, transaction: transaction)
                self.setSubscriberCurrencyCode(nil, transaction: transaction)
                self.setLastSubscriptionExpirationDate(nil, transaction: transaction)
                self.setMostRecentSubscriptionPaymentMethod(paymentMethod: nil, transaction: transaction)
                self.setUserManuallyCancelledSubscription(true, transaction: transaction)

                DependenciesBridge.shared.receiptCredentialResultStore
                    .clearRedemptionSuccessForAnyRecurringSubscription(tx: transaction.asV2Write)
                DependenciesBridge.shared.receiptCredentialResultStore
                    .clearRequestErrorForAnyRecurringSubscription(tx: transaction.asV2Write)
            }

            self.storageServiceManager.recordPendingLocalAccountUpdates()
            Logger.info("[Donations] Deleted local subscription.")
        }
    }

    /// Generate and register an ID for a new subscriber.
    ///
    /// - Returns the new subscriber ID.
    private class func setupNewSubscriberID() -> Promise<Data> {
        Logger.info("[Donations] Setting up new subscriber ID")

        let newSubscriberID = Cryptography.generateRandomBytes(UInt(32))
        return firstly {
            self.postSubscriberID(subscriberID: newSubscriberID)
        }.map(on: DispatchQueue.global()) { _ in
            return newSubscriberID
        }
    }

    private class func postSubscriberID(subscriberID: Data) -> Promise<Void> {
        let request = OWSRequestFactory.setSubscriberID(subscriberID)
        return firstly {
            networkManager.makePromise(request: request)
        }.map(on: DispatchQueue.global()) { response in
            let statusCode = response.responseStatusCode

            if statusCode != 200 {
                throw OWSAssertionError("Got bad response code \(statusCode).")
            }
        }
    }

    private class func setDefaultPaymentMethod(
        for subscriberId: Data,
        using processor: DonationPaymentProcessor,
        paymentMethodId: String
    ) -> Promise<Void> {
        let request = OWSRequestFactory.subscriptionSetDefaultPaymentMethod(
            subscriberId: subscriberId,
            processor: processor.rawValue,
            paymentMethodId: paymentMethodId
        )

        return firstly {
            networkManager.makePromise(request: request)
        }.map(on: DispatchQueue.global()) { response in
            let statusCode = response.responseStatusCode
            if statusCode != 200 {
                throw OWSAssertionError("Got bad response code \(statusCode).")
            }
        }
    }

    private class func setDefaultIDEALPaymentMethod(
        for subscriberId: Data,
        setupIntentId: String
    ) -> Promise<Void> {
        let request = OWSRequestFactory.subscriptionSetDefaultIDEALPaymentMethod(
            subscriberId: subscriberId,
            setupIntentId: setupIntentId
        )

        return firstly {
            networkManager.makePromise(request: request)
        }.map(on: DispatchQueue.global()) { response in
            let statusCode = response.responseStatusCode
            if statusCode != 200 {
                throw OWSAssertionError("Got bad response code \(statusCode).")
            }
        }
    }

    /// Set the current subscription to the given level and currency.
    ///
    /// - Returns
    /// The updated subscription.
    private class func setSubscription(
        for subscriberID: Data,
        subscription: SubscriptionLevel,
        currencyCode: Currency.Code
    ) -> Promise<Subscription> {
        let key = Cryptography.generateRandomBytes(UInt(32)).asBase64Url
        let request = OWSRequestFactory.subscriptionSetSubscriptionLevelRequest(
            subscriberID: subscriberID,
            level: subscription.level,
            currency: currencyCode,
            idempotencyKey: key
        )
        return firstly {
            networkManager.makePromise(request: request)
        }.then(on: DispatchQueue.global()) { response -> Promise<Subscription?> in
            let statusCode = response.responseStatusCode
            if statusCode != 200 {
                throw OWSAssertionError("Got bad response code \(statusCode).")
            }

            return self.getCurrentSubscriptionStatus(for: subscriberID)
        }.map(on: DispatchQueue.global()) { subscription in
            guard let subscription = subscription else {
                throw OWSAssertionError("Failed to fetch valid subscription object after setSubscription")
            }

            databaseStorage.write { transaction in
                self.setSubscriberCurrencyCode(currencyCode, transaction: transaction)
                self.setLastSubscriptionExpirationDate(Date(timeIntervalSince1970: subscription.endOfCurrentPeriod), transaction: transaction)
            }

            self.storageServiceManager.recordPendingLocalAccountUpdates()

            return subscription
        }
    }

    public class func requestAndRedeemReceipt(
        subscriberId: Data,
        subscriptionLevel: UInt,
        priorSubscriptionLevel: UInt?,
        paymentProcessor: DonationPaymentProcessor,
        paymentMethod: DonationPaymentMethod?,
        isNewSubscription: Bool,
        shouldSuppressPaymentAlreadyRedeemed: Bool
    ) -> Promise<Void> {
        let (promise, future) = Promise<Void>.pending()
        let request = generateReceiptRequest()
        databaseStorage.asyncWrite { transaction in
            self.jobQueue.addSubscriptionJob(
                paymentProcessor: paymentProcessor,
                paymentMethod: paymentMethod,
                receiptCredentialRequestContext: request.context.serialize().asData,
                receiptCredentialRequest: request.request.serialize().asData,
                subscriberID: subscriberId,
                targetSubscriptionLevel: subscriptionLevel,
                priorSubscriptionLevel: priorSubscriptionLevel,
                isNewSubscription: isNewSubscription,
                shouldSuppressPaymentAlreadyRedeemed: shouldSuppressPaymentAlreadyRedeemed,
                future: future,
                transaction: transaction
            )
        }
        return promise
    }

    public class func requestAndRedeemReceipt(
        boostPaymentIntentId: String,
        amount: FiatMoney,
        paymentProcessor: DonationPaymentProcessor,
        paymentMethod: DonationPaymentMethod
    ) -> Promise<Void> {
        let (promise, future) = Promise<Void>.pending()
        let request = generateReceiptRequest()
        databaseStorage.asyncWrite { transaction in
            self.jobQueue.addBoostJob(
                amount: amount,
                paymentProcessor: paymentProcessor,
                paymentMethod: paymentMethod,
                receiptCredentialRequestContext: request.context.serialize().asData,
                receiptCredentialRequest: request.request.serialize().asData,
                boostPaymentIntentID: boostPaymentIntentId,
                future: future,
                transaction: transaction
            )
        }
        return promise
    }

    public class func generateReceiptRequest() -> (context: ReceiptCredentialRequestContext, request: ReceiptCredentialRequest) {
        do {
            let clientOperations = try clientZKReceiptOperations()
            let receiptSerial = try generateReceiptSerial()

            let receiptCredentialRequestContext = try clientOperations.createReceiptCredentialRequestContext(receiptSerial: receiptSerial)
            let receiptCredentialRequest = try receiptCredentialRequestContext.getRequest()
            return (receiptCredentialRequestContext, receiptCredentialRequest)
        } catch {
            // This operation happens entirely on-device and is unlikely to fail.
            // If it does, a full crash is probably desirable.
            owsFail("Could not generate receipt request: \(error)")
        }
    }

    /// Represents a known error received during a receipt credential request.
    ///
    /// Not to be confused with ``ReceiptCredentialRequestError``.
    public struct KnownReceiptCredentialRequestError: Error {
        /// A code describing this error.
        public let errorCode: ReceiptCredentialRequestError.ErrorCode

        /// If this error represents a payment failure, contains a string from
        /// the payment processor describing the payment failure.
        public let chargeFailureCodeIfPaymentFailed: String?

        fileprivate init(
            errorCode: ReceiptCredentialRequestError.ErrorCode,
            chargeFailureCodeIfPaymentFailed: String? = nil
        ) {
            owsAssert(
                chargeFailureCodeIfPaymentFailed == nil || errorCode == .paymentFailed,
                "Must only provide a charge failure if payment failed!"
            )

            self.errorCode = errorCode
            self.chargeFailureCodeIfPaymentFailed = chargeFailureCodeIfPaymentFailed
        }
    }

    public class func requestReceiptCredentialPresentation(
        subscriberId: Data,
        targetSubscriptionLevel: UInt,
        priorSubscriptionLevel: UInt,
        context: ReceiptCredentialRequestContext,
        request: ReceiptCredentialRequest
    ) throws -> Promise<ReceiptCredentialPresentation> {
        return firstly {
            let networkRequest = OWSRequestFactory.subscriptionReceiptCredentialsRequest(
                subscriberID: subscriberId,
                request: request.serialize().asData
            )

            return networkManager.makePromise(request: networkRequest)
        }.map(on: DispatchQueue.global()) { response throws -> ReceiptCredentialPresentation in
            return try self.parseReceiptCredentialPresentationResponse(
                httpResponse: response,
                receiptCredentialRequestContext: context,
                isValidReceiptLevelPredicate: { receiptLevel in
                    // Validate that receipt credential level matches requested
                    // level, or prior subscription level.
                    if receiptLevel == targetSubscriptionLevel {
                        return true
                    } else if priorSubscriptionLevel != 0 {
                        return receiptLevel == priorSubscriptionLevel
                    }

                    return false
                }
            )
        }.recover(on: DispatchQueue.global()) { error throws -> Promise<ReceiptCredentialPresentation> in
            throw parseReceiptCredentialPresentationError(error: error)
        }
    }

    public static func requestReceiptCredentialPresentation(
        boostPaymentIntentId: String,
        expectedBadgeLevel: OneTimeBadgeLevel,
        paymentProcessor: DonationPaymentProcessor,
        context: ReceiptCredentialRequestContext,
        request: ReceiptCredentialRequest
    ) throws -> Promise<ReceiptCredentialPresentation> {
        return firstly {
            let networkRequest = OWSRequestFactory.boostReceiptCredentials(
                with: boostPaymentIntentId,
                for: paymentProcessor.rawValue,
                request: request.serialize().asData
            )

            return networkManager.makePromise(request: networkRequest)
        }.map(on: DispatchQueue.global()) { response throws -> ReceiptCredentialPresentation in
            return try self.parseReceiptCredentialPresentationResponse(
                httpResponse: response,
                receiptCredentialRequestContext: context,
                isValidReceiptLevelPredicate: { receiptLevel in
                    return receiptLevel == expectedBadgeLevel.rawValue
                }
            )
        }.recover(on: DispatchQueue.global()) { error throws -> Promise<ReceiptCredentialPresentation> in
            throw parseReceiptCredentialPresentationError(error: error)
        }
    }

    private class func parseReceiptCredentialPresentationResponse(
        httpResponse: HTTPResponse,
        receiptCredentialRequestContext: ReceiptCredentialRequestContext,
        isValidReceiptLevelPredicate: (UInt64) -> Bool
    ) throws -> ReceiptCredentialPresentation {
        let clientOperations = try clientZKReceiptOperations()

        let httpStatusCode = httpResponse.responseStatusCode
        switch httpStatusCode {
        case 200:
            Logger.info("[Donations] Got valid receipt response.")
        case 204:
            Logger.info("[Donations] No receipt yet, payment processing.")
            throw KnownReceiptCredentialRequestError(
                errorCode: .paymentStillProcessing
            )
        default:
            throw OWSAssertionError("[Donations] Unexpected success status code: \(httpStatusCode)")
        }

        func failValidation(_ message: String) -> Error {
            owsFailDebug(message)
            return KnownReceiptCredentialRequestError(errorCode: .localValidationFailed)
        }

        guard
            let json = httpResponse.responseBodyJson,
            let parser = ParamParser(responseObject: json),
            let receiptCredentialResponseData = Data(
                base64Encoded: (try parser.required(key: "receiptCredentialResponse") as String)
            )
        else {
            throw failValidation("Failed to parse receipt credential response into data!")
        }

        let receiptCredentialResponse = try ReceiptCredentialResponse(
            contents: [UInt8](receiptCredentialResponseData)
        )
        let receiptCredential = try clientOperations.receiveReceiptCredential(
            receiptCredentialRequestContext: receiptCredentialRequestContext,
            receiptCredentialResponse: receiptCredentialResponse
        )

        let receiptLevel = try receiptCredential.getReceiptLevel()
        guard isValidReceiptLevelPredicate(receiptLevel) else {
            throw failValidation("Unexpected receipt credential level! \(receiptLevel)")
        }

        // Validate receipt credential expiration % 86400 == 0, per server spec
        let expiration = try receiptCredential.getReceiptExpirationTime()
        guard expiration % 86400 == 0 else {
            throw failValidation("Invalid receipt credential expiration! \(expiration)")
        }

        // Validate expiration is less than 90 days from now
        let maximumValidExpirationDate = Date().timeIntervalSince1970 + (90 * 24 * 60 * 60)
        guard TimeInterval(expiration) < maximumValidExpirationDate else {
            throw failValidation("Invalid receipt credential expiration!")
        }

        return try clientOperations.createReceiptCredentialPresentation(
            receiptCredential: receiptCredential
        )
    }

    private class func parseReceiptCredentialPresentationError(
        error: Error
    ) -> Error {
        guard
            let httpStatusCode = error.httpStatusCode,
            let errorCode = ReceiptCredentialRequestError.ErrorCode(rawValue: httpStatusCode)
        else { return error }

        if
            case .paymentFailed = errorCode,
            let parser = ParamParser(responseObject: error.httpResponseJson),
            let chargeFailureDict: [String: Any] = try? parser.optional(key: "chargeFailure"),
            let chargeFailureCode = chargeFailureDict["code"] as? String
        {
            return KnownReceiptCredentialRequestError(
                errorCode: errorCode,
                chargeFailureCodeIfPaymentFailed: chargeFailureCode
            )
        }

        return KnownReceiptCredentialRequestError(errorCode: errorCode)
    }

    public class func redeemReceiptCredentialPresentation(
        receiptCredentialPresentation: ReceiptCredentialPresentation
    ) -> Promise<Void> {
        let expiresAtForLogging: String = {
            guard let result = try? receiptCredentialPresentation.getReceiptExpirationTime() else { return "UNKNOWN" }
            return String(result)
        }()
        Logger.info("[Donations] Redeeming receipt credential presentation. Expires at \(expiresAtForLogging)")

        let receiptCredentialPresentationData = receiptCredentialPresentation.serialize().asData

        let request = OWSRequestFactory.subscriptionRedeemReceiptCredential(
            receiptCredentialPresentation: receiptCredentialPresentationData
        )
        return firstly(on: DispatchQueue.global()) {
            networkManager.makePromise(request: request)
        }.map(on: DispatchQueue.global()) { response in
            let statusCode = response.responseStatusCode
            if statusCode != 200 {
                Logger.warn("[Donations] Receipt credential presentation request failed with status code \(statusCode)")
                throw OWSRetryableSubscriptionError()
            }
        }.then(on: DispatchQueue.global()) {
            self.profileManagerImpl.fetchLocalUsersProfile(authedAccount: .implicit()).asVoid()
        }
    }

    private class func generateReceiptSerial() throws -> ReceiptSerial {
        let count = ReceiptSerial.SIZE
        let bytes = Cryptography.generateRandomBytes(UInt(count))
        return try ReceiptSerial(contents: [UInt8](bytes))
    }

    private class func clientZKReceiptOperations() throws -> ClientZkReceiptOperations {
        let params = try GroupsV2Protos.serverPublicParams()
        return ClientZkReceiptOperations(serverPublicParams: params)
    }

    // 3 day heartbeat interval
    private static let heartbeatInterval: TimeInterval = 3 * kDayInterval

    // MARK: Heartbeat

    public class func performSubscriptionKeepAliveIfNecessary() {
        let tsAccountManager = DependenciesBridge.shared.tsAccountManager
        guard tsAccountManager.registrationStateWithMaybeSneakyTransaction.isPrimaryDevice ?? false else {
            return
        }

        // Fetch subscriberID / subscriber currencyCode
        var lastKeepAliveHeartbeat: Date?
        var lastSubscriptionExpiration: Date?
        var subscriberID: Data?
        var currencyCode: Currency.Code?
        databaseStorage.read { transaction in
            lastKeepAliveHeartbeat = self.subscriptionKVS.getDate(self.lastSubscriptionHeartbeatKey, transaction: transaction)
            lastSubscriptionExpiration = self.lastSubscriptionExpirationDate(transaction: transaction)
            subscriberID = self.getSubscriberID(transaction: transaction)
            currencyCode = self.getSubscriberCurrencyCode(transaction: transaction)
        }

        var performHeartbeat: Bool = true
        if let lastKeepAliveHeartbeat = lastKeepAliveHeartbeat, Date().timeIntervalSince(lastKeepAliveHeartbeat) < heartbeatInterval {
            performHeartbeat = false
        }

        guard performHeartbeat else {
            return
        }

        Logger.info("[Donations] Performing subscription heartbeat")

        guard let subscriberID = subscriberID, currencyCode != nil else {
            Logger.warn("[Donations] No subscription + currency code found")
            self.updateSubscriptionHeartbeatDate()
            return
        }

        firstly(on: DispatchQueue.global()) {
            self.postSubscriberID(subscriberID: subscriberID)
        }.then(on: DispatchQueue.global()) {
            self.getCurrentSubscriptionStatus(for: subscriberID)
        }.done(on: DispatchQueue.global()) { subscription in
            defer {
                // We did a heartbeat, so regardless of the outcomes below we
                // should save the fact that we did so.
                self.updateSubscriptionHeartbeatDate()
            }

            guard let subscription else {
                Logger.info("[Donations] No current subscription for this subscriber ID.")
                return
            }

            if let lastSubscriptionExpiration, lastSubscriptionExpiration.timeIntervalSince1970 == subscription.endOfCurrentPeriod {
                Logger.info("[Donations] Not triggering receipt redemption, expiration date is the same")
            } else if subscription.status == .pastDue {
                /// For some payment methods (e.g., cards), the payment
                /// processors will automatically retry a subscription-renewal
                /// payment failure. While that's happening, the subscription
                /// will be "past due".
                ///
                /// Retries will occur on the scale of days, for a period of
                /// weeks. We don't want to attempt badge redemption during this
                /// time since we don't expect to succeed now, but failure
                /// doesn't yet mean much as we may succeed in the future.
                Logger.warn("[Donations] Subscription failed to renew, but payment processor is retrying. Not yet attempting receipt credential redemption for this period.")
            } else {
                /// When a subscription renews, the "end of period" changes to
                /// reflect a later date. When that happens, we need to redeem a
                /// badge for the new period.
                ///
                /// We may also get here if we're missing our last subscription
                /// expiration entirely, potentially due to a reinstall. In that
                /// case, we don't know whether or not we've already redeemed a
                /// badge for the period we're in. Either way, we can kick off
                /// a receipt credential job:
                ///
                /// - If we haven't redeemed the badge yet, maybe because the
                /// subscription renewed just before we reinstalled, everything
                /// is fortuitously working as expected.
                ///
                /// - If we *have* redeemed the badge, we can expect the job to
                /// fail with a "payment already redeemed" error. We'll
                /// configure the job to swallow that error and be back to our
                /// regular rhythm.

                let shouldSuppressPaymentAlreadyRedeemed: Bool = {
                    let newExpiration = Date(timeIntervalSince1970: subscription.endOfCurrentPeriod)

                    if let lastSubscriptionExpiration {
                        Logger.info("[Donations] Triggering receipt redemption job during heartbeat, last expiration \(lastSubscriptionExpiration), new expiration \(newExpiration)")
                        return false
                    } else {
                        Logger.warn("[Donations] Attempting receipt credential redemption during heartbeat, missing last subscription expiration. New expiration: \(newExpiration)")
                        return true
                    }
                }()

                _ = requestAndRedeemReceipt(
                    subscriberId: subscriberID,
                    subscriptionLevel: subscription.level,
                    priorSubscriptionLevel: nil,
                    paymentProcessor: subscription.paymentProcessor,
                    paymentMethod: subscription.paymentMethod,
                    isNewSubscription: false,
                    shouldSuppressPaymentAlreadyRedeemed: shouldSuppressPaymentAlreadyRedeemed
                )

                // Save last expiration
                databaseStorage.write { transaction in
                    self.setLastSubscriptionExpirationDate(Date(timeIntervalSince1970: subscription.endOfCurrentPeriod), transaction: transaction)
                }
            }
        }.catch(on: DispatchQueue.global()) { error in
            owsFailDebug("Failed subscription heartbeat with error \(error)")
        }
    }

    private static func updateSubscriptionHeartbeatDate() {
        databaseStorage.write { transaction in
            // Update keepalive
            self.subscriptionKVS.setDate(Date(), key: self.lastSubscriptionHeartbeatKey, transaction: transaction)
        }
    }

    @objc
    public class func performDeviceSubscriptionExpiryUpdate() {
        Logger.info("[Donations] doing subscription expiry update")

        var lastSubscriptionExpiration: Date?
        var subscriberID: Data?
        databaseStorage.read { transaction in
            lastSubscriptionExpiration = self.subscriptionKVS.getDate(self.lastSubscriptionExpirationKey, transaction: transaction)
            subscriberID = self.getSubscriberID(transaction: transaction)
        }

        guard let subscriberID = subscriberID else {
            owsFailDebug("Device missing subscriberID")
            return
        }

        firstly(on: DispatchQueue.global()) {
            // Fetch current subscription
            self.getCurrentSubscriptionStatus(for: subscriberID)
        }.done(on: DispatchQueue.global()) { subscription in
            guard let subscription = subscription else {
                Logger.info("[Donations] No current subscription for this subscriberID")
                return
            }

            if let lastSubscriptionExpiration = lastSubscriptionExpiration, lastSubscriptionExpiration.timeIntervalSince1970 == subscription.endOfCurrentPeriod {
                Logger.info("[Donations] Not updating last subscription expiration, expirations are the same")
            } else {
                Logger.info("[Donations] Updating last subscription expiration")
                // Save last expiration
                databaseStorage.write { transaction in
                    self.setLastSubscriptionExpirationDate(Date(timeIntervalSince1970: subscription.endOfCurrentPeriod), transaction: transaction)
                }
            }

        }.catch(on: DispatchQueue.global()) { error in
            owsFailDebug("Failed last subscription expiration update with error \(error)")
        }
    }
}

// MARK: - State management

extension SubscriptionManagerImpl {

    public static func getSubscriberID(transaction: SDSAnyReadTransaction) -> Data? {
        guard let subscriberID = subscriptionKVS.getObject(
            forKey: subscriberIDKey,
            transaction: transaction
        ) as? Data else {
            return nil
        }
        return subscriberID
    }

    public static func setSubscriberID(_ subscriberID: Data?, transaction: SDSAnyWriteTransaction) {
        subscriptionKVS.setObject(subscriberID,
                                  key: subscriberIDKey,
                                  transaction: transaction)
    }

    public static func getSubscriberCurrencyCode(transaction: SDSAnyReadTransaction) -> String? {
        guard let subscriberCurrencyCode = subscriptionKVS.getObject(
            forKey: subscriberCurrencyCodeKey,
            transaction: transaction
        ) as? String else {
            return nil
        }
        return subscriberCurrencyCode
    }

    public static func setSubscriberCurrencyCode(
        _ currencyCode: Currency.Code?,
        transaction: SDSAnyWriteTransaction
    ) {
        subscriptionKVS.setObject(currencyCode,
                                  key: subscriberCurrencyCodeKey,
                                  transaction: transaction)
    }

    public static func userManuallyCancelledSubscription(transaction: SDSAnyReadTransaction) -> Bool {
        return subscriptionKVS.getBool(userManuallyCancelledSubscriptionKey, transaction: transaction) ?? false
    }

    private static func setUserManuallyCancelledSubscription(_ value: Bool, updateStorageService: Bool = false, transaction: SDSAnyWriteTransaction) {
        guard value != userManuallyCancelledSubscription(transaction: transaction) else { return }
        subscriptionKVS.setBool(value, key: userManuallyCancelledSubscriptionKey, transaction: transaction)
        if updateStorageService {
            storageServiceManager.recordPendingLocalAccountUpdates()
        }
    }

    private static func displayBadgesOnProfile(transaction: SDSAnyReadTransaction) -> Bool {
        return subscriptionKVS.getBool(displayBadgesOnProfileKey, transaction: transaction) ?? false
    }

    private static var displayBadgesOnProfileCache = AtomicBool(false, lock: .sharedGlobal)
    private static func setDisplayBadgesOnProfile(_ value: Bool, updateStorageService: Bool = false, transaction: SDSAnyWriteTransaction) {
        guard value != displayBadgesOnProfile(transaction: transaction) else { return }
        displayBadgesOnProfileCache.set(value)
        subscriptionKVS.setBool(value, key: displayBadgesOnProfileKey, transaction: transaction)
        if updateStorageService {
            storageServiceManager.recordPendingLocalAccountUpdates()
        }
    }

    fileprivate static func lastSubscriptionExpirationDate(transaction: SDSAnyReadTransaction) -> Date? {
        return subscriptionKVS.getDate(lastSubscriptionExpirationKey, transaction: transaction)
    }

    fileprivate static func setLastSubscriptionExpirationDate(_ expirationDate: Date?, transaction: SDSAnyWriteTransaction) {
        guard let expirationDate = expirationDate else {
            subscriptionKVS.removeValue(forKey: lastSubscriptionExpirationKey, transaction: transaction)
            return
        }

        subscriptionKVS.setDate(expirationDate, key: lastSubscriptionExpirationKey, transaction: transaction)
    }

    fileprivate static func setKnownUserSubscriptionBadgeIDs(badgeIDs: [String], transaction: SDSAnyWriteTransaction) {
        subscriptionKVS.setObject(badgeIDs, key: knownUserSubscriptionBadgeIDsKey, transaction: transaction)
    }

    fileprivate static func knownUserSubscriptionBadgeIDs(transaction: SDSAnyReadTransaction) -> [String] {
        let ids = subscriptionKVS.getObject(forKey: knownUserSubscriptionBadgeIDsKey, transaction: transaction) as? [String]
        return ids ?? []
    }

    fileprivate static func setKnownUserBoostBadgeIDs(badgeIDs: [String], transaction: SDSAnyWriteTransaction) {
        subscriptionKVS.setObject(badgeIDs, key: knownUserBoostBadgeIDsKey, transaction: transaction)
    }

    fileprivate static func knownUserBoostBadgeIDs(transaction: SDSAnyReadTransaction) -> [String] {
        guard let ids = subscriptionKVS.getObject(forKey: knownUserBoostBadgeIDsKey, transaction: transaction) as? [String] else {
            return []
        }

        return ids
    }

    fileprivate static func setKnownUserGiftBadgeIDs(badgeIDs: [String], transaction: SDSAnyWriteTransaction) {
        subscriptionKVS.setObject(badgeIDs, key: knownUserGiftBadgeIDsKey, transaction: transaction)
    }

    fileprivate static func knownUserGiftBadgeIDs(transaction: SDSAnyReadTransaction) -> [String] {
        subscriptionKVS.getObject(forKey: knownUserGiftBadgeIDsKey, transaction: transaction) as? [String] ?? []
    }

    fileprivate static func setMostRecentlyExpiredBadgeID(badgeID: String?, transaction: SDSAnyWriteTransaction) {
        guard let badgeID = badgeID else {
            subscriptionKVS.removeValue(forKey: mostRecentlyExpiredBadgeIDKey, transaction: transaction)
            return
        }

        subscriptionKVS.setString(badgeID, key: mostRecentlyExpiredBadgeIDKey, transaction: transaction)

    }

    public static func mostRecentlyExpiredBadgeID(transaction: SDSAnyReadTransaction) -> String? {
        subscriptionKVS.getString(mostRecentlyExpiredBadgeIDKey, transaction: transaction)
    }

    public static func clearMostRecentlyExpiredBadgeIDWithSneakyTransaction() {
        databaseStorage.write { transaction in
            self.setMostRecentlyExpiredBadgeID(badgeID: nil, transaction: transaction)
        }
    }

    fileprivate static func setMostRecentlyExpiredGiftBadgeID(badgeID: String?, transaction: SDSAnyWriteTransaction) {
        if let badgeID = badgeID {
            subscriptionKVS.setString(badgeID, key: mostRecentlyExpiredGiftBadgeIDKey, transaction: transaction)
        } else {
            subscriptionKVS.removeValue(forKey: mostRecentlyExpiredGiftBadgeIDKey, transaction: transaction)
        }
        transaction.addAsyncCompletionOnMain {
            NotificationCenter.default.post(name: .hasExpiredGiftBadgeDidChangeNotification, object: nil)
        }
    }

    public static func mostRecentlyExpiredGiftBadgeID(transaction: SDSAnyReadTransaction) -> String? {
        subscriptionKVS.getString(mostRecentlyExpiredGiftBadgeIDKey, transaction: transaction)
    }

    public static func clearMostRecentlyExpiredGiftBadgeIDWithSneakyTransaction() {
        databaseStorage.write { transaction in
            self.setMostRecentlyExpiredGiftBadgeID(badgeID: nil, transaction: transaction)
        }
    }

    public static func setShowExpirySheetOnHomeScreenKey(show: Bool, transaction: SDSAnyWriteTransaction) {
        Logger.info("\(show)")
        subscriptionKVS.setBool(show, key: showExpirySheetOnHomeScreenKey, transaction: transaction)
    }

    public static func showExpirySheetOnHomeScreenKey(transaction: SDSAnyReadTransaction) -> Bool {
        return subscriptionKVS.getBool(showExpirySheetOnHomeScreenKey, transaction: transaction) ?? false
    }

    public static func setMostRecentSubscriptionPaymentMethod(
        paymentMethod: DonationPaymentMethod?,
        transaction: SDSAnyWriteTransaction
    ) {
        subscriptionKVS.setString(paymentMethod?.rawValue, key: mostRecentSubscriptionPaymentMethodKey, transaction: transaction)
    }

    public static func getMostRecentSubscriptionPaymentMethod(transaction: SDSAnyReadTransaction) -> DonationPaymentMethod? {
        guard let paymentMethodString = subscriptionKVS.getString(mostRecentSubscriptionPaymentMethodKey, transaction: transaction) else {
            return nil
        }

        guard let paymentMethod = DonationPaymentMethod(rawValue: paymentMethodString) else {
            owsFailBeta("Unexpected payment method string: \(paymentMethodString)")
            return nil
        }

        return paymentMethod
    }
}

// MARK: -

public class OWSRetryableSubscriptionError: NSObject, CustomNSError, IsRetryableProvider {
    @objc
    public static var asNSError: NSError {
        OWSRetryableSubscriptionError() as Error as NSError
    }

    // MARK: - IsRetryableProvider

    public var isRetryableProvider: Bool { true }
}

extension SubscriptionManagerImpl {

    private static var cachedBadges = [OneTimeBadgeLevel: CachedBadge]()

    public class func getCachedBadge(level: OneTimeBadgeLevel) -> CachedBadge {
        if let cachedBadge = self.cachedBadges[level] {
            return cachedBadge
        }
        let cachedBadge = CachedBadge(level: level)
        self.cachedBadges[level] = cachedBadge
        return cachedBadge
    }

    public class func getBoostBadge() -> Promise<ProfileBadge> {
        firstly {
            getOneTimeBadge(level: .boostBadge)
        }.map { profileBadge in
            guard let profileBadge = profileBadge else {
                owsFail("No badge for this level was found")
            }
            return profileBadge
        }
    }

    public class func getOneTimeBadge(level: OneTimeBadgeLevel) -> Promise<ProfileBadge?> {
        firstly { () -> Promise<DonationConfiguration> in
            fetchDonationConfiguration()
        }.map { donationConfiguration -> ProfileBadge? in
            switch level {
            case .boostBadge:
                return donationConfiguration.boost.badge
            case .giftBadge(let level):
                guard donationConfiguration.gift.level == level.rawLevel else {
                    Logger.warn("Requested gift badge with level \(level), which did not match known gift badge with level \(donationConfiguration.gift.level)")
                    return nil
                }

                return donationConfiguration.gift.badge
            }
        }
    }

    public class func getSubscriptionBadge(subscriptionLevel levelRawValue: UInt) -> Promise<ProfileBadge> {
        firstly { () -> Promise<DonationConfiguration> in
            fetchDonationConfiguration()
        }.map { donationConfiguration throws -> ProfileBadge in
            guard let matchingLevel = donationConfiguration.subscription.levels.first(where: {
                $0.level == levelRawValue
            }) else {
                throw OWSAssertionError("Missing requested subscription level!")
            }

            return matchingLevel.badge
        }
    }
}

extension SubscriptionManagerImpl: SubscriptionManager {
    public func getSubscriberID(transaction: SDSAnyReadTransaction) -> Data? {
        Self.getSubscriberID(transaction: transaction)
    }

    public func setSubscriberID(_ subscriberID: Data?, transaction: SDSAnyWriteTransaction) {
        Self.setSubscriberID(subscriberID, transaction: transaction)
    }

    public func getSubscriberCurrencyCode(transaction: SDSAnyReadTransaction) -> String? {
        Self.getSubscriberCurrencyCode(transaction: transaction)
    }

    public func setSubscriberCurrencyCode(_ currencyCode: Currency.Code?, transaction: SDSAnyWriteTransaction) {
        Self.setSubscriberCurrencyCode(currencyCode, transaction: transaction)
    }

    public func reconcileBadgeStates(transaction: SDSAnyWriteTransaction) {
        let currentBadges = profileManagerImpl.localUserProfile().badges

        let currentSubscriberBadgeIDs = currentBadges.compactMap { (badge: OWSUserProfileBadgeInfo) -> String? in
            guard SubscriptionBadgeIds.contains(badge.badgeId) else { return nil }
            return badge.badgeId
        }

        let currentBoostBadgeIDs = currentBadges.compactMap { (badge: OWSUserProfileBadgeInfo) -> String? in
            guard BoostBadgeIds.contains(badge.badgeId) else { return nil }
            return badge.badgeId
        }

        let currentGiftBadgeIDs = currentBadges.compactMap { (badge: OWSUserProfileBadgeInfo) -> String? in
            guard GiftBadgeIds.contains(badge.badgeId) else { return nil }
            return badge.badgeId
        }

        // Read existing values
        let persistedSubscriberBadgeIDs = Self.knownUserSubscriptionBadgeIDs(transaction: transaction)
        let persistedBoostBadgeIDs = Self.knownUserBoostBadgeIDs(transaction: transaction)
        let persistedGiftBadgeIDs = Self.knownUserGiftBadgeIDs(transaction: transaction)
        let oldExpiredGiftBadgeID = Self.mostRecentlyExpiredGiftBadgeID(transaction: transaction)
        var expiringBadgeId = Self.mostRecentlyExpiredBadgeID(transaction: transaction)
        var userManuallyCancelled = Self.userManuallyCancelledSubscription(transaction: transaction)
        var showExpiryOnHomeScreen = Self.showExpirySheetOnHomeScreenKey(transaction: transaction)
        var displayBadgesOnProfile = Self.displayBadgesOnProfile(transaction: transaction)

        let isCurrentlyDisplayingBadgesOnProfile = currentBadges.allSatisfy { badge in
            badge.isVisible ?? {
                owsFailDebug("Local user badges should always have a non-nil visibility flag")
                return true
            }()
        }
        if displayBadgesOnProfile != isCurrentlyDisplayingBadgesOnProfile {
            displayBadgesOnProfile = isCurrentlyDisplayingBadgesOnProfile
            Logger.info("Updating displayBadgesOnProfile to reflect state on profile \(displayBadgesOnProfile)")
        }

        let newSubscriberBadgeIds = Set(currentSubscriberBadgeIDs).subtracting(persistedSubscriberBadgeIDs)
        if !newSubscriberBadgeIds.isEmpty {
            Logger.info("Learned of \(newSubscriberBadgeIds.count) new subscriber badge ids: \(newSubscriberBadgeIds)")
        }

        let expiredSubscriberBadgeIds = Set(persistedSubscriberBadgeIDs).subtracting(currentSubscriberBadgeIDs)
        if !expiredSubscriberBadgeIds.isEmpty {
            Logger.info("Learned of \(expiredSubscriberBadgeIds.count) newly expired subscriber badge ids: \(expiredSubscriberBadgeIds)")
        }

        let newBoostBadgeIds = Set(currentBoostBadgeIDs).subtracting(persistedBoostBadgeIDs)
        if !newBoostBadgeIds.isEmpty {
            Logger.info("Learned of \(newBoostBadgeIds.count) new boost badge ids: \(newBoostBadgeIds)")
        }

        let expiredBoostBadgeIds = Set(persistedBoostBadgeIDs).subtracting(currentBoostBadgeIDs)
        if !expiredBoostBadgeIds.isEmpty {
            Logger.info("Learned of \(expiredBoostBadgeIds.count) newly expired boost badge ids: \(expiredBoostBadgeIds)")
        }

        let newGiftBadgeIds = Set(currentGiftBadgeIDs).subtracting(persistedGiftBadgeIDs)
        if !newGiftBadgeIds.isEmpty {
            Logger.info("Learned of \(newGiftBadgeIds.count) new gift badge ids: \(newGiftBadgeIds)")
        }

        let expiredGiftBadgeIds = Set(persistedGiftBadgeIDs).subtracting(currentGiftBadgeIDs)
        if !expiredGiftBadgeIds.isEmpty {
            Logger.info("Learned of \(expiredGiftBadgeIds.count) newly expired gift badge ids: \(expiredGiftBadgeIds)")
        }

        var newExpiringBadgeId: String?
        if let persistedBadgeId = persistedSubscriberBadgeIDs.first, currentSubscriberBadgeIDs.isEmpty {
            if !userManuallyCancelled {
                Logger.info("Last subscription badge id expired \(persistedBadgeId)")
                newExpiringBadgeId = persistedBadgeId
            } else {
                Logger.info("Last subscription badge id expired \(persistedBadgeId), but ignoring because subscription was manually cancelled")
            }
        }

        if let persistedBadgeId = persistedBoostBadgeIDs.first, currentBoostBadgeIDs.isEmpty {
            if (expiringBadgeId == nil || BoostBadgeIds.contains(expiringBadgeId!)) && newExpiringBadgeId == nil {
                Logger.info("Last boost badge id expired \(persistedBadgeId)")
                newExpiringBadgeId = persistedBadgeId
            } else {
                Logger.info("Last boost badge id expired \(persistedBadgeId), but ignoring because subscription badge also expired")
            }
        }

        if let newExpiringBadgeId = newExpiringBadgeId, newExpiringBadgeId != expiringBadgeId {
            Logger.info("Recording new expired badge id to show on home screen \(newExpiringBadgeId)")
            expiringBadgeId = newExpiringBadgeId
            showExpiryOnHomeScreen = true
        } else if let oldExpiringBadgeId = expiringBadgeId {
            if SubscriptionBadgeIds.contains(oldExpiringBadgeId), !newSubscriberBadgeIds.isEmpty {
                Logger.info("Clearing expired subscription badge id \(oldExpiringBadgeId), new subscription badge found.")
                expiringBadgeId = nil
                showExpiryOnHomeScreen = false
            } else if BoostBadgeIds.contains(oldExpiringBadgeId), !newBoostBadgeIds.isEmpty {
                Logger.info("Clearing expired boost badge id \(oldExpiringBadgeId), new boost badge found.")
                expiringBadgeId = nil
                showExpiryOnHomeScreen = false
            }
        }

        if userManuallyCancelled, !newSubscriberBadgeIds.isEmpty {
            Logger.info("Clearing manual subscription cancellation, new subscription badge found.")
            userManuallyCancelled = false
        }

        let newExpiredGiftBadgeID: String?
        if currentGiftBadgeIDs.isEmpty {
            // If you don't have any remaining gift badges, show (a) the badge that
            // *just* expired, (b) a gift that expired during a previous call to
            // reconcile badge states, or (c) nothing. Most users will fall into (c).
            newExpiredGiftBadgeID = expiredGiftBadgeIds.first ?? oldExpiredGiftBadgeID ?? nil
        } else {
            // If you have a gift badge, don't show any expiration about gift badges.
            // Perhaps you redeemed another gift before we displayed the sheet.
            newExpiredGiftBadgeID = nil
        }

        Logger.info("""
        Reconciled badge state:
            Subscriber Badge Ids: \(currentSubscriberBadgeIDs)
            Boost Badge Ids: \(currentBoostBadgeIDs)
            Gift Badge Ids: \(currentGiftBadgeIDs)
            Most Recently Expired Badge Id: \(expiringBadgeId ?? "nil")
            Expired Gift Badge Id: \(newExpiredGiftBadgeID ?? "nil")
            Show Expiry On Home Screen: \(showExpiryOnHomeScreen)
            User Manually Cancelled Subscription: \(userManuallyCancelled)
            Display Badges On Profile: \(displayBadgesOnProfile)
        """)

        // Persist new values
        Self.setKnownUserSubscriptionBadgeIDs(badgeIDs: currentSubscriberBadgeIDs, transaction: transaction)
        Self.setKnownUserBoostBadgeIDs(badgeIDs: currentBoostBadgeIDs, transaction: transaction)
        Self.setKnownUserGiftBadgeIDs(badgeIDs: currentGiftBadgeIDs, transaction: transaction)
        Self.setMostRecentlyExpiredGiftBadgeID(badgeID: newExpiredGiftBadgeID, transaction: transaction)
        Self.setMostRecentlyExpiredBadgeID(badgeID: expiringBadgeId, transaction: transaction)
        Self.setShowExpirySheetOnHomeScreenKey(show: showExpiryOnHomeScreen, transaction: transaction)
        Self.setUserManuallyCancelledSubscription(userManuallyCancelled, transaction: transaction)
        Self.setDisplayBadgesOnProfile(displayBadgesOnProfile, transaction: transaction)
    }

    public func hasCurrentSubscription(transaction: SDSAnyReadTransaction) -> Bool {
        guard !Self.currentProfileSubscriptionBadges().isEmpty else { return false }

        guard Self.getSubscriberID(transaction: transaction) != nil else { return false }

        guard let lastSubscriptionExpiryDate = Self.lastSubscriptionExpirationDate(transaction: transaction) else {
            return false
        }

        return lastSubscriptionExpiryDate.isAfterNow
    }

    public func timeSinceLastSubscriptionExpiration(transaction: SDSAnyReadTransaction) -> TimeInterval {
        guard let lastSubscriptionExpiryDate = Self.lastSubscriptionExpirationDate(transaction: transaction) else {
            return -Date.distantPast.timeIntervalSinceNow
        }

        guard lastSubscriptionExpiryDate.isBeforeNow else {
            return 0
        }

        return -lastSubscriptionExpiryDate.timeIntervalSinceNow
    }

    public func userManuallyCancelledSubscription(transaction: SDSAnyReadTransaction) -> Bool {
        return Self.userManuallyCancelledSubscription(transaction: transaction)
    }

    public func setUserManuallyCancelledSubscription(_ userCancelled: Bool, updateStorageService: Bool, transaction: SDSAnyWriteTransaction) {
        Self.setUserManuallyCancelledSubscription(userCancelled, updateStorageService: updateStorageService, transaction: transaction)
    }

    public var displayBadgesOnProfile: Bool { Self.displayBadgesOnProfileCache.get() }

    public func displayBadgesOnProfile(transaction: SDSAnyReadTransaction) -> Bool {
        return Self.displayBadgesOnProfile(transaction: transaction)
    }

    public func setDisplayBadgesOnProfile(_ displayBadgesOnProfile: Bool, updateStorageService: Bool, transaction: SDSAnyWriteTransaction) {
        Self.setDisplayBadgesOnProfile(displayBadgesOnProfile, updateStorageService: updateStorageService, transaction: transaction)
    }
}

extension SubscriptionManagerImpl {

    public enum RecurringSubscriptionPaymentType {
        case applePay(paymentMethodId: String)
        case creditOrDebitCard(paymentMethodId: String)
        case paypal(paymentMethodId: String)
        case sepa(paymentMethodId: String)
        case ideal(setupIntentId: String)

        public var paymentProcessor: DonationPaymentProcessor {
            switch self {
            case .applePay, .ideal, .sepa, .creditOrDebitCard:
                return .stripe
            case .paypal:
                return .braintree
            }
        }

        public var paymentMethod: DonationPaymentMethod {
            switch self {
            case .applePay: return .applePay
            case .creditOrDebitCard: return .creditOrDebitCard
            case .paypal: return .paypal
            case .sepa: return .sepa
            case .ideal: return .ideal
            }
        }
    }
}
