# AuthController.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.api.auth

    import android.content.ActivityNotFoundException
    import android.content.Context
    import android.content.Intent
    import dk.nsp.api.auth.keyserver.KeyServerController
    import dk.nsp.api.auth.keyserver.models.KeyModelDTO
    import dk.nsp.api.auth.keyserver.models.decrypt
    import dk.nsp.api.auth.keyserver.models.encrypt
    import dk.nsp.api.auth.keystore.KeyStoreController
    import dk.nsp.shared.Failed
    import dk.nsp.shared.ResultWrapper
    import dk.nsp.shared.Success
    import dk.nsp.shared.tryOrFailed
    import net.openid.appauth.*
    import timber.log.Timber
    import javax.crypto.Cipher

    class AuthController private constructor(context: Context) {
        private val config by lazy {
            AuthConstants.openIdBackendConfig
        }

        private val keyServer by lazy {
            KeyServerController.get()
        }

        private val authServiceInstance: AuthorizationService = AuthorizationService(
            context.applicationContext,
            AppAuthConfiguration.Builder().build()
        )

        private val keyStoreController: KeyStoreController =
            KeyStoreController(context.applicationContext)

        //region Current user/auth state
        private var currentAuth: AuthState? = null

        private fun hasUser(): Boolean =
            keyStoreController.hasRefreshToken() && keyStoreController.hasKeyId()

        fun isLoggedIn(): Boolean = currentAuth != null
        fun hasLogin(): Boolean = currentAuth != null || hasUser()
        fun clear() = keyStoreController.clear()
        //endregion

        fun removeLongSecretStoredViaBiometric() =
            keyStoreController.removeLongSecretStoredViaBiometric()

        /**
         * Creates an intent for signing in to the Open ID connect server using the local [AuthController.config]
         * @return ResultWrapper<Intent> Contains the Intent to use for startActivityForResult
         * or an error if something went wrong
         */
        suspend fun getRegisterIntent(): ResultWrapper<Intent> {
            when (val servicesResult = discoverServices()) {
                is Failed -> return servicesResult
                is Success -> {
                    val authRequest = buildAuthRequest(servicesResult.value)

                    // Create intent for NemID flow
                    val intent: Intent
                    try {
                        intent = authServiceInstance.getAuthorizationRequestIntent(authRequest)
                        Timber.d("Auth request: ${intent}")
                    } catch (exception: ActivityNotFoundException) {
                        Timber.e(exception, "No usable browser found")
                        return Failed(
                            AuthException.NoUsableBrowserFoundException
                        )
                    }

                    return Success(intent)
                }
            }
        }

        /**
         * Discovers the available services (Using the discovery Uri from the local [config]),
         * which finds all the right configuration for the given open id connect server
         * @return ResultWrapper<AuthorizationServiceConfiguration>
         */
        private suspend fun discoverServices(): ResultWrapper<AuthorizationServiceConfiguration> =
            getServiceConfiguration(config.discoveryUrl)

        /**
         * Handles result from NemID custom tab data intent, requests a token and attempts gets user CPR
         * @param loginResult Intent?
         * @return [Success] if everything went well or [Failed] if something went wrong
         */
        suspend fun handleLoginResult(loginResult: Intent?): ResultWrapper<Unit> {
            if (loginResult == null) {
                Timber.e("Login result was null")
                return Failed(Exception("Login result was null"))
            }

            val response = AuthorizationResponse.fromIntent(loginResult)
            val exception = AuthorizationException.fromIntent(loginResult)
            if (response == null) {
                val error = exception ?: AuthException.UnknownError("AuthResponse error was null")
                Timber.e(error, "Auth response was null")
                return Failed(error)
            }

            // We are now ensured auth data
            val tokenRequest =
                authServiceInstance.performTokenRequest(response.createTokenExchangeRequest())
            when (tokenRequest) {
                is Success -> {
                    //If a user is already registered locally, clear the user and force generation of a new login.
                    if (hasUser()) {
                        Timber.d("Deleting existing user")
                        clearSession()
                        clear()
                    }

                    currentAuth = AuthState(response, tokenRequest.value, exception)

                    return Success(Unit)
                }
                is Failed -> {
                    Timber.e("Failed to get token")
                    return tokenRequest
                }
            }
        }

        private suspend fun tryRestoreFromLongSecret(longSecret: String): OptionalKeyModelWithPotentialError {
            val keyId = keyStoreController.getKeyId() ?: return OptionalKeyModelWithPotentialError(
                Failed(AuthException.MissingKeyIdInStorage)
            )
            val keyModelResult = keyServer.getKeyViaLongSecret(longSecret, keyId)
            return restoreFromKeyModel(keyModelResult)
        }

        //region AccessToken
        suspend fun getAccessToken(): ResultWrapper<String> =
            currentAuth?.performActionWithFreshTokens(authServiceInstance)
                ?: Failed(Exception("User not logged in"))
        //endregion

        //region Store refresh token
        /**
         * Attempts to get a [KeyModelDTO] from [KeyServerController], encrypt the local refreshToken (If it exists),
         * stores it in the [KeyStoreController] and returns the [KeyModelDTO] used for encryption
         * @param password String The pincode selected by the user
         * @return ResultWrapper<KeyModelDTO> The KeyModel used to encrypt the refreshToken
         */
        suspend fun storeRefreshTokenAndKeyId(password: String): ResultWrapper<KeyModelDTO> {
            val createKeyModelResult: ResultWrapper<KeyModelDTO> = keyServer.createKey(password)

            when (createKeyModelResult) {
                is Failed -> {
                    Timber.e(createKeyModelResult.error, "Failed to get key from KeyServer")
                    return createKeyModelResult
                }
                is Success -> {
                    val keyModel = createKeyModelResult.value

                    val refreshToken = currentAuth?.refreshToken
                    if (refreshToken == null) {
                        val error = AuthException.NoRefreshTokenError
                        Timber.e(error, "Refresh token was null")
                        return Failed(error)
                    }

                    val encryptedToken = keyModel.encrypt(refreshToken.toByteArray(Charsets.UTF_8))
                    if (encryptedToken == null) {
                        val error = AuthException.FailedEncryption
                        Timber.e(error, "Could not encrypt with keymodel")
                        return Failed(error)
                    }

                    keyStoreController.storeRefreshTokenAndKeyId(encryptedToken, keyModel.keyId)

                    return createKeyModelResult
                }
            }
        }
        //endregion

        //region Restore session from password
        suspend fun tryRestoreFromPassword(password: String): OptionalKeyModelWithPotentialError {
            val keyId = keyStoreController.getKeyId()
            return if (keyId == null) {
                OptionalKeyModelWithPotentialError(
                    Failed(
                        AuthException.MissingKeyIdInStorage
                    )
                )
            } else {
                restoreFromKeyModel(keyServer.getKey(password, keyId))
            }
        }

        /**
         * Uses the KeyModel to do all the heavy lifting: Decrypting -> Service Discovery -> Refresh-
         * token fetch -> Access token fetch -> attempt to store locally in [KeyStoreController]
         * @param wrappedKeyModel ResultWrapper<KeyModelDTO>
         * @return ResultWrapper<KeyModelDTO> The KeyModel used for the whole flow
         */
        private suspend fun restoreFromKeyModel(wrappedKeyModel: ResultWrapper<KeyModelDTO>): OptionalKeyModelWithPotentialError {
            when (wrappedKeyModel) {
                is Failed -> return OptionalKeyModelWithPotentialError(wrappedKeyModel)
                is Success -> {
                    val keyModel = wrappedKeyModel.value
                    val encryptedRefreshToken = keyStoreController.getEncryptedRefreshToken()
                        ?: return OptionalKeyModelWithPotentialError(
                            Failed(
                                AuthException.MissingRefreshTokenInStorage
                            ), keyModel
                        )
                    val decryptedRefreshToken = keyModel.decrypt(encryptedRefreshToken)
                        ?: return OptionalKeyModelWithPotentialError(
                            Failed(
                                AuthException.BadKeyOrEncryption
                            ), keyModel
                        )

                    val refreshToken = decryptedRefreshToken.toString(Charsets.UTF_8)

                    when (val serviceConfig = discoverServices()) {
                        is Failed -> return OptionalKeyModelWithPotentialError(
                            Failed(
                                serviceConfig.error
                            ), keyModel
                        )
                        is Success -> {
                            val tokenRequest = TokenRequest.Builder(
                                serviceConfig.value,
                                config.clientId
                            )
                                .setScopes(config.scopes)
                                .setGrantType(GrantTypeValues.REFRESH_TOKEN)
                                .setRefreshToken(refreshToken)
                                .build()

                            when (val tokenResponse =
                                authServiceInstance.performTokenRequest(tokenRequest)) {
                                is Failed -> return OptionalKeyModelWithPotentialError(
                                    Failed(
                                        tokenResponse.error
                                    ), keyModel
                                )
                                is Success -> {
                                    // Create a correct AuthState
                                    currentAuth = AuthState(
                                        buildFakeAuthorizationResponse(serviceConfig.value),
                                        tokenResponse.value,
                                        null
                                    )

                                    currentAuth?.refreshToken?.let {
                                        storeRefreshTokenNoException(it, keyModel)
                                    }

                                    return OptionalKeyModelWithPotentialError(
                                        Success(
                                            keyModel
                                        )
                                    )
                                }
                            }
                        }
                    }
                }
            }
        }

        /**
         * Builds an authorization request. (Using the local [config])
         * @param authServiceConfig The discovered Auth service configuration from [discoverServices]
         * @return AuthorizationRequest
         */
        private fun buildAuthRequest(authServiceConfig: AuthorizationServiceConfiguration): AuthorizationRequest =
            AuthorizationRequest.Builder(
                authServiceConfig,
                config.clientId,
                ResponseTypeValues.CODE,
                config.redirectUrl
            )
                .setScopes(config.scopes)
                .build()

        /**
         * Builds a fake authorization response, which is needed to update the AuthState correctly when restoring
         * the RefreshToken from a KeyModel
         */
        private fun buildFakeAuthorizationResponse(serviceConfig: AuthorizationServiceConfiguration) =
            AuthorizationResponse.Builder(
                buildAuthRequest(serviceConfig)
            ).build()

        private fun storeRefreshTokenNoException(refreshToken: String, keyModel: KeyModelDTO) {
            val unencryptedRefreshToken = refreshToken.toByteArray(Charsets.UTF_8)
            keyModel.encrypt(unencryptedRefreshToken)?.let { encryptedToken ->
                keyStoreController.storeRefreshTokenAndKeyId(encryptedToken, keyModel.keyId)
            }
        }

        fun storeLongSecretViaBiometric(cipher: Cipher, keyModel: KeyModelDTO): ResultWrapper<Unit> {
            return tryOrFailed {
                keyStoreController.storeLongSecretViaBiometric(cipher, keyModel)
            }
        }

        fun clearSession() {
            currentAuth = null
        }

        suspend fun tryRestoreFromBiometric(cipher: Cipher): OptionalKeyModelWithPotentialError {
            return try {
                val encryptedLongSecret = keyStoreController.getEncryptedLongSecret()
                val decryptedLongSecret = cipher.doFinal(encryptedLongSecret)
                val longSecret = String(decryptedLongSecret, Charsets.UTF_8)
                tryRestoreFromLongSecret(longSecret)
            } catch (e: Exception) {
                OptionalKeyModelWithPotentialError(
                    Failed(e)
                )
            }
        }
        //endregion

        companion object {

            private var instance: AuthController? = null

            /**
             * Gets shared AuthController instance for the app
             * @param context Context
             * @return A singleton instance of AuthController
             */
            fun get(context: Context): AuthController {
                return instance ?: AuthController(context.applicationContext).apply {
                    instance = this
                }
            }
        }
    }