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

import Foundation
import PushKit
import SignalCoreKit
import SignalServiceKit

public enum PushRegistrationError: Error {
    case assertionError(description: String)
    case pushNotSupported(description: String)
    case timeout
}

/**
 * Singleton used to integrate with push notification services - registration and routing received remote notifications.
 */
public class PushRegistrationManager: NSObject, PKPushRegistryDelegate {

    override init() {
        (preauthChallengeGuarantee, preauthChallengeFuture) = Guarantee<String>.pending()

        super.init()

        SwiftSingletons.register(self)
    }

    // Coordinates blocking of the calloutQueue while we wait for an incoming call
    private let incomingCallFuture = AtomicValue<GuaranteeFuture<Void>?>(nil, lock: .init())

    // Private callout queue that we can use to synchronously wait for our call to start
    // TODO: Rewrite call message routing to be able to synchronously report calls
    private static let calloutQueue = DispatchQueue(
        label: "org.signal.push-registration",
        autoreleaseFrequency: .workItem
    )
    private var calloutQueue: DispatchQueue { Self.calloutQueue }

    private var vanillaTokenPromise: Promise<Data>?
    private var vanillaTokenFuture: Future<Data>?

    private var voipRegistry: PKPushRegistry?
    private var voipTokenPromise: Promise<Data?>?
    private var voipTokenFuture: Future<Data?>?

    private var preauthChallengeGuarantee: Guarantee<String>
    private var preauthChallengeFuture: GuaranteeFuture<String>

    // MARK: Public interface

    public func needsNotificationAuthorization() -> Guarantee<Bool> {
        return Guarantee<Bool> { resolve in
            UNUserNotificationCenter.current().getNotificationSettings { settings in
                resolve(settings.authorizationStatus == .notDetermined)
            }
        }
    }

    public typealias ApnRegistrationId = RegistrationRequestFactory.ApnRegistrationId

    /// - parameter timeOutEventually: If the OS fails to get back to us with the apns token after
    /// we have requested it and significant time has passed, do we time out or keep waiting? Default to keep waiting.
    public func requestPushTokens(
        forceRotation: Bool,
        timeOutEventually: Bool = false
    ) -> Promise<ApnRegistrationId> {
        Logger.info("")
        return Promise.wrapAsync {
            await self.registerUserNotificationSettings()
        }.then { (_) -> Promise<ApnRegistrationId> in
            guard !Platform.isSimulator else {
                throw PushRegistrationError.pushNotSupported(description: "Push not supported on simulators")
            }

            return self
                .registerForVanillaPushToken(
                    forceRotation: forceRotation,
                    timeOutEventually: timeOutEventually
                ).then { vanillaPushToken -> Promise<ApnRegistrationId> in
                    self.registerForVoipPushToken().map { voipPushToken in
                        return ApnRegistrationId(apnsToken: vanillaPushToken, voipToken: voipPushToken)
                    }
                }
        }
    }

    public func didFinishReportingIncomingCall() {
        incomingCallFuture.swap(nil)?.resolve()
    }

    // MARK: Vanilla push token

    /// Receives a pre-auth challenge token.
    ///
    /// Notably, this method is not responsible for requesting these tokens—that must be
    /// managed elsewhere. Before you request one, you should call this method.
    public func receivePreAuthChallengeToken() -> Guarantee<String> { preauthChallengeGuarantee }

    /// Clears any existing pre-auth challenge token. If none exists, this method does nothing.
    public func clearPreAuthChallengeToken() {
        if preauthChallengeGuarantee.isSealed {
            (preauthChallengeGuarantee, preauthChallengeFuture) = Guarantee<String>.pending()
        }
    }

    @objc
    public func didReceiveVanillaPreAuthChallengeToken(_ challenge: String) {
        AppReadiness.runNowOrWhenAppDidBecomeReadySync {
            AssertIsOnMainThread()
            Logger.info("received vanilla preauth challenge")
            self.preauthChallengeFuture.resolve(challenge)
        }
    }

    // Vanilla push token is obtained from the system via AppDelegate
    @objc
    public func didReceiveVanillaPushToken(_ tokenData: Data) {
        guard let vanillaTokenFuture = self.vanillaTokenFuture else {
            Logger.warn("System volunteered a push token even though we didn't request one. Syncing.")
            Task {
                do {
                    try await SyncPushTokensJob(mode: .normal).run()
                    Logger.info("Done syncing push tokens after system volunteered one.")
                } catch {
                    Logger.error("Failed to sync push tokens after system volunteered one.")
                }
            }
            return
        }

        vanillaTokenFuture.resolve(tokenData)
    }

    // Vanilla push token is obtained from the system via AppDelegate    
    @objc
    public func didFailToReceiveVanillaPushToken(error: Error) {
        guard let vanillaTokenFuture = self.vanillaTokenFuture else {
            owsFailDebug("promise completion in \(#function) unexpectedly nil")
            return
        }

        vanillaTokenFuture.reject(error)
    }

    // MARK: PKPushRegistryDelegate - voIP Push Token

    public func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType) {
        assertOnQueue(calloutQueue)
        owsAssertDebug(type == .voIP)

        // Synchronously wait until the app is ready.
        let appReady = DispatchSemaphore(value: 0)
        AppReadiness.runNowOrWhenAppDidBecomeReadySync {
            appReady.signal()
        }
        appReady.wait()

        // This branch MUST start a CallKit call before it returns or else we risk
        // a PushKit penalty that may prevent us from handling future calls.
        let callRelayPayload = CallMessagePushPayload(payload.dictionaryPayload)
        if let callRelayPayload {
            Logger.info("Received VoIP push from the NSE: \(callRelayPayload)")
            let (guarantee, future) = Guarantee<Void>.pending()
            incomingCallFuture.set(future)
            AppEnvironment.shared.callService.earlyRingNextIncomingCall.set(true)
            CallMessageRelay.handleVoipPayload(callRelayPayload)
            Logger.info("Waiting for call to start: \(callRelayPayload)")
            guarantee.timeout(
                on: DispatchQueue.global(qos: .userInitiated),
                seconds: 5,
                substituteValue: ()
            ).wait()
            Logger.info("Returning back to PushKit. Good luck! \(callRelayPayload)")
            return
        }

        if let challenge = payload.dictionaryPayload["challenge"] as? String {
            Logger.info("received preauth challenge")
            DispatchQueue.main.sync {
                self.preauthChallengeFuture.resolve(challenge)
            }
            return
        }

        Logger.info("Fetching messages.")
        var backgroundTask: OWSBackgroundTask? = OWSBackgroundTask(label: "Push fetch.")
        firstly { () -> Promise<Void> in
            self.messageFetcherJob.run().promise
        }.done(on: DispatchQueue.main) {
            owsAssertDebug(backgroundTask != nil)
            backgroundTask = nil
        }.catch { error in
            owsFailDebug("Error: \(error)")
        }

        if FeatureFlags.notificationServiceExtension {
            Self.handleUnexpectedVoipPush()
        }
    }

    private static func handleUnexpectedVoipPush() {
        assertOnQueue(calloutQueue)

        Logger.info("")

        guard #available(iOS 15, *) else {
            owsFailDebug("Voip push is expected.")
            return
        }

        // If the main app receives an unexpected VOIP push on iOS 15,
        // we need to:
        //
        // * Post a generic incoming message notification.
        // * Try to sync push tokens.
        // * Block on completion of both activities for reasons?
        let completionSignal = DispatchSemaphore(value: 0)
        Task {
            defer {
                completionSignal.signal()
            }
            await notificationPresenterImpl.postGenericIncomingMessageNotification()
            do {
                try await SyncPushTokensJob(mode: .forceUpload).run()
            } catch {
                owsFailDebugUnlessNetworkFailure(error)
            }
        }
        let waitInterval = DispatchTimeInterval.seconds(20)
        let didTimeout = (completionSignal.wait(timeout: .now() + waitInterval) == .timedOut)
        if didTimeout {
            owsFailDebug("Timed out.")
        } else {
            Logger.info("Complete.")
            Logger.flush()
        }
    }

    public func pushRegistry(_ registry: PKPushRegistry, didUpdate credentials: PKPushCredentials, for type: PKPushType) {
        assertOnQueue(calloutQueue)
        Logger.info("")
        owsAssertDebug(type == .voIP)
        owsAssertDebug(credentials.type == .voIP)
        guard let voipTokenFuture = self.voipTokenFuture else { return }

        voipTokenFuture.resolve(credentials.token)
    }

    public func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
        // It's not clear when this would happen. We've never previously handled it, but we should at
        // least start learning if it happens.
        assertOnQueue(calloutQueue)
        owsFailDebug("Invalid state")
    }

    // MARK: helpers

    // User notification settings must be registered *before* AppDelegate will
    // return any requested push tokens.
    public func registerUserNotificationSettings() async {
        Logger.info("registering user notification settings")

        await notificationPresenterImpl.registerNotificationSettings()
    }

    /**
     * When users have disabled notifications and background fetch, the system hangs when returning a push token.
     * More specifically, after registering for remote notification, the app delegate calls neither
     * `didFailToRegisterForRemoteNotificationsWithError` nor `didRegisterForRemoteNotificationsWithDeviceToken`
     * This behavior is identical to what you'd see if we hadn't previously registered for user notification settings, though
     * in this case we've verified that we *have* properly registered notification settings.
     */
    @MainActor
    private func isSusceptibleToFailedPushRegistration() async -> Bool {

        // Only affects users who have disabled both: background refresh *and* notifications
        guard UIApplication.shared.backgroundRefreshStatus == .denied else {
            Logger.info("has backgroundRefreshStatus != .denied, not susceptible to push registration failure")
            return false
        }

        let notificationSettings = await UNUserNotificationCenter.current().notificationSettings()

        // This was ported from UIApplication.shared.currentUserNotificationSettings.types == [] so it only looks at these three settings.
        guard notificationSettings.alertSetting != .enabled && notificationSettings.badgeSetting != .enabled && notificationSettings.soundSetting != .enabled else {
            Logger.info("notificationSettings was not empty, not susceptible to push registration failure.")
            return false
        }

        Logger.info("background refresh and notifications were disabled. Device is susceptible to push registration failure.")
        return true
    }

    private func registerForVanillaPushToken(
        forceRotation: Bool,
        timeOutEventually: Bool
    ) -> Promise<String> {
        AssertIsOnMainThread()
        Logger.info("")

        guard self.vanillaTokenPromise == nil else {
            let promise = vanillaTokenPromise!
            owsAssertDebug(!promise.isSealed)
            Logger.info("already pending promise for vanilla push token")
            return promise.map { $0.hexEncodedString }
        }

        // No pending vanilla token yet. Create a new promise
        let (promise, future) = Promise<Data>.pending()
        self.vanillaTokenPromise = promise
        self.vanillaTokenFuture = future

        if forceRotation {
            UIApplication.shared.unregisterForRemoteNotifications()
        }
        UIApplication.shared.registerForRemoteNotifications()

        let returnedPromise = firstly {
            promise.timeout(seconds: 10, description: "Register for vanilla push token") {
                PushRegistrationError.timeout
            }
        }.recover { error -> Promise<Data> in
            switch error {
            case PushRegistrationError.timeout:
                Promise.wrapAsync {
                    await self.isSusceptibleToFailedPushRegistration()
                }.then { isSusceptibleToFailedPushRegistration in
                    if isSusceptibleToFailedPushRegistration {
                        // If we've timed out on a device known to be susceptible to failures, quit trying
                        // so the user doesn't remain indefinitely hung for no good reason.
                        throw PushRegistrationError.pushNotSupported(description: "Device configuration disallows push notifications")
                    } else {
                        Logger.info("Push registration is taking a while. Continuing to wait since this configuration is not known to fail push registration.")
                        // Sometimes registration can just take a while.
                        // If we're not on a device known to be susceptible to push registration failure,
                        // just return the original promise.
                        return promise
                    }
                }
            default:
                throw error
            }
        }.then { (pushTokenData: Data) -> Promise<String> in
            Promise.wrapAsync {
                await self.isSusceptibleToFailedPushRegistration()
            }.map { isSusceptibleToFailedPushRegistration in
                if isSusceptibleToFailedPushRegistration {
                    // Sentinel in case this bug is fixed.
                    owsFailDebug("Device was unexpectedly able to complete push registration even though it was susceptible to failure.")
                }

                Logger.info("successfully registered for vanilla push notifications")
                return pushTokenData.hexEncodedString
            }
        }.ensure {
            self.vanillaTokenPromise = nil
        }
        guard timeOutEventually else {
            return returnedPromise
        }
        return returnedPromise.timeout(seconds: 20, timeoutErrorBlock: { return PushRegistrationError.timeout })
    }

    private func createVoipRegistryIfNecessary() {
        AssertIsOnMainThread()

        guard voipRegistry == nil else { return }
        let voipRegistry = PKPushRegistry(queue: calloutQueue)
        self.voipRegistry  = voipRegistry
        voipRegistry.desiredPushTypes = [.voIP]
        voipRegistry.delegate = self
    }

    private func registerForVoipPushToken() -> Promise<String?> {
        AssertIsOnMainThread()

        // We never populate voip tokens with the service when
        // using the notification service extension.
        guard !FeatureFlags.notificationServiceExtension else {
            // We still must create the voip registry to handle voip
            // pushes relayed from the NSE.
            createVoipRegistryIfNecessary()
            return Promise.value(nil)
        }

        guard self.voipTokenPromise == nil else {
            let promise = self.voipTokenPromise!
            owsAssertDebug(!promise.isSealed)
            return promise.map { $0?.hexEncodedString }
        }

        // No pending voip token yet. Create a new promise
        let (promise, future) = Promise<Data?>.pending()
        self.voipTokenPromise = promise
        self.voipTokenFuture = future

        // We don't create the voip registry in init, because it immediately requests the voip token,
        // potentially before we're ready to handle it.
        createVoipRegistryIfNecessary()

        guard let voipRegistry = self.voipRegistry else {
            owsFailDebug("failed to initialize voipRegistry")
            future.reject(PushRegistrationError.assertionError(description: "failed to initialize voipRegistry"))
            return promise.map { _ in
                // coerce expected type of returned promise - we don't really care about the value,
                // since this promise has been rejected. In practice this shouldn't happen
                String()
            }
        }

        // If we've already completed registering for a voip token, resolve it immediately,
        // rather than waiting for the delegate method to be called.
        if let voipTokenData = voipRegistry.pushToken(for: .voIP) {
            Logger.info("using pre-registered voIP token")
            future.resolve(voipTokenData)
        }

        return promise.map { (voipTokenData: Data?) -> String? in
            Logger.info("successfully registered for voip push notifications")
            return voipTokenData?.hexEncodedString
        }.ensure {
            self.voipTokenPromise = nil
        }
    }
}

// We transmit pushToken data as hex encoded string to the server
fileprivate extension Data {
    var hexEncodedString: String {
        return map { String(format: "%02hhx", $0) }.joined()
    }
}
