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

import LibSignalClient
import SignalCoreKit

/// Manages "donation receipt credential" redemption.
///
/// Donation payments are handled differently depending on the payment
/// method. Ultimately, however, all payments are "confirmed" – this means
/// the user has authorized the payment. Once that happens, we end up with a
/// "payment intent ID" as well as a "receipt credential request/context".
///
/// At this point, we're in a zero-knowledge world – neither the payment
/// intent ID nor the receipt credential request are associated with our
/// account.
///
/// We take the payment intent ID and receipt credential request, and send
/// them (unauthenticated) to Signal servers. If the payment in question has
/// been "processed" (per the relevant payment processor, such as Stripe),
/// the server returns us a value that we can combine with our receipt
/// credential request context to create a zero-knowledge "receipt
/// credential".
///
/// Note that if the payment has not processed successfully, we instead
/// receive an error which can tell us the status of the payment and how to
/// proceed. For example, the payment may have failed to process, or may
/// still be pending but not have affirmatively failed – we want to respond
/// differently to those scenarios.
///
/// *Finally*, we make an authenticated request to send a presentation for
/// the ZK receipt credential to the service – thereby proving that we have
/// made a donation – which assigns a badge to our account.
///
/// - Note
/// Some payment types (such as credit cards) usually process immediately,
/// but others (such as SEPA debit transfers) can take days/weeks to
/// process. During that time, receipt credential request redemption will
/// fail.
public class ReceiptCredentialRedemptionJobQueue {
    private let jobQueueRunner: JobQueueRunner<
        JobRecordFinderImpl<ReceiptCredentialRedemptionJobRecord>,
        ReceiptCredentialRedemptionJobRunnerFactory
    >
    private let jobRunnerFactory: ReceiptCredentialRedemptionJobRunnerFactory

    public init(db: DB, reachabilityManager: SSKReachabilityManager) {
        self.jobRunnerFactory = ReceiptCredentialRedemptionJobRunnerFactory()
        self.jobQueueRunner = JobQueueRunner(
            canExecuteJobsConcurrently: true,
            db: db,
            jobFinder: JobRecordFinderImpl(db: db),
            jobRunnerFactory: self.jobRunnerFactory
        )
        self.jobQueueRunner.listenForReachabilityChanges(reachabilityManager: reachabilityManager)
    }

    func start(appContext: AppContext) {
        guard appContext.isMainApp else { return }
        jobQueueRunner.start(shouldRestartExistingJobs: true)
    }

    public func addBoostJob(
        amount: FiatMoney,
        paymentProcessor: DonationPaymentProcessor,
        paymentMethod: DonationPaymentMethod,
        receiptCredentialRequestContext: Data,
        receiptCredentialRequest: Data,
        boostPaymentIntentID: String,
        future: Future<Void>,
        transaction: SDSAnyWriteTransaction
    ) {
        Logger.info("[Donations] Adding a boost job")

        let jobRecord = ReceiptCredentialRedemptionJobRecord(
            paymentProcessor: paymentProcessor.rawValue,
            paymentMethod: paymentMethod.rawValue,
            receiptCredentialRequestContext: receiptCredentialRequestContext,
            receiptCredentialRequest: receiptCredentialRequest,
            subscriberID: Data(), // Unused
            targetSubscriptionLevel: 0, // Unused
            priorSubscriptionLevel: 0, // Unused
            isNewSubscription: true, // Unused
            shouldSuppressPaymentAlreadyRedeemed: false, // Unused
            isBoost: true,
            amount: amount.value,
            currencyCode: amount.currencyCode,
            boostPaymentIntentID: boostPaymentIntentID
        )
        add(jobRecord: jobRecord, future: future, tx: transaction)
    }

    /// Add a new redemption job for a recurring payment.
    ///
    /// - Parameter paymentMethod
    /// The payment method for this subscription. In practice, should not be
    /// `nil`! However, we fetch this from the service, which cannot guarantee a
    /// recognized value (as it is in turn fetched from an external service,
    /// such as Stripe).
    ///
    /// - Parameter isNewSubscription
    /// `true` if this job represents a new or updated subscription. `false` if
    /// this job is associated with the renewal of an existing subscription.
    ///
    /// - Parameter shouldSuppressPaymentAlreadyRedeemed
    /// Whether this job should suppress "payment already redeemed" errors.
    public func addSubscriptionJob(
        paymentProcessor: DonationPaymentProcessor,
        paymentMethod: DonationPaymentMethod?,
        receiptCredentialRequestContext: Data,
        receiptCredentialRequest: Data,
        subscriberID: Data,
        targetSubscriptionLevel: UInt,
        priorSubscriptionLevel: UInt?,
        isNewSubscription: Bool,
        shouldSuppressPaymentAlreadyRedeemed: Bool,
        future: Future<Void>,
        transaction: SDSAnyWriteTransaction
    ) {
        Logger.info("[Donations] Adding a subscription job")

        let jobRecord = ReceiptCredentialRedemptionJobRecord(
            paymentProcessor: paymentProcessor.rawValue,
            paymentMethod: paymentMethod?.rawValue,
            receiptCredentialRequestContext: receiptCredentialRequestContext,
            receiptCredentialRequest: receiptCredentialRequest,
            subscriberID: subscriberID,
            targetSubscriptionLevel: targetSubscriptionLevel,
            priorSubscriptionLevel: priorSubscriptionLevel ?? 0,
            isNewSubscription: isNewSubscription,
            shouldSuppressPaymentAlreadyRedeemed: shouldSuppressPaymentAlreadyRedeemed,
            isBoost: false,
            amount: nil,
            currencyCode: nil,
            boostPaymentIntentID: String() // Unused
        )
        add(jobRecord: jobRecord, future: future, tx: transaction)
    }

    private func add(jobRecord: ReceiptCredentialRedemptionJobRecord, future: Future<Void>, tx: SDSAnyWriteTransaction) {
        jobRecord.anyInsert(transaction: tx)
        tx.addSyncCompletion {
            self.jobQueueRunner.addPersistedJob(jobRecord, runner: self.jobRunnerFactory.buildRunner(future: future))
        }
    }
}

private class ReceiptCredentialRedemptionJobRunnerFactory: JobRunnerFactory {
    func buildRunner() -> ReceiptCredentialRedemptionJobRunner { buildRunner(future: nil) }

    func buildRunner(future: Future<Void>?) -> ReceiptCredentialRedemptionJobRunner {
        return ReceiptCredentialRedemptionJobRunner(future: future)
    }
}

private class ReceiptCredentialRedemptionJobRunner: JobRunner, Dependencies {
    private let future: Future<Void>?

    init(future: Future<Void>?) {
        self.future = future
    }

    /// Represents the type of payment that resulted in this receipt credential
    /// redemption.
    enum PaymentType: CustomStringConvertible {
        /// A one-time payment, or "boost".
        case oneTimeBoost(paymentIntentId: String, amount: FiatMoney)

        /// A recurring payment, or (an overloaded term) "subscription".
        case recurringSubscription(
            subscriberId: Data,
            targetSubscriptionLevel: UInt,
            priorSubscriptionLevel: UInt,
            isNewSubscription: Bool,
            shouldSuppressPaymentAlreadyRedeemed: Bool
        )

        var receiptCredentialResultMode: ReceiptCredentialResultStore.Mode {
            switch self {
            case .oneTimeBoost: return .oneTimeBoost
            case .recurringSubscription(_, _, _, isNewSubscription: true, _): return .recurringSubscriptionInitiation
            case .recurringSubscription(_, _, _, isNewSubscription: false, _): return .recurringSubscriptionRenewal
            }
        }

        var donationReceiptType: DonationReceipt.DonationReceiptType {
            switch self {
            case .oneTimeBoost:
                return .boost
            case let .recurringSubscription(_, targetSubscriptionLevel, _, _, _):
                return .subscription(subscriptionLevel: targetSubscriptionLevel)
            }
        }

        var description: String {
            switch self {
            case .oneTimeBoost: return "one-time"
            case .recurringSubscription(_, _, _, isNewSubscription: true, _): return "recurring-initiation"
            case .recurringSubscription(_, _, _, isNewSubscription: false, _): return "recurring-renewal"
            }
        }
    }

    private var receiptCredentialResultStore: ReceiptCredentialResultStore {
        DependenciesBridge.shared.receiptCredentialResultStore
    }

    // MARK: - Retries

    private enum Constants {
        /// Defines the time between retries for SEPA and recurring iDEAL transactions.
        static let sepaRetryInterval = TSConstants.isUsingProductionService ? kDayInterval : kMinuteInterval
    }

    private enum RetryInterval {
        case exponential
        case sepa
    }

    private func stillProcessingRetryParameters(
        paymentType: PaymentType,
        paymentMethod: DonationPaymentMethod?
    ) -> (RetryInterval, maxRetries: UInt) {
        switch paymentMethod {
        case nil, .applePay, .creditOrDebitCard, .paypal:
            // We'll retry operations for these payment types fairly aggressively, so
            // we need a large retry buffer.
            return (.exponential, 110)
        case .sepa:
            // We'll only retry operations for SEPA 1x/day (including those fronted by
            // iDEAL), so we limit the retry buffer. They should always complete within
            // 14 business days, so this is generous compared to what we should need,
            // but prevents us from having an indefinite job if for whatever reason a
            // payment never processes.
            return (.sepa, 30)
        case .ideal:
            switch paymentType {
            case .oneTimeBoost:
                return (.exponential, 110)
            case .recurringSubscription:
                return (.sepa, 30)
            }
        }
    }

    var transientFailureCount: UInt = 0

    private func triggerExponentialRetry(jobRecord: JobRecord) -> TimeInterval {
        // If this operation just started, but we've tried in the past and have
        // persisted failures on the job record, we'll respect those and start with
        // a longer retry. Otherwise, as this operation continues to retry it'll
        // get a longer retry from its local error count.
        transientFailureCount += 1
        return OWSOperation.retryIntervalForExponentialBackoff(
            failureCount: max(jobRecord.failureCount, transientFailureCount)
        )
    }

    private func timeIntervalSince(_ timestampMs: UInt64) -> TimeInterval {
        return Date().timeIntervalSince(Date(millisecondsSince1970: timestampMs))
    }

    private func sepaRetryDelay(configuration: Configuration) -> TimeInterval? {
        let (retryInterval, _) = stillProcessingRetryParameters(
            paymentType: configuration.paymentType,
            paymentMethod: configuration.paymentMethod
        )
        guard retryInterval == .sepa else { return nil }

        let priorError = databaseStorage.read(block: { tx -> ReceiptCredentialRequestError? in
            return receiptCredentialResultStore.getRequestError(
                errorMode: configuration.paymentType.receiptCredentialResultMode,
                tx: tx.asV2Read
            )
        })
        guard let priorError, priorError.errorCode == .paymentStillProcessing else {
            return nil
        }
        let delay = Constants.sepaRetryInterval - timeIntervalSince(priorError.timestampMs)
        guard delay > 0 else {
            return nil
        }
        owsAssertDebug(priorError.paymentMethod == .sepa || priorError.paymentMethod == .ideal)
        return delay
    }

    // MARK: - Parsing

    private struct Configuration {
        var paymentMethod: DonationPaymentMethod?
        var paymentProcessor: DonationPaymentProcessor
        var paymentType: PaymentType
        var receiptCredentialRequest: ReceiptCredentialRequest
        var receiptCredentialRequestContext: ReceiptCredentialRequestContext
        var receiptCredentialPresentation: ReceiptCredentialPresentation?
    }

    private func parseJobRecord(_ jobRecord: ReceiptCredentialRedemptionJobRecord) throws -> Configuration {
        guard let paymentProcessor = DonationPaymentProcessor(rawValue: jobRecord.paymentProcessor) else {
            throw OWSGenericError("Unexpected payment processor in job record! \(jobRecord.paymentProcessor)")
        }

        let paymentMethod: DonationPaymentMethod? = try jobRecord.paymentMethod.map { paymentMethodString in
            guard let paymentMethod = DonationPaymentMethod(rawValue: paymentMethodString) else {
                throw OWSGenericError("[Donations] Unexpected payment method in job record! \(paymentMethodString)")
            }
            return paymentMethod
        }

        let receiptCredentialRequestContext = try ReceiptCredentialRequestContext(
            contents: [UInt8](jobRecord.receiptCredentialRequestContext)
        )
        let receiptCredentialRequest = try ReceiptCredentialRequest(
            contents: [UInt8](jobRecord.receiptCredentialRequest)
        )
        let receiptCredentialPresentation = try jobRecord.receiptCredentialPresentation.map {
            try ReceiptCredentialPresentation(contents: [UInt8]($0))
        }

        let paymentType: PaymentType
        if jobRecord.isBoost {
            guard
                let value = jobRecord.amount.map({ $0 as Decimal }),
                let currencyCode = jobRecord.currencyCode
            else {
                throw OWSGenericError("[Donations] Boost job record missing amount!")
            }
            paymentType = .oneTimeBoost(
                paymentIntentId: jobRecord.boostPaymentIntentID,
                amount: FiatMoney(currencyCode: currencyCode, value: value)
            )
        } else {
            paymentType = .recurringSubscription(
                subscriberId: jobRecord.subscriberID,
                targetSubscriptionLevel: jobRecord.targetSubscriptionLevel,
                priorSubscriptionLevel: jobRecord.priorSubscriptionLevel,
                isNewSubscription: jobRecord.isNewSubscription,
                shouldSuppressPaymentAlreadyRedeemed: jobRecord.shouldSuppressPaymentAlreadyRedeemed
            )
        }

        return Configuration(
            paymentMethod: paymentMethod,
            paymentProcessor: paymentProcessor,
            paymentType: paymentType,
            receiptCredentialRequest: receiptCredentialRequest,
            receiptCredentialRequestContext: receiptCredentialRequestContext,
            receiptCredentialPresentation: receiptCredentialPresentation
        )
    }

    // MARK: - Running

    func runJobAttempt(_ jobRecord: ReceiptCredentialRedemptionJobRecord) async -> JobAttemptResult {
        do {
            return try await _runJobAttempt(jobRecord)
        } catch {
            if error.isRetryable {
                // In practice, the only retryable errors are network failures.
                owsAssertDebug(error.isNetworkFailureOrTimeout)
                return .retryAfter(triggerExponentialRetry(jobRecord: jobRecord))
            }
            Logger.warn("[Donations] Job encountered unexpected terminal error")
            return await databaseStorage.awaitableWrite { tx in
                jobRecord.anyRemove(transaction: tx)
                return .finished(.failure(error))
            }
        }
    }

    func didFinishJob(_ jobRecordId: JobRecord.RowId, result: JobResult) async {
        switch result.ranSuccessfullyOrError {
        case .success:
            Logger.info("[Donations] Redemption job succeeded")
            ReceiptCredentialRedemptionJob.postNotification(name: ReceiptCredentialRedemptionJob.didSucceedNotification)
            future?.resolve(())
        case .failure(let error):
            ReceiptCredentialRedemptionJob.postNotification(name: ReceiptCredentialRedemptionJob.didFailNotification)
            future?.reject(error)
        }
    }

    private func _runJobAttempt(_ jobRecord: ReceiptCredentialRedemptionJobRecord) async throws -> JobAttemptResult {
        // First, load a bunch of state that *could* fail. If it does, the
        // operation can't ever succeed, so we throw it away.
        let configuration = try parseJobRecord(jobRecord)

        Logger.info("[Donations] Running job for \(configuration.paymentType).")

        // When the app relaunches, we'll try to restart all pending redemption
        // jobs. If one is for SEPA, and if that job hit a "still processing" error
        // in the past 24 hours, don't check again until 24 hours after the error.
        if let retryDelay = sepaRetryDelay(configuration: configuration) {
            return .retryAfter(retryDelay, canRetryEarly: false)
        }

        // In order to properly show the "you have a new badge" UI after this job
        // succeeds, we need to know what badges we had beforehand.
        let badgesSnapshotBeforeJob: ProfileBadgesSnapshot = .current()

        let badge: ProfileBadge
        if let cachedBadge {
            badge = cachedBadge
        } else {
            badge = try await loadBadge(paymentType: configuration.paymentType)
            cachedBadge = badge
        }

        let amount: FiatMoney
        if let cachedAmount {
            amount = cachedAmount
        } else {
            amount = try await loadAmount(paymentType: configuration.paymentType)
            cachedAmount = amount
        }

        let receiptCredentialPresentation: ReceiptCredentialPresentation
        if let persistedReceiptCredentialPresentation = configuration.receiptCredentialPresentation {
            Logger.info("[Donations] Using persisted receipt credential presentation")
            receiptCredentialPresentation = persistedReceiptCredentialPresentation
        } else {
            Logger.info("[Donations] Creating new receipt credential presentation")
            do {
                receiptCredentialPresentation = try await fetchReceiptCredentialPresentation(
                    jobRecord: jobRecord,
                    configuration: configuration,
                    badge: badge,
                    amount: amount
                )
            } catch let error as SubscriptionManagerImpl.KnownReceiptCredentialRequestError {
                let errorCode = error.errorCode
                let chargeFailureCodeIfPaymentFailed = error.chargeFailureCodeIfPaymentFailed
                let paymentMethod = configuration.paymentMethod
                let paymentType = configuration.paymentType

                return await databaseStorage.awaitableWrite { tx in
                    if
                        errorCode == .paymentIntentRedeemed,
                        case .recurringSubscription(_, _, _, _, shouldSuppressPaymentAlreadyRedeemed: true) = paymentType
                    {
                        Logger.warn("[Donations] Suppressing payment-already-redeemed error.")
                        jobRecord.anyRemove(transaction: tx)
                        return .finished(.success(()))
                    }

                    self.persistErrorCode(
                        errorCode: errorCode,
                        chargeFailureCodeIfPaymentFailed: chargeFailureCodeIfPaymentFailed,
                        configuration: configuration,
                        badge: badge,
                        amount: amount,
                        tx: tx.asV2Write
                    )

                    if errorCode == .paymentStillProcessing {
                        let (retryInterval, maxRetries) = self.stillProcessingRetryParameters(
                            paymentType: paymentType,
                            paymentMethod: paymentMethod
                        )
                        if jobRecord.failureCount < maxRetries {
                            Logger.warn("[Donations] Payment still processing; scheduling retry…")
                            jobRecord.addFailure(tx: tx)
                            switch retryInterval {
                            case .exponential:
                                return .retryAfter(self.triggerExponentialRetry(jobRecord: jobRecord))
                            case .sepa:
                                return .retryAfter(Constants.sepaRetryInterval, canRetryEarly: false)
                            }
                        }
                    }

                    Logger.warn("[Donations] Couldn't fetch credential; aborting: \(errorCode)")
                    jobRecord.anyRemove(transaction: tx)
                    return .finished(.failure(error))
                }
            }
        }

        try await SubscriptionManagerImpl.redeemReceiptCredentialPresentation(
            receiptCredentialPresentation: receiptCredentialPresentation
        ).awaitable()

        return await databaseStorage.awaitableWrite { tx in
            self.receiptCredentialResultStore.clearRequestError(
                errorMode: configuration.paymentType.receiptCredentialResultMode,
                tx: tx.asV2Write
            )
            self.receiptCredentialResultStore.setRedemptionSuccess(
                success: ReceiptCredentialRedemptionSuccess(
                    badgesSnapshotBeforeJob: badgesSnapshotBeforeJob,
                    badge: badge,
                    paymentMethod: configuration.paymentMethod
                ),
                successMode: configuration.paymentType.receiptCredentialResultMode,
                tx: tx.asV2Write
            )

            DonationReceipt(
                receiptType: configuration.paymentType.donationReceiptType,
                timestamp: Date(),
                amount: amount
            ).anyInsert(transaction: tx)

            jobRecord.anyRemove(transaction: tx)
            return .finished(.success(()))
        }
    }

    var cachedBadge: ProfileBadge?

    private func loadBadge(paymentType: PaymentType) async throws -> ProfileBadge {
        switch paymentType {
        case .oneTimeBoost:
            return try await SubscriptionManagerImpl.getBoostBadge().awaitable()
        case let .recurringSubscription(_, targetSubscriptionLevel, _, _, _):
            return try await SubscriptionManagerImpl.getSubscriptionBadge(
                subscriptionLevel: targetSubscriptionLevel
            ).awaitable()
        }
    }

    var cachedAmount: FiatMoney?

    private func loadAmount(paymentType: PaymentType) async throws -> FiatMoney {
        switch paymentType {
        case .oneTimeBoost(paymentIntentId: _, amount: let amount):
            return amount
        case let .recurringSubscription(subscriberId, _, _, _, _):
            let subscription = try await SubscriptionManagerImpl.getCurrentSubscriptionStatus(for: subscriberId).awaitable()
            guard let subscription else {
                throw OWSAssertionError("Missing subscription")
            }
            Logger.info("[Donations] Fetched current subscription. \(subscription.debugDescription)")
            return subscription.amount
        }
    }

    private func fetchReceiptCredentialPresentation(
        jobRecord: ReceiptCredentialRedemptionJobRecord,
        configuration: Configuration,
        badge: ProfileBadge,
        amount: FiatMoney
    ) async throws -> ReceiptCredentialPresentation {
        let receiptCredentialPresentation: ReceiptCredentialPresentation
        switch configuration.paymentType {
        case let .oneTimeBoost(paymentIntentId: paymentIntentId, amount: _):
            Logger.info("[Donations] Durable job requesting receipt for boost")
            receiptCredentialPresentation = try await SubscriptionManagerImpl.requestReceiptCredentialPresentation(
                boostPaymentIntentId: paymentIntentId,
                expectedBadgeLevel: .boostBadge,
                paymentProcessor: configuration.paymentProcessor,
                context: configuration.receiptCredentialRequestContext,
                request: configuration.receiptCredentialRequest
            ).awaitable()

        case let .recurringSubscription(subscriberId, targetSubscriptionLevel, priorSubscriptionLevel, _, _):
            Logger.info("[Donations] Durable job requesting receipt for subscription")
            receiptCredentialPresentation = try await SubscriptionManagerImpl.requestReceiptCredentialPresentation(
                subscriberId: subscriberId,
                targetSubscriptionLevel: targetSubscriptionLevel,
                priorSubscriptionLevel: priorSubscriptionLevel,
                context: configuration.receiptCredentialRequestContext,
                request: configuration.receiptCredentialRequest
            ).awaitable()
        }
        await databaseStorage.awaitableWrite { tx in
            jobRecord.setReceiptCredentialPresentation(receiptCredentialPresentation.serialize().asData, tx: tx)
        }
        return receiptCredentialPresentation
    }

    private func persistErrorCode(
        errorCode: ReceiptCredentialRequestError.ErrorCode,
        chargeFailureCodeIfPaymentFailed: String?,
        configuration: Configuration,
        badge: ProfileBadge,
        amount: FiatMoney,
        tx: DBWriteTransaction
    ) {
        let receiptCredentialRequestError: ReceiptCredentialRequestError = {
            if let paymentMethod = configuration.paymentMethod {
                return ReceiptCredentialRequestError(
                    errorCode: errorCode,
                    chargeFailureCodeIfPaymentFailed: chargeFailureCodeIfPaymentFailed,
                    badge: badge,
                    amount: amount,
                    paymentMethod: paymentMethod
                )
            } else {
                Logger.warn("[Donations] Building legacy error, job record missing fields!")
                return ReceiptCredentialRequestError(legacyErrorCode: errorCode)
            }
        }()

        receiptCredentialResultStore.setRequestError(
            error: receiptCredentialRequestError,
            errorMode: configuration.paymentType.receiptCredentialResultMode,
            tx: tx
        )
    }
}

// MARK: - Notifications

public enum ReceiptCredentialRedemptionJob {
    public static let didSucceedNotification = NSNotification.Name("ReceiptCredentialRedemptionJob.DidSucceed")
    public static let didFailNotification = NSNotification.Name("ReceiptCredentialRedemptionJob.DidFail")

    fileprivate static func postNotification(name: NSNotification.Name) {
        NotificationCenter.default.postNotificationNameAsync(name, object: nil, userInfo: nil)
    }
}
