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

import SignalServiceKit
import SignalUI

public class ProvisioningNavigationController: OWSNavigationController {
    private(set) var provisioningController: ProvisioningController

    public init(provisioningController: ProvisioningController) {
        self.provisioningController = provisioningController
        super.init()
    }

    public override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
        let superOrientations = super.supportedInterfaceOrientations
        let provisioningOrientations: UIInterfaceOrientationMask = UIDevice.current.isIPad ? .all : .portrait

        return superOrientations.intersection(provisioningOrientations)
    }
}

public class ProvisioningController: NSObject {

    private let provisioningCipher: ProvisioningCipher
    private let provisioningSocket: ProvisioningSocket

    private var deviceIdPromise: Promise<String>
    private var deviceIdFuture: Future<String>

    private var provisionEnvelopePromise: Promise<ProvisioningProtoProvisionEnvelope>
    private var provisionEnvelopeFuture: Future<ProvisioningProtoProvisionEnvelope>

    private lazy var provisioningCoordinator: ProvisioningCoordinator = {
        return ProvisioningCoordinatorImpl(
            chatConnectionManager: DependenciesBridge.shared.chatConnectionManager,
            db: DependenciesBridge.shared.db,
            identityManager: DependenciesBridge.shared.identityManager,
            messageFactory: ProvisioningCoordinatorImpl.Wrappers.MessageFactory(),
            preKeyManager: DependenciesBridge.shared.preKeyManager,
            profileManager: ProvisioningCoordinatorImpl.Wrappers.ProfileManager(self.profileManagerImpl),
            pushRegistrationManager: ProvisioningCoordinatorImpl.Wrappers.PushRegistrationManager(
                self.pushRegistrationManager
            ),
            receiptManager: ProvisioningCoordinatorImpl.Wrappers.ReceiptManager(OWSReceiptManager.shared),
            registrationStateChangeManager: DependenciesBridge.shared.registrationStateChangeManager,
            signalService: self.signalService,
            storageServiceManager: self.storageServiceManager,
            svr: DependenciesBridge.shared.svr,
            syncManager: ProvisioningCoordinatorImpl.Wrappers.SyncManager(OWSSyncManager.shared),
            threadStore: ThreadStoreImpl(),
            tsAccountManager: DependenciesBridge.shared.tsAccountManager,
            udManager: ProvisioningCoordinatorImpl.Wrappers.UDManager(self.udManager)
        )
    }()

    private override init() {
        provisioningCipher = ProvisioningCipher.generate()

        (self.deviceIdPromise, self.deviceIdFuture) = Promise.pending()
        (self.provisionEnvelopePromise, self.provisionEnvelopeFuture) = Promise.pending()

        provisioningSocket = ProvisioningSocket()

        super.init()

        provisioningSocket.delegate = self
    }

    public func resetPromises() {
        _awaitProvisionMessage = nil
        (self.deviceIdPromise, self.deviceIdFuture) = Promise.pending()
        (self.provisionEnvelopePromise, self.provisionEnvelopeFuture) = Promise.pending()
    }

    public static func presentProvisioningFlow() {
        let provisioningController = ProvisioningController()
        let navController = ProvisioningNavigationController(provisioningController: provisioningController)
        provisioningController.setUpDebugLogsGesture(on: navController)

        let vc = ProvisioningSplashViewController(provisioningController: provisioningController)
        navController.setViewControllers([vc], animated: false)

        CurrentAppContext().mainWindow?.rootViewController = navController
    }

    public static func presentRelinkingFlow() {
        let provisioningController = ProvisioningController()
        let navController = ProvisioningNavigationController(provisioningController: provisioningController)
        provisioningController.setUpDebugLogsGesture(on: navController)

        let vc = ProvisioningQRCodeViewController(provisioningController: provisioningController)
        navController.setViewControllers([vc], animated: false)

        provisioningController.awaitProvisioning(from: vc, navigationController: navController)
        CurrentAppContext().mainWindow?.rootViewController = navController
    }

    private func setUpDebugLogsGesture(
        on navigationController: UINavigationController
    ) {
        let submitLogsGesture = UITapGestureRecognizer(target: self, action: #selector(submitLogs))
        submitLogsGesture.numberOfTapsRequired = 8
        submitLogsGesture.delaysTouchesEnded = false
        navigationController.view.addGestureRecognizer(submitLogsGesture)
    }

    @objc
    private func submitLogs() {
        DebugLogs.submitLogsWithSupportTag("Onboarding")
    }

    // MARK: - Transitions

    public func provisioningSplashRequestedModeSwitch(viewController: UIViewController) {
        AssertIsOnMainThread()

        Logger.info("")

        let view = ProvisioningModeSwitchConfirmationViewController(provisioningController: self)
        viewController.navigationController?.pushViewController(view, animated: true)
    }

    public func switchToPrimaryRegistration(viewController: UIViewController) {
        AssertIsOnMainThread()

        Logger.info("")
        let loader = RegistrationCoordinatorLoaderImpl(dependencies: .from(self))
        SignalApp.shared.showRegistration(loader: loader, desiredMode: .registering)
    }

    public func provisioningSplashDidComplete(viewController: UIViewController) {
        AssertIsOnMainThread()

        Logger.info("")

        pushPermissionsViewOrSkipToRegistration(onto: viewController)
    }

    private func pushPermissionsViewOrSkipToRegistration(
        onto oldViewController: UIViewController
    ) {
        // Disable interaction during the asynchronous operation.
        oldViewController.view.isUserInteractionEnabled = false

        let newViewController = ProvisioningPermissionsViewController(provisioningController: self)

        firstly(on: DispatchQueue.sharedUserInitiated) {
            newViewController.needsToAskForAnyPermissions()
        }.timeout(
            // If we don't get an answer quickly, assume we need to ask. We don't
            // expect to hit this timeout, but we really don't want to keep users
            // waiting during registration.
            seconds: 1,
            substituteValue: true
        ).recover(on: DispatchQueue.main) { error in
            // This could only happen if something rejects, which we don't expect.
            // However, because it's registration, we assume we need to ask instead of
            // crashing—that's better than preventing registration.
            owsFailDebug("\(error)")
            return .value(true)
        }.done(on: DispatchQueue.main) { (needsToAskForAnyPermissions: Bool) in
            // Always re-enable interaction in case the user restart registration.
            oldViewController.view.isUserInteractionEnabled = true

            if needsToAskForAnyPermissions {
                oldViewController.navigationController?.pushViewController(newViewController, animated: true)
            } else {
                self.provisioningPermissionsDidComplete(viewController: oldViewController)
            }
        }
    }

    public func provisioningPermissionsDidComplete(viewController: UIViewController) {
        AssertIsOnMainThread()

        Logger.info("")

        guard let navigationController = viewController.navigationController else {
            owsFailDebug("navigationController was unexpectedly nil")
            return
        }

        pushTransferChoiceView(onto: navigationController)
    }

    func pushTransferChoiceView(onto navigationController: UINavigationController) {
        AssertIsOnMainThread()

        let view = ProvisioningTransferChoiceViewController(provisioningController: self)
        navigationController.pushViewController(view, animated: true)
    }

    // MARK: - Transfer

    func transferAccount(fromViewController: UIViewController) {
        AssertIsOnMainThread()

        Logger.info("")

        guard let navigationController = fromViewController.navigationController else {
            owsFailDebug("Missing navigationController")
            return
        }

        guard !(navigationController.topViewController is ProvisioningTransferQRCodeViewController) else {
            // qr code view is already presented, we don't need to push it again.
            return
        }

        let view = ProvisioningTransferQRCodeViewController(provisioningController: self)
        navigationController.pushViewController(view, animated: true)
    }

    func accountTransferInProgress(fromViewController: UIViewController, progress: Progress) {
        AssertIsOnMainThread()

        Logger.info("")

        guard let navigationController = fromViewController.navigationController else {
            owsFailDebug("Missing navigationController")
            return
        }

        guard !(navigationController.topViewController is ProvisioningTransferProgressViewController) else {
            // qr code view is already presented, we don't need to push it again.
            return
        }

        let view = ProvisioningTransferProgressViewController(provisioningController: self, progress: progress)
        navigationController.pushViewController(view, animated: true)
    }

    // MARK: - Linking

    func didConfirmSecondaryDevice(from viewController: ProvisioningPrepViewController) {
        guard let navigationController = viewController.navigationController else {
            owsFailDebug("navigationController was unexpectedly nil")
            return
        }

        let qrCodeViewController = ProvisioningQRCodeViewController(provisioningController: self)
        navigationController.pushViewController(qrCodeViewController, animated: true)

        awaitProvisioning(from: qrCodeViewController, navigationController: navigationController)
    }

    private func awaitProvisioning(from viewController: ProvisioningQRCodeViewController,
                                   navigationController: UINavigationController) {

        awaitProvisionMessage.done { [weak self, weak navigationController] message in
            guard let self = self else { throw PromiseError.cancelled }
            guard let navigationController = navigationController else { throw PromiseError.cancelled }

            // Verify the primary device is new enough to link us. Right now this is a simple check
            // of >= the latest version, but when we bump the version we may need to be more specific
            // if we have some backwards compatible support and allow a limited linking with an old
            // version of the app.
            guard let provisioningVersion = message.provisioningVersion,
                provisioningVersion >= OWSDeviceProvisionerConstant.provisioningVersion else {
                    OWSActionSheets.showActionSheet(
                        title: OWSLocalizedString("SECONDARY_LINKING_ERROR_OLD_VERSION_TITLE",
                                                 comment: "alert title for outdated linking device"),
                        message: OWSLocalizedString("SECONDARY_LINKING_ERROR_OLD_VERSION_MESSAGE",
                                                   comment: "alert message for outdated linking device")
                    ) { _ in
                        self.resetPromises()
                        navigationController.popViewController(animated: true)
                    }
                return
            }

            let confirmVC = ProvisioningSetDeviceNameViewController(provisioningController: self)
            navigationController.pushViewController(confirmVC, animated: true)
        }.catch { error in
            switch error {
            case PromiseError.cancelled:
                Logger.info("cancelled")
            default:
                Logger.warn("error: \(error)")
                let alert = ActionSheetController(title: OWSLocalizedString("SECONDARY_LINKING_ERROR_WAITING_FOR_SCAN", comment: "alert title"),
                                                  message: error.userErrorDescription)
                alert.addAction(ActionSheetAction(title: CommonStrings.retryButton,
                                                  accessibilityIdentifier: "alert.retry",
                                                  style: .default,
                                                  handler: { _ in
                                                    self.resetPromises()
                                                    navigationController.popViewController(animated: true)
                }))
                navigationController.presentActionSheet(alert)
            }
        }
    }

    func didSetDeviceName(_ deviceName: String, from viewController: UIViewController) {
        let backgroundBlock: (ModalActivityIndicatorViewController) -> Void = { modal in
            self.completeLinking(deviceName: deviceName).done { result in
                let alert: ActionSheetController
                switch result {
                case .success:
                    modal.dismiss {
                        self.provisioningDidComplete(from: viewController)
                    }
                    return
                case .previouslyLinkedWithDifferentAccount:
                    Logger.warn("was previously linked/registered on different account!")
                    let title = OWSLocalizedString("SECONDARY_LINKING_ERROR_DIFFERENT_ACCOUNT_TITLE",
                                                  comment: "Title for error alert indicating that re-linking failed because the account did not match.")
                    let message = OWSLocalizedString("SECONDARY_LINKING_ERROR_DIFFERENT_ACCOUNT_MESSAGE",
                                                    comment: "Message for error alert indicating that re-linking failed because the account did not match.")
                    alert = ActionSheetController(title: title, message: message)
                    alert.addAction(ActionSheetAction(title: OWSLocalizedString("SECONDARY_LINKING_ERROR_DIFFERENT_ACCOUNT_RESET_DEVICE",
                                                                               comment: "Label for the 'reset device' action in the 're-linking failed because the account did not match' alert."),
                                                      accessibilityIdentifier: "alert.reset_device",
                                                      style: .default,
                                                      handler: { _ in
                                                        Self.resetDeviceState()
                                                      }))
                case .deviceLimitExceededError(let error):
                    alert = ActionSheetController(title: error.errorDescription, message: error.recoverySuggestion)
                    alert.addAction(ActionSheetAction(title: CommonStrings.okButton))
                case .obsoleteLinkedDeviceError:
                    Logger.warn("obsolete device error")
                    let title = OWSLocalizedString("SECONDARY_LINKING_ERROR_OBSOLETE_LINKED_DEVICE_TITLE",
                                                  comment: "Title for error alert indicating that a linked device must be upgraded before it can be linked.")
                    let message = OWSLocalizedString("SECONDARY_LINKING_ERROR_OBSOLETE_LINKED_DEVICE_MESSAGE",
                                                    comment: "Message for error alert indicating that a linked device must be upgraded before it can be linked.")
                    alert = ActionSheetController(title: title, message: message)

                    let updateButtonText = OWSLocalizedString("APP_UPDATE_NAG_ALERT_UPDATE_BUTTON", comment: "Label for the 'update' button in the 'new app version available' alert.")
                    let updateAction = ActionSheetAction(title: updateButtonText,
                                                         accessibilityIdentifier: "alert.update",
                                                         style: .default) { _ in
                                                            let url = TSConstants.appStoreUrl
                                                            UIApplication.shared.open(url, options: [:])
                    }
                    alert.addAction(updateAction)
                case .genericError(let error):
                    let title = OWSLocalizedString("SECONDARY_LINKING_ERROR_WAITING_FOR_SCAN", comment: "alert title")
                    let message = error.userErrorDescription
                    alert = ActionSheetController(title: title, message: message)
                    alert.addAction(ActionSheetAction(title: CommonStrings.retryButton,
                                                      accessibilityIdentifier: "alert.retry",
                                                      style: .default,
                                                      handler: { _ in
                                                        self.didSetDeviceName(deviceName, from: viewController)
                    }))
                }
                modal.dismiss {
                    viewController.presentActionSheet(alert)
                }
            }
        }

        ModalActivityIndicatorViewController.present(fromViewController: viewController,
                                                     canCancel: false,
                                                     backgroundBlock: backgroundBlock)
    }

    public func provisioningDidComplete(from viewController: UIViewController) {
        SignalApp.shared.showConversationSplitView()
    }

    private static func resetDeviceState() {
        Logger.warn("")

        SignalApp.resetAppDataWithUI()
    }

    public func getProvisioningURL() -> Promise<URL> {
        return getDeviceId().map { [weak self] deviceId in
            guard let self = self else { throw PromiseError.cancelled }

            return try self.buildProvisioningUrl(deviceId: deviceId)
        }
    }

    private var _awaitProvisionMessage: Promise<ProvisionMessage>?
    private var awaitProvisionMessage: Promise<ProvisionMessage> {
        if _awaitProvisionMessage == nil {
            _awaitProvisionMessage = provisionEnvelopePromise.map { [weak self] envelope in
                guard let self = self else { throw PromiseError.cancelled }
                return try self.provisioningCipher.decrypt(envelope: envelope)
            }
        }
        return _awaitProvisionMessage!
    }

    private func completeLinking(deviceName: String) -> Guarantee<CompleteProvisioningResult> {
        return awaitProvisionMessage.then { [weak self] provisionMessage -> Promise<CompleteProvisioningResult> in
            guard let self = self else { throw PromiseError.cancelled }

            return Promise.wrapAsync {
                await self.provisioningCoordinator.completeProvisioning(
                    provisionMessage: provisionMessage,
                    deviceName: deviceName
                )
            }
        }.recover {
            return .value(.genericError($0))
        }
    }

    // MARK: -

    private func buildProvisioningUrl(deviceId: String) throws -> URL {
        let base64PubKey: String = Data(
            provisioningCipher.secondaryDevicePublicKey.serialize()
        ).base64EncodedString()
        guard let encodedPubKey = base64PubKey.encodeURIComponent else {
            throw OWSAssertionError("Failed to url encode query params")
        }

        // We don't use URLComponents to generate this URL as it encodes '+' and '/'
        // in the base64 pub_key in a way the Android doesn't tolerate.
        let urlString = "\(UrlOpener.Constants.sgnlPrefix)://\(DeviceProvisioningURL.Constants.linkDeviceHost)?uuid=\(deviceId)&pub_key=\(encodedPubKey)"
        guard let url = URL(string: urlString) else {
            throw OWSAssertionError("invalid url: \(urlString)")
        }

        return url
    }

    private func getDeviceId() -> Promise<String> {
        assert(provisioningSocket.state != .open)
        // TODO send Keep-Alive or ping frames at regular intervals
        // iOS uses ping frames elsewhere, but moxie seemed surprised we weren't
        // using the keepalive endpoint. Waiting to here back from him before proceeding.
        // (If it's sufficient, my preference would be to do like we do elsewhere and
        // use the ping frames)
        provisioningSocket.connect()
        return deviceIdPromise
    }
}

extension ProvisioningController: ProvisioningSocketDelegate {
    public func provisioningSocket(_ provisioningSocket: ProvisioningSocket, didReceiveDeviceId deviceId: String) {
        owsAssertDebug(!deviceIdPromise.isSealed)
        deviceIdFuture.resolve(deviceId)
    }

    public func provisioningSocket(_ provisioningSocket: ProvisioningSocket, didReceiveEnvelope envelope: ProvisioningProtoProvisionEnvelope) {
        // After receiving the provisioning message, there's nothing else to retrieve from the provisioning socket
        provisioningSocket.disconnect(code: .normalClosure)

        owsAssertDebug(!provisionEnvelopePromise.isSealed)
        return provisionEnvelopeFuture.resolve(envelope)
    }

    public func provisioningSocket(_ provisioningSocket: ProvisioningSocket, didError error: Error) {
        deviceIdFuture.reject(error)
        provisionEnvelopeFuture.reject(error)
    }
}
