# LoginViewModel.kt

    /*
     * The MIT License
     *
     * Original work sponsored and donated by The Danish Health Data Authority (http://www.sundhedsdatastyrelsen.dk)
     *
     * Copyright (C) 2021 The Danish Health Data Authority (http://www.sundhedsdatastyrelsen.dk)
     *
     *
     * Permission is hereby granted, free of charge, to any person obtaining a copy of
     * this software and associated documentation files (the "Software"), to deal in
     * the Software without restriction, including without limitation the rights to
     * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
     * of the Software, and to permit persons to whom the Software is furnished to do
     * so, subject to the following conditions:
     *
     *
     * The above copyright notice and this permission notice shall be included in all
     * copies or substantial portions of the Software.
     *
     *
     * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
     * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
     * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
     * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
     * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
     * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
     * SOFTWARE.
     */

    package dk.nsp.mingraviditet.ui.useradmission.login

    import android.app.Application
    import android.content.Context
    import android.content.DialogInterface
    import android.security.keystore.KeyPermanentlyInvalidatedException
    import androidx.biometric.BiometricPrompt
    import androidx.lifecycle.*
    import dk.nsp.domain.auth.*
    import dk.nsp.mingraviditet.R
    import dk.nsp.mingraviditet.domain.AlertDialogButtonModel
    import dk.nsp.mingraviditet.domain.AlertDialogViewModel
    import dk.nsp.mingraviditet.ui.useradmission.login.keyboard.KeyboardViewModel
    import dk.nsp.mingraviditet.util.extensions.*
    import dk.nsp.mingraviditet.util.livedata.SingleEvent
    import dk.nsp.shared.Failed
    import dk.nsp.shared.Success
    import dk.nsp.shared.tryAndLog
    import dk.nsp.shared.util.launchDefault
    import dk.nsp.shared.util.launchMain
    import dk.nsp.shared.util.sharedprefs.SharedPrefsHelper
    import dk.nsp.util.TextModel
    import timber.log.Timber
    import javax.crypto.BadPaddingException
    import javax.crypto.Cipher
    import javax.crypto.IllegalBlockSizeException

    class LoginViewModel(
        app: Application,
        initialPinMode: PinCodeMode,
        private val keyboardViewModel: KeyboardViewModel
    ) :
        AndroidViewModel(app) {

        private val prefsHelper by lazy {
            SharedPrefsHelper(getApplication())
        }

        private val authRepo by lazy {
            AuthRepo(getApplication())
        }

        private var lastUsedKeyModel: KeyModel? = null

        private var askForBiometricAfterNormalLogin = false

        private val _triggerBiometricPrompt = MutableLiveData<SingleEvent<Cipher>>()
        val triggerBiometricPrompt by _triggerBiometricPrompt

        private val _pinCodeMode = MutableLiveData(initialPinMode)
        val pinCodeMode by _pinCodeMode

        val titleTextModel: LiveData<TextModel> =
            _pinCodeMode.transform { pinMode: PinCodeMode ->
                when (pinMode) {
                    PinCodeMode.Login -> TextModel.from(R.string.login_title)
                    PinCodeMode.CreatePin -> TextModel.from(R.string.login_select_pin_title)
                }
            }

        val descriptionTextModel: LiveData<TextModel> =
            _pinCodeMode.transform { pinMode: PinCodeMode ->
                when (pinMode) {
                    PinCodeMode.Login -> TextModel.from(
                        R.string.login_text,
                        TextModel.from(R.string.login_text_biometric_option)
                    )
                    PinCodeMode.CreatePin -> TextModel.from(R.string.login_select_pin_text)
                }
            }
        //region Error properties

        private val _forceNavigateToRegistration = MutableLiveData<SingleEvent<Unit>>()
        val forceNavigateToRegistration by _forceNavigateToRegistration

        private val _dialogError = MutableLiveData<SingleEvent<AlertDialogViewModel>>()
        val dialogError by _dialogError

        private val _loginErrorEvent = MutableLiveData<SingleEvent<TextModel?>>()
        val loginErrorEvent by _loginErrorEvent

        private val _loginSuccessEvent = MutableLiveData<SingleEvent<Unit>>()
        val loginSuccessEvent by _loginSuccessEvent

        private val _doingLogin = MutableLiveData<SingleEvent<Unit>>()
        val doingLogin by _doingLogin
        //endregion

        private val _basicInfoToast = MutableLiveData<SingleEvent<TextModel>>()
        val basicInfoToast: LiveData<SingleEvent<TextModel>>
            get() = _basicInfoToast

        private val requiredLength = 4
        private val pinObserver = Observer<String> { pinValue ->
            val currentPin = pinValue ?: return@Observer

            if (currentPin.length != requiredLength) {
                return@Observer
            }
            // Disable inputs
            _doingLogin.postSingleEvent()

            when (_pinCodeMode.value) {
                PinCodeMode.CreatePin -> {
                    tryLoginWithNewPin(currentPin)
                }
                PinCodeMode.Login -> {
                    verifyPin(currentPin)
                }
            }
        }

        init {
            keyboardViewModel.onPinChangedListener.observeForever(pinObserver)
            tryHandleInitialBiometric()
        }

        override fun onCleared() {
            keyboardViewModel.onPinChangedListener.removeObserver(pinObserver)
            super.onCleared()
        }

        private fun resetUi() = viewModelScope.launchMain {
            keyboardViewModel.clearPin()
        }

        fun setPinMode(pinMode: PinCodeMode) {
            _pinCodeMode.postValue(pinMode)
        }

        fun goToRegistration() {
            _forceNavigateToRegistration.postSingleEvent()
        }

        //region Existing login with pin
        private fun verifyPin(pinCode: String) = viewModelScope.launchDefault {
            val result = authRepo.tryRestoreFromPassword(pinCode, this).await()
            when (val keyModel = result.keyModelResult) {
                is Failed -> {
                    lastUsedKeyModel = result.optionalKeyModel
                    handleNormalPinLoginError(keyModel.error).join()
                }
                is Success -> {
                    lastUsedKeyModel = keyModel.value
                    handleLoginSuccess(keyModel.value).join()
                }
            }
        }

        private fun handleLoginSuccess(newKeyModel: KeyModel) = viewModelScope.launchMain {
            lastUsedKeyModel = newKeyModel
            when (_pinCodeMode.value) {
                PinCodeMode.Login -> {
                    if (askForBiometricAfterNormalLogin || prefsHelper.askedForUseBiometrics == false) {
                        if (!tryTriggerBiometricPrompt()) {
                            triggerSuccessfulAuthNavigation()
                        }
                    } else {
                        triggerSuccessfulAuthNavigation()
                    }
                }
                PinCodeMode.CreatePin -> {
                    if (!tryTriggerBiometricPrompt()) {
                        triggerSuccessfulAuthNavigation()
                    }
                }
            }
        }

        private fun handleNormalPinLoginError(error: Throwable) =
            handleLoginError(error)
        //endregion

        //region Create pin login flow
        private fun tryLoginWithNewPin(pinCode: String) = viewModelScope.launchDefault {
            when (val result = authRepo.loginWithNewPin(pinCode, this).await()) {
                is Success -> handleCreateLoginSuccess(result.value)
                is Failed -> {
                    handleLoginFailure(result.error).join()
                }
            }
        }

        /**
         * Handle successful login for create pin flow
         * @param newKeyModel KeyModel
         */
        private fun handleCreateLoginSuccess(newKeyModel: KeyModel) = viewModelScope.launchMain {
            lastUsedKeyModel = newKeyModel
            val didTriggerBiometricPrompt = tryTriggerBiometricPrompt()
            if (!didTriggerBiometricPrompt) {
                continueLogin()
            }
        }

        private fun continueLogin() = viewModelScope.launchMain {
            triggerSuccessfulAuthNavigation()
        }

        private fun triggerSuccessfulAuthNavigation() = viewModelScope.launchDefault {
            _loginSuccessEvent.postSingleEvent()
        }
        //endregion

        private fun clearAuthAndForceNewRegistration() = viewModelScope.launchMain {
            prefsHelper.clearBiometricSettings()
            authRepo.clear()
            forceRelogin()
        }

        private fun forceRelogin() {
            _forceNavigateToRegistration.postSingleEvent()
            resetUi()
        }

        //region Biometric prompt
        /**
         * Checks if biometric flow should be started right away
         */
        private fun tryHandleInitialBiometric() {
            val context: Context = getApplication()
            if (_pinCodeMode.value == PinCodeMode.Login && context.isBiometricAvailable()) {
                if (prefsHelper.useBiometrics == true
                    && prefsHelper.askedForUseBiometrics == true
                    && BiometricCipherKeyStore(context).hasAllRequiredStored()
                    && biometricKeystoreIsValid()
                ) {
                    forceBiometricFlow()
                } else if (!biometricKeystoreIsValid()) {
                    //Biometric keystore was invalidated
                    askForBiometricAfterNormalLogin = true
                    _basicInfoToast.postValue(
                        TextModel.from(R.string.login_use_biometrics_after_login).toSingleEvent()
                    )
                } else if (prefsHelper.askedForUseBiometrics == false && biometricKeystoreIsValid()) {
                    //We need the user to log in before we can auth with the biometric dialog
                    askForBiometricAfterNormalLogin = true
                    _basicInfoToast.postValue(
                        TextModel.from(R.string.login_use_biometrics_after_login).toSingleEvent()
                    )
                }
            }
        }

        /**
         * Used to try and force trigger the biometric flow
         * E.g. when the user presses the biometric button
         */
        fun forceBiometricFlow() {
            if (prefsHelper.useBiometrics != true && biometricKeystoreIsValid()) {
                //We need the user to log in before we can auth with the biometric dialog
                askForBiometricAfterNormalLogin = true
                _basicInfoToast.postValue(
                    TextModel.from(R.string.login_use_biometrics_after_login).toSingleEvent()
                )
            } else {
                tryTriggerBiometricPrompt()
            }
        }

        /**
         * Checks and show the biometric prompt if possible/enabled
         * @return Boolean True if the prompt is shown and this takes over the flow, otherwise false
         */
        private fun tryTriggerBiometricPrompt(): Boolean {
            prefsHelper.askedForUseBiometrics = true
            if (!getApplication<Application>().isBiometricAvailable() || !biometricKeystoreIsValid()) {
                Timber.e("Biometric not supported")
                // Biometric not supported
                return false
            }
            val cipher = getKeyStoreCipher() ?: return false
            viewModelScope.launchMain {
                _triggerBiometricPrompt.postValue(SingleEvent(cipher))
            }
            return true
        }

        private fun getKeyStoreCipher(): Cipher? {
            val keyStore = BiometricCipherKeyStore(getApplication())
            return when (_pinCodeMode.value) {
                PinCodeMode.Login -> {
                    val shouldEncryptNewBiometric = askForBiometricAfterNormalLogin
                    try {
                        keyStore.getKeyStoreCipher(shouldEncryptNewBiometric)
                    } catch (invalidKeyException: KeyPermanentlyInvalidatedException) {
                        tryAndLog {
                            // If key is invalid, try delete and retry method
                            keyStore.deleteStoredKey()
                            getKeyStoreCipher()
                        }
                    } catch (e: Exception) {
                        null
                    }
                }
                PinCodeMode.CreatePin -> {
                    tryAndLog { keyStore.getKeyStoreCipher(true) }
                }
                else -> null
            }
        }

        private fun biometricKeystoreIsValid(): Boolean {
            return try {
                val context: Application = getApplication()
                BiometricCipherKeyStore(context.applicationContext).getKeyStoreCipher(false)
                true
            } catch (e: Exception) {
                Timber.e(e, "Error thrown during check of BiometricKeystore validity")
                false
            }
        }

        val biometricPromptCallback = object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                val didUserCancelDialog = errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON
                    || errorCode != BiometricPrompt.ERROR_USER_CANCELED
                    || errorCode != BiometricPrompt.ERROR_CANCELED

                if (didUserCancelDialog) {
                    when (_pinCodeMode.value) {
                        PinCodeMode.Login -> {
                            if (askForBiometricAfterNormalLogin) {
                                // User cancelled the dialog, but haven't biometric setup, so continue
                                askForBiometricAfterNormalLogin = false
                                continueLogin()
                            }
                        }
                        PinCodeMode.CreatePin -> continueLogin()
                    }
                } else {
                    // If error wasn't due to user cancelling the dialog
                    Timber.e("$errString (code: $errorCode)")
                    handleLoginError(BiometricError(errString.toString()))
                }

                super.onAuthenticationError(errorCode, errString)
            }

            override fun onAuthenticationFailed() {
                Timber.e("Authentication with biometric failed.")
                handleLoginError(BiometricError("Biometrisk login fejlede, brug pinkode"))
                super.onAuthenticationFailed()
            }

            override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                super.onAuthenticationSucceeded(result)
                result.cryptoObject?.let {
                    onBiometricSuccess(it)
                }
            }
        }

        private fun onBiometricSuccess(crypto: BiometricPrompt.CryptoObject) {
            val keyModel = this.lastUsedKeyModel
            when (_pinCodeMode.value) {
                PinCodeMode.Login -> {
                    Timber.d("Login")
                    if (askForBiometricAfterNormalLogin) {
                        keyModel ?: return
                        // User is setting up biometric logging for the app
                        askForBiometricAfterNormalLogin = false
                        storeLongSecretViaBiometric(crypto, keyModel)
                    } else {
                        // User already has biometric setup try login
                        authenticateWithBiometric(crypto)
                    }
                }
                PinCodeMode.CreatePin -> {
                    Timber.d("Create pin")
                    keyModel ?: return
                    storeLongSecretViaBiometric(crypto, keyModel)
                }
            }
        }

        private fun storeLongSecretViaBiometric(
            crypto: BiometricPrompt.CryptoObject,
            keyModel: KeyModel
        ) {
            val cipher = crypto.cipher ?: return
            when (val saveResult = authRepo.storeLongSecretViaBiometric(cipher, keyModel)) {
                is Success -> {
                    prefsHelper.useBiometrics = true
                    continueLogin()
                }
                is Failed -> {
                    Timber.e(
                        saveResult.error,
                        "Something went wrong when trying to store biometric crypto"
                    )
                    _basicInfoToast.postValue(
                        TextModel.from(R.string.common_alert_message_generic).toSingleEvent()
                    )
                    prefsHelper.askedForUseBiometrics = false
                    continueLogin()
                }
            }
        }

        private fun authenticateWithBiometric(crypto: BiometricPrompt.CryptoObject) =
            viewModelScope.launchDefault {
                _doingLogin.postSingleEvent()
                val cipher = crypto.cipher ?: return@launchDefault
                val restoreResult = authRepo.tryRestoreFromBiometric(cipher, this).await()
                when (val keyModel = restoreResult.keyModelResult) {
                    is Success -> {
                        lastUsedKeyModel = keyModel.value
                        continueLogin().join()
                    }
                    is Failed -> {
                        Timber.e(keyModel.error, "Biometric login failed")
                        handleLoginError(
                            keyModel.error
                        )
                        // Check if error is due to user added/deleted biometric(s) from biometric
                        if (keyModel.error is BadPaddingException) {
                            // We have to clear settings and
                            authRepo.removeLongSecretStoredViaBiometric()
                            prefsHelper.clearBiometricSettings()
                            prefsHelper.askedForUseBiometrics = true
                        }
                    }
                }
            }
        //endregion

        //region Error handling
        private fun handleLoginError(error: Throwable) =
            viewModelScope.launchMain {
                if (getApplication<Application>().isInternetAvailable()) {
                    val logMessage: String

                    // Potential network errors
                    //       bad pin
                    //      key not found
                    //      key locked
                    //      refresh token expired
                    //      statusCode == 500
                    //      bad gateway
                    //      generic error
                    when {
                        error.loginErrors.isBadPin() -> {
                            _loginErrorEvent.postValue(
                                TextModel.from(R.string.login_error_wrong_pin_code).toSingleEvent()
                            )
                            logMessage = "Bad pin"
                        }
                        error.loginErrors.isKeyLocked() -> {
                            logMessage = "Key locked"
                            keyLocked()
                        }
                        error.loginErrors.couldNotCreateKey() -> {
                            logMessage = "Could not create key?"
                            keyLocked()
                        }
                        error.loginErrors.keyNotFound() -> {
                            logMessage = "Key could not be found"
                            keyLocked()
                        }
                        error.loginErrors.isRefreshTokenExpired() -> {
                            logMessage = "RefreshToken expired"
                            loginExpired()
                        }
                        error is IllegalBlockSizeException -> {
                            logMessage = "User added/deleted biometric(s)"
                        }
                        error is BiometricError -> {
                            logMessage = "Was biometric error"
                            onBiometricsError(error)
                        }
                        else -> {
                            logMessage = "Unknown error"
                            _loginErrorEvent.postValue(
                                TextModel.from(R.string.login_error_generic).toSingleEvent()
                            )
                        }
                    }

                    Timber.e("Login gone wrong - $logMessage")
                } else {
                    noInternetConnection()
                }
            }

        /**
         * Handle potential error thrown when executing login
         * @param error Throwable
         */
        private fun handleLoginFailure(error: Throwable) = viewModelScope.launchMain {
            Timber.e(error, "Failed to store refresh token")
            resetUi().join()
        }

        private fun onBiometricsError(error: BiometricError) {
            _loginErrorEvent.postValue(
                TextModel.from(error.uiMessage ?: "Fejl ved godkendelse med biometri").toSingleEvent()
            )
            resetUi()
        }

        private fun noInternetConnection() {
            _dialogError.postValue(
                AlertDialogViewModel(
                    R.string.common_alert_title_error,
                    R.string.common_alert_message_failed_to_load_data,
                    false,
                    positiveButton = AlertDialogButtonModel(R.string.common_ok),
                    onDismissListener = defaultOnErrorDismissListener
                ).toSingleEvent()
            )
        }

        private fun loginExpired() {
            _dialogError.postValue(
                AlertDialogViewModel(
                    R.string.common_alert_title_error,
                    R.string.login_error_refresh_token_expired,
                    false,
                    AlertDialogButtonModel(
                        R.string.common_ok
                    ) { _, _ ->
                        clearAuthAndForceNewRegistration()
                    }
                ).toSingleEvent()
            )
        }

        private fun keyLocked() {
            _dialogError.postValue(
                AlertDialogViewModel(
                    R.string.common_alert_title_error,
                    R.string.login_error_key_locked,
                    false,
                    AlertDialogButtonModel(
                        R.string.common_ok
                    ) { _, _ ->
                        clearAuthAndForceNewRegistration()
                    }
                ).toSingleEvent()
            )
        }

        private val defaultOnErrorDismissListener = DialogInterface.OnDismissListener {
            resetUi()
        }
    }