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

/// This object serves as a store of data for interop between the NSE and SyncPushTokensJob.
/// It lets the NSE record when it handles messages, so that if it hasn't done so in a while,
/// SyncPushTokensJob can rotate the APNS token to try and recover.
public final class APNSRotationStore: NSObject {

    private static let kvStore = SDSKeyValueStore(collection: "APNSRotationStore")

    // exposed for testing. we need a better way to do this.
    internal static var nowMs: () -> UInt64 = { Date().ows_millisecondsSince1970 }

    @objc
    public static func didReceiveAPNSPush(transaction: SDSAnyWriteTransaction) {
        // See comments on `setAppVersionTimeForAPNSRotationIfNeeded`.
        // If we actually get an APNS push, even before the app version bake time
        // has passed, we have all the state we need and don't need the app version
        // check anymore. Bypass it by writing a timestamp far enough in the past that
        // we think the bake time has passed already.
        kvStore.setUInt64(
            nowMs() - Constants.appVersionBakeTimeMs - 1,
            key: Constants.apnsRotationAppVersionUpdateTimestampKey,
            transaction: transaction
        )
        // Mark the current token as one we know works!
        guard let token = preferences.getPushToken(tx: transaction) else {
            owsFailDebug("Got a push without a push token; not marking any token as working.")
            return
        }
        kvStore.setString(
            token,
            key: Constants.lastKnownWorkingAPNSTokenKey,
            transaction: transaction
        )
        kvStore.setUInt64(
            nowMs(),
            key: Constants.lastKnownWorkingAPNSTokenTimestampKey,
            transaction: transaction
        )
    }

    public static func didRotateAPNSToken(transaction: SDSAnyWriteTransaction) {
        kvStore.setUInt64(
            nowMs(),
            key: Constants.lastAPNSRotationTimestampKey,
            transaction: transaction
        )
        kvStore.removeValue(
            forKey: Constants.lastKnownWorkingAPNSTokenKey,
            transaction: transaction
        )
        kvStore.removeValue(
            forKey: Constants.lastKnownWorkingAPNSTokenTimestampKey,
            transaction: transaction
        )
    }

    /// Call this on app startup once launched, registered, and ready.
    /// Takes a closure that rotates the APNS token. If the token should be rotated, calls this closure.
    /// Returns an optional closure, which if present should be run once the message queue is flushed and
    /// all pending messages are done processing.
    /// Note the passed in closure may be called after the returned closure is called.
    public static func rotateIfNeededOnAppLaunchAndReadiness(performRotation: @escaping () -> Void) -> (() -> Void)? {
        // Structuring as efficiently as possible: in a single read we check if we
        // are eligible to rotate, if not whether we need to open an expensive write transaction
        // to write the app version to (which we should only ever do once),
        // if we need to write the "known good" token timestamp (only need to do this once
        // to catch app versions from after we started rotating but before we wrote timestamps),
        // and if we are eligible to rotate fetch the latest message for later comparison.
        // Presence of a latestMessageTimestamp implies we should attempt a rotation after message processing.
        let (needsAppVersionWrite, needsKnownWorkingWrite, latestMessageTimestamp) =
            databaseStorage.read { transaction -> (Bool, Bool, UInt64?) in
                let needsKnownWorkingWrite = APNSRotationStore.kvStore.hasValue(
                    forKey: Constants.lastKnownWorkingAPNSTokenKey,
                    transaction: transaction
                ) && !APNSRotationStore.kvStore.hasValue(
                    forKey: Constants.lastKnownWorkingAPNSTokenTimestampKey,
                    transaction: transaction
                )
                if APNSRotationStore.needsAppVersionWrite(transaction: transaction) {
                    // We need to do a write to set the app version check.
                    // No need to actually check if we need a rotation, we definitely don't
                    // if we haven't written the app version time.
                    return (true, needsKnownWorkingWrite, nil)
                }

                let canRotate = APNSRotationStore.canRotateAPNSToken(transaction: transaction)
                if canRotate {
                    return (false, needsKnownWorkingWrite, InteractionFinder.lastInsertedIncomingMessage(transaction: transaction)?.timestamp)
                } else {
                    return (false, needsKnownWorkingWrite, nil)
                }
            }

        if let latestMessageTimestampBeforeProcessing = latestMessageTimestamp {
            // We are eligible to rotate the APNS token. Wait for fetching and processing to finish,
            // and if the latest message changed that means we had new messages to process
            // and therefore missed messages when the app wasn't active.
            return {
                let latestMessageTimestamp = Self.databaseStorage.read { transaction -> UInt64? in
                    return InteractionFinder.lastInsertedIncomingMessage(transaction: transaction)?.timestamp
                }
                if let latestMessageTimestamp, latestMessageTimestamp != latestMessageTimestampBeforeProcessing {
                    // Rotate.
                    Logger.info("New messages seen on app startup, rotating APNS token.")
                    performRotation()
                    return
                }
            }
        } else if needsAppVersionWrite || needsKnownWorkingWrite {
            databaseStorage.asyncWrite { transaction in
                if needsAppVersionWrite {
                    APNSRotationStore.setAppVersionTimeForAPNSRotationIfNeeded(transaction: transaction)
                }
                if needsKnownWorkingWrite {
                    APNSRotationStore.kvStore.setUInt64(
                        APNSRotationStore.nowMs(),
                        key: Constants.lastKnownWorkingAPNSTokenTimestampKey,
                        transaction: transaction
                    )
                }
            }
            return nil
        } else {
            return nil
        }
    }

    public static func canRotateAPNSToken(transaction: SDSAnyReadTransaction) -> Bool {
        guard let currentToken = preferences.getPushToken(tx: transaction) else {
            // No need to rotate if we don't even have a token yet.
            Logger.info("No push token available, not rotating.")
            return false
        }
        guard RemoteConfig.enableAutoAPNSRotation else {
            Logger.info("Not enabled remotely, not rotating token.")
            return false
        }
        guard isClientEligibleForAPNSTokenRotation(transaction: transaction) else {
            // We gotta give this client time to sit on the app release that added
            // this check before we attempt a rotation.
            Logger.info("Letting client update bake before rotating push token.")
            return false
        }
        guard hasLockoutPeriodElapsed(transaction: transaction) else {
            // We rotated too recently!
            Logger.info("Last push token rotation too recent; not rotating again.")
            return false
        }
        let knownGoodToken = self.kvStore.getString(
            Constants.lastKnownWorkingAPNSTokenKey,
            transaction: transaction
        )
        let now = nowMs()
        // Default to now; the initial release of this code didn't track
        // this date, so it may be nil even if the known-good token is not.
        let knownGoodTokenTimestamp = self.kvStore.getUInt64(
            Constants.lastKnownWorkingAPNSTokenTimestampKey,
            transaction: transaction
        ) ?? now
        let isUsingKnownGoodToken = currentToken == knownGoodToken
        if isUsingKnownGoodToken {
            if
                now > knownGoodTokenTimestamp,
                now - knownGoodTokenTimestamp > Constants.lastKnownWorkingAPNSTokenExpirationTimeMs
            {
                // Too long ago, eligible to rotate.
                Logger.warn("APNS token was known-good long ago, rotating.")
                return true
            } else {
                // Our current token is a known working one, don't rotate.
                Logger.info("Has known-good APNS token, skipping rotation.")
                return false
            }
        }
        return true
    }

    /// See comments on `setAppVersionTimeForAPNSRotationIfNeeded`.
    /// This is a read transaction (faster) way to check if we _need_ to write anything before
    /// committing to a write transaction that blocks on a serial queue.
    private static func needsAppVersionWrite(transaction: SDSAnyReadTransaction) -> Bool {
        return kvStore.getUInt64(
            Constants.apnsRotationAppVersionUpdateTimestampKey,
            transaction: transaction
        ) == nil
    }

    /// Consumers of this class should call this when they call `shouldRotateAPNSToken`
    /// (before or after, doesn't matter).
    /// This ensures that after an app version update, we give enough baking time to write state
    /// before attempting a rotation.
    static func setAppVersionTimeForAPNSRotationIfNeeded(transaction: SDSAnyWriteTransaction) {
        // Consider this scnario: the user updates their app to the first version which includes
        // this rotation checking code. We check and see that we haven't marked down any incoming
        // APNS pushes in `lastAPNSPushLocalTimestampKey` (we weren't storing that before this code was added!)
        // but we do have missed messages, so the code thinks we should rotate!
        //
        // To avoid this, we give the version update time to bake, marking down when we first
        // attempted a rotation (i.e. the first time we ran an app version with this code present)
        // and not rotating until enough time has passed (or we get an APNS push).
        guard needsAppVersionWrite(transaction: transaction) else {
            return
        }
        kvStore.setUInt64(
            nowMs(),
            key: Constants.apnsRotationAppVersionUpdateTimestampKey,
            transaction: transaction
        )
    }

    private static func isClientEligibleForAPNSTokenRotation(transaction: SDSAnyReadTransaction) -> Bool {
        // Clients will update to a version that runs this code for the first time.
        // There might not be any APNS pushes since updating, but that doesn't mean it didn't
        // run for previous incoming messages; we just weren't storing anything
        // at the time, prior to the update.
        // Make sure its been some time since we first started checking to avoid this.
        let nowMs = self.nowMs()
        guard
            let clientUpdateTime = kvStore.getUInt64(
                Constants.apnsRotationAppVersionUpdateTimestampKey,
                transaction: transaction
            ),
            // Protect against negative UInt64 values if the clock changes back in time.
            nowMs > clientUpdateTime,
            nowMs - clientUpdateTime >= Constants.appVersionBakeTimeMs
        else {
            return false
        }
        return true
    }

    private static func hasLockoutPeriodElapsed(transaction: SDSAnyReadTransaction) -> Bool {
        guard let lastRotationTime = kvStore.getUInt64(
            Constants.lastAPNSRotationTimestampKey,
            transaction: transaction
        ) else {
            // We haven't rotated before
            return true
        }
        let nowMs = self.nowMs()
        // Protect against negative UInt64 values if the clock changes back in time.
        return nowMs > lastRotationTime && nowMs - lastRotationTime > Constants.minRotationInterval
    }

    internal enum Constants {
        /// When we get an APNS push, we store the current token under this key
        /// since we know it is working.
        fileprivate static let lastKnownWorkingAPNSTokenKey = "lastKnownWorkingAPNSTokenKey"
        /// We also store the date at which the token last worked,
        /// if it was too long ago we might be eligible to rotate.
        fileprivate static let lastKnownWorkingAPNSTokenTimestampKey = "lastKnownWorkingAPNSTokenTimestampKey"
        internal static let lastKnownWorkingAPNSTokenExpirationTimeMs: UInt64 = 60 /*days*/ * kDayInMs

        /// See comments on `setAppVersionTimeForAPNSRotationIfNeeded`.
        /// Time we wait after the app first updates to a version with this code before we issue
        /// a token rotation due to missed messages.
        internal static let appVersionBakeTimeMs: UInt64 = kWeekInMs
        /// See comments on `setAppVersionTimeForAPNSRotationIfNeeded`.
        /// This is the key where we store when we have updated.
        fileprivate static let apnsRotationAppVersionUpdateTimestampKey = "apnsRotationAppVersionUpdateTimestampKey"

        /// Don't ever rotate tokens more often than this.
        internal static let minRotationInterval: UInt64 = kWeekInMs
        fileprivate static let lastAPNSRotationTimestampKey = "lastAPNSRotationTimestampKey"
    }
}
