ConfigDraftViewModel.kt

/*
 * This file is part of GNU Taler
 * (C) 2026 Taler Systems S.A.
 *
 * GNU Taler is free software; you can redistribute it and/or modify it under the
 * terms of the GNU General Public License as published by the Free Software
 * Foundation; either version 3, or (at your option) any later version.
 *
 * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
 * A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along with
 * GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
 */

package net.taler.merchantpos.config

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope

import net.taler.common.Challenge
import net.taler.common.ChallengesResponse
import net.taler.common.TokenDuration
import net.taler.lib.android.ChallengeCancelledException
import net.taler.lib.android.formatMfaCode
import net.taler.lib.android.normalizeMfaCode
import net.taler.merchantpos.R

import io.ktor.client.plugins.ClientRequestException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

import net.taler.common.R as CommonR

/** No Activity or Fragment references: a login attempt can outlive its screen. */
internal interface LoginBackend
{
    suspend fun fetchToken(challengeIds: List<String>): String

    suspend fun requestChallenge(id: String)

    suspend fun confirmChallenge(
        id: String,
        code: String,
    )

    fun configure(token: String)
}

internal class MerchantLoginBackend(
    private val manager: ConfigManager,
    private val baseUrl: String,
    private val username: String,
    private val secret: String,
    private val savePassword: Boolean,
) : LoginBackend
{
    override suspend fun fetchToken(challengeIds: List<String>) =
        withContext(Dispatchers.IO) {
            manager.fetchLimitedAccessToken(baseUrl, username, secret, TokenDuration.Forever, challengeIds)
        }

    override suspend fun requestChallenge(id: String) =
        withContext(Dispatchers.IO) {
            manager.requestChallenge(baseUrl, username, id)
        }

    override suspend fun confirmChallenge(
        id: String,
        code: String,
    ) = withContext(Dispatchers.IO) {
        manager.confirmChallenge(baseUrl, username, id, code)
    }

    override fun configure(token: String)
    {
        manager.fetchConfig(Config.New("$baseUrl/instances/$username", token, savePassword), true)
    }
}

internal sealed class LoginPrompt
{
    abstract fun cancel()

    class Method(
        val challenges: List<Challenge>,
    ) : LoginPrompt()
    {
        internal val answer = CompletableDeferred<Challenge?>()

        fun select(challenge: Challenge)
        {
            if (challenge in challenges) answer.complete(challenge)
        }

        override fun cancel()
        {
            answer.complete(null)
        }
    }

    class Code(
        val challenge: Challenge,
        initialError: Int? = null,
    ) : LoginPrompt()
    {
        internal val answer = CompletableDeferred<String?>()
        var code by mutableStateOf("")
            private set
        var error by mutableStateOf(initialError)
            private set

        fun updateCode(value: String)
        {
            code = value.filter(Char::isDigit).take(8)
            error = null
        }

        fun submit()
        {
            val normalized = normalizeMfaCode(code)
            if (normalized == null)
            {
                error = CommonR.string.mfa_challenge_code_incomplete
            }
            else
            {
                answer.complete(formatMfaCode(normalized))
            }
        }

        override fun cancel()
        {
            answer.complete(null)
        }
    }
}

/** Credentials and MFA state survive rotation in process memory only, never saved state. */
internal class ConfigDraftViewModel : ViewModel()
{
    private var loginJob: Job? = null
    private var attempt: Any? = null

    var initialized = false
    var mode: ConfigMode? = null
    var merchantUrl: String? = null
    var username: String? = null
    var token: String? = null
    var saveToken: Boolean? = null

    var awaitingConfigUpdate = false
        private set

    var isSubmitting by mutableStateOf(false)
        private set

    var prompt by mutableStateOf<LoginPrompt?>(null)
        private set

    val loginError = MutableLiveData<Int?>()

    fun startLogin(backend: LoginBackend)
    {
        if (isSubmitting) return
        val currentAttempt = Any().also { attempt = it }
        isSubmitting = true
        loginError.value = null
        loginJob =
            viewModelScope.launch {
                try
                {
                    var solvedIds = emptyList<String>()
                    while (true)
                    {
                        val limitedToken =
                            try
                            {
                                backend.fetchToken(solvedIds)
                            }
                            catch (e: ChallengeRequiredException)
                            {
                                solvedIds = solveChallenges(backend, e.challengeResponse)
                                continue
                            }
                        awaitingConfigUpdate = true
                        backend.configure(limitedToken)
                        break
                    }
                }
                catch (_: ChallengeCancelledException)
                {
                    // The user cancelled MFA. Keep the form available for another attempt.
                }
                catch (e: CancellationException)
                {
                    throw e
                }
                catch (_: LocalNetworkAccessDenied)
                {
                    awaitingConfigUpdate = false
                    loginError.value = R.string.config_local_network_required
                }
                catch (e: Exception)
                {
                    awaitingConfigUpdate = false
                    loginError.value =
                        if (e is ClientRequestException &&
                            e.response.status.value == 401)
                        {
                            R.string.config_auth_error
                        }
                        else
                        {
                            R.string.config_error_network
                        }
                }
                finally
                {
                    if (attempt === currentAttempt)
                    {
                        prompt = null
                        isSubmitting = awaitingConfigUpdate
                        loginJob = null
                    }
                }
            }
    }

    private suspend fun solveChallenges(
        backend: LoginBackend,
        response: ChallengesResponse,
    ): List<String>
    {
        if (response.challenges.isEmpty()) throw ChallengeCancelledException()
        val challenges =
            if (response.combiAnd)
            {
                response.challenges
            }
            else
            {
                val selection = LoginPrompt.Method(response.challenges)
                prompt = selection
                val selected =
                    try
                    {
                        selection.answer.await()
                    }
                    finally
                    {
                        if (prompt === selection) prompt = null
                    }
                listOf(selected ?: throw ChallengeCancelledException())
            }
        val solved = mutableListOf<String>()
        for (challenge in challenges)
        {
            backend.requestChallenge(challenge.challengeId)
            var error: Int? = null
            while (true)
            {
                val entry = LoginPrompt.Code(challenge, error)
                prompt = entry
                val code =
                    try
                    {
                        entry.answer.await()
                    }
                    finally
                    {
                        if (prompt === entry) prompt = null
                    }
                        ?: throw ChallengeCancelledException()
                try
                {
                    backend.confirmChallenge(challenge.challengeId, code)
                    solved.add(challenge.challengeId)
                    break
                }
                catch (e: CancellationException)
                {
                    throw e
                }
                catch (e: ClientRequestException)
                {
                    when (e.response.status.value)
                    {
                        409 ->
                        {
                            error = CommonR.string.mfa_challenge_invalid
                        }

                        429 ->
                        {
                            backend.requestChallenge(challenge.challengeId)
                            error = CommonR.string.mfa_challenge_retry
                        }

                        else ->
                        {
                            throw e
                        }
                    }
                }
            }
        }
        return solved
    }

    fun finishConfigUpdate()
    {
        awaitingConfigUpdate = false
        isSubmitting = false
    }

    fun cancelLogin()
    {
        attempt = null
        loginJob?.cancel()
        loginJob = null
        prompt = null
        isSubmitting = false
        loginError.value = null
        awaitingConfigUpdate = false
    }

    fun clear()
    {
        cancelLogin()
        initialized = false
        mode = null
        merchantUrl = null
        username = null
        token = null
        saveToken = null
        awaitingConfigUpdate = false
    }
}