ConfigManager.kt

/*
 * This file is part of GNU Taler
 * (C) 2020 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 android.content.Context
import android.content.Context.MODE_PRIVATE
import android.util.Log

import androidx.annotation.UiThread
import androidx.annotation.WorkerThread
import androidx.core.net.toUri
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData

import net.taler.common.ChallengeConfirmRequest
import net.taler.common.ChallengesResponse
import net.taler.common.CurrencySpecification
import net.taler.common.Event
import net.taler.common.TokenDuration
import net.taler.common.TokenRequest
import net.taler.common.Version
import net.taler.lib.android.getIncompatibleStringOrNull
import net.taler.merchantlib.MerchantConfig
import net.taler.merchantpos.BuildConfig
import net.taler.merchantpos.R

import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.plugins.timeout
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpHeaders.Authorization
import io.ktor.http.HttpStatusCode
import io.ktor.http.HttpStatusCode.Companion.Unauthorized
import io.ktor.http.contentType
import io.ktor.serialization.ContentConvertException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.buildJsonObject

private const val SETTINGS_NAME = "taler-merchant-terminal"

private const val SETTINGS_CONFIG_VERSION = "configVersion"

internal const val CONFIG_VERSION_OLD = 0
internal const val CONFIG_VERSION_NEW = 1

// Old JSON config + basic auth config

private const val SETTINGS_CONFIG_URL = "configUrl"
private const val SETTINGS_USERNAME = "username"
private const val SETTINGS_PASSWORD = "password"
private const val SETTINGS_SAVE_PASSWORD = "savePassword"

internal const val OLD_CONFIG_URL_DEMO = "https://docs.taler.net/_static/sample-pos-config.json"
internal const val OLD_CONFIG_USERNAME_DEMO = ""
internal const val OLD_CONFIG_PASSWORD_DEMO = ""

// New merchant API + token config

private const val SETTINGS_MERCHANT_URL = "merchantUrl"
private const val SETTINGS_ACCESS_TOKEN = "accessToken"
private const val SETTINGS_INITIAL_ORDER_SCREEN = "initialOrderScreen"
private const val SETTINGS_CACHED_RUNTIME_CONFIG = "cachedRuntimeConfig"

internal const val NEW_CONFIG_URL_DEMO = "my.taler-ops.ch"

private val VERSION = Version.parse(BuildConfig.BACKEND_API_VERSION)!!
private val json =
    Json {
        ignoreUnknownKeys = true
        encodeDefaults = true
    }

private val TAG = ConfigManager::class.java.simpleName

enum class InitialOrderScreen(
    val prefValue: String,
)
{
    AmountEntry("amountEntry"),
    Inventory("inventory"),
    ;

    companion object
    {
        fun fromPrefValue(value: String?): InitialOrderScreen =
            entries.firstOrNull { it.prefValue == value } ?: AmountEntry
    }
}

@Serializable
private data class MerchantBackendConfigResponse(
    val version: String,
    val currency: String,
    val currencies: Map<String, CurrencySpecification> = emptyMap(),
)

@Serializable
private data class ProductDetail(
    @SerialName("total_sold")
    val totalSold: Int? = null,
    @SerialName("total_lost")
    val totalLost: Int? = null,
    @SerialName("unit_total_sold")
    val unitTotalSold: String? = null,
    @SerialName("unit_total_lost")
    val unitTotalLost: String? = null,
)

@Serializable
private data class CachedRuntimeConfig(
    val posConfig: PosConfig,
    val merchantConfig: MerchantConfig,
    val currency: String,
    val currencySpec: CurrencySpecification? = null,
)

// -- Limited access token --
@Serializable
private data class LimitedTokenResponse(
    val token: String,
    val scope: String,
    val refreshable: Boolean,
    // Using Unit here as a placeholder if we don't need the actual expiration object
    // or we could use net.taler.common.TokenExpiration
    val expiration: JsonElement,
)

class ChallengeRequiredException(
    val challengeResponse: ChallengesResponse,
) : Exception()

// -- Limited access token END --

interface ConfigurationReceiver
{
    /**
     * Returns null if the configuration was valid, or an error string for user display otherwise.
     */
    suspend fun onConfigurationReceived(
        posConfig: PosConfig,
        currency: String,
        currencySpec: CurrencySpecification?,
    ): String?

    /** Applies an inventory refresh; returns null on success or an error for display. */
    suspend fun onInventoryUpdated(
        posConfig: PosConfig,
        currency: String,
        currencySpec: CurrencySpecification?,
    ): String? = onConfigurationReceived(posConfig, currency, currencySpec)
}

class ConfigManager(
    private val context: Context,
    private val scope: CoroutineScope,
    private val httpClient: HttpClient,
    private val requestTimeoutMillis: Long = 30_000,
    private val beforeConnection: suspend (String) -> Unit = {},
)
{
    private val prefs = context.getSharedPreferences(SETTINGS_NAME, MODE_PRIVATE)
    private val configurationReceivers = ArrayList<ConfigurationReceiver>()
    private var inventoryRefreshJob: Job? = null
    private var configFetchJob: Job? = null
    private var fetchingConfig: Config? = null
    private var fetchingSilently = false

    private fun HttpRequestBuilder.configTimeout()
    {
        timeout {
            requestTimeoutMillis = this@ConfigManager.requestTimeoutMillis
            socketTimeoutMillis = this@ConfigManager.requestTimeoutMillis
            connectTimeoutMillis = minOf(15_000, this@ConfigManager.requestTimeoutMillis)
        }
    }

    @UiThread
    fun cancelConfigFetch()
    {
        configFetchJob?.cancel()
        configFetchJob = null
        fetchingConfig = null
        mConfigUpdateResult.value = null
    }

    private var cachedRuntimeConfig: CachedRuntimeConfig? = null

    init
    {
        migrateLegacyPrefsIfNeeded()
    }

    private val _sessionExpired = MutableLiveData<Event<Unit>>()
    private val mMerchantIdentity = MutableLiveData<String?>(null)
    private val mInitialOrderScreen =
        MutableLiveData(
            InitialOrderScreen.fromPrefValue(
                prefs.getString(SETTINGS_INITIAL_ORDER_SCREEN, InitialOrderScreen.AmountEntry.prefValue),
            ),
        )
    private val mConfigUpdateResult = MutableLiveData<ConfigUpdateResult?>()

    val sessionExpired: LiveData<Event<Unit>> = _sessionExpired
    val merchantIdentity: LiveData<String?> = mMerchantIdentity
    val initialOrderScreenLiveData: LiveData<InitialOrderScreen> = mInitialOrderScreen
    val configUpdateResult: LiveData<ConfigUpdateResult?> = mConfigUpdateResult

    @Volatile
    var config: Config =
        Config.New(
            merchantUrl = prefs.getString(SETTINGS_MERCHANT_URL, "")!!,
            accessToken = prefs.getString(SETTINGS_ACCESS_TOKEN, "")!!,
            savePassword = prefs.getBoolean(SETTINGS_SAVE_PASSWORD, true),
        )

    @Volatile
    var merchantConfig: MerchantConfig? = null
        private set(value)
        {
            field = value
            val identity = value?.baseUrl?.trimEnd('/')
            if (mMerchantIdentity.value != identity) mMerchantIdentity.value = identity
        }

    @Volatile
    var currency: String? = null
        private set

    @Volatile
    var currencySpec: CurrencySpecification? = null
        private set

    @Volatile
    var backendVersion: String? = null
        private set

    var initialOrderScreen: InitialOrderScreen
        get() = mInitialOrderScreen.value ?: InitialOrderScreen.AmountEntry
        set(value)
        {
            prefs
                .edit()
                .putString(SETTINGS_INITIAL_ORDER_SCREEN, value.prefValue)
                .apply()
            mInitialOrderScreen.value = value
        }

    init
    {
        restoreCachedRuntimeConfig()
    }

    fun addConfigurationReceiver(receiver: ConfigurationReceiver)
    {
        configurationReceivers.add(receiver)
        cachedRuntimeConfig?.let { cached ->
            scope.launch {
                receiver.onConfigurationReceived(cached.posConfig, cached.currency, cached.currencySpec)
            }
        }
    }

    internal fun debugApplyFixture(
        posConfig: PosConfig,
        merchantConfig: MerchantConfig,
        currency: String,
        currencySpec: CurrencySpecification? = null,
        initialOrderScreen: InitialOrderScreen = InitialOrderScreen.Inventory,
    )
    {
        config =
            Config.New(
                merchantUrl = merchantConfig.baseUrl,
                accessToken = "",
                savePassword = false,
            )
        this.merchantConfig = merchantConfig
        this.currency = currency
        this.currencySpec = currencySpec
        this.initialOrderScreen = initialOrderScreen

        val snapshot =
            CachedRuntimeConfig(
                posConfig = posConfig,
                merchantConfig = merchantConfig,
                currency = currency,
                currencySpec = currencySpec,
            )
        // Screenshot fixtures must not replace the signed-in merchant's saved cache.
        cachedRuntimeConfig = snapshot
        runBlocking {
            configurationReceivers.forEach { receiver ->
                receiver.onConfigurationReceived(posConfig, currency, currencySpec)
            }
        }
    }

    private fun migrateLegacyPrefsIfNeeded()
    {
        val legacyVersion = prefs.getInt(SETTINGS_CONFIG_VERSION, CONFIG_VERSION_NEW)
        if (legacyVersion == CONFIG_VERSION_OLD)
        {
            prefs.edit().clear().apply()
        }
    }

    @UiThread
    fun reloadConfig()
    {
        fetchConfig(config, save = true, inventoryOnly = true, silent = false)
    }

    @UiThread
    fun refreshConfigInBackground()
    {
        if (!config.isValid() ||
            !config.hasPassword())
        {
            return
        }
        fetchConfig(config, save = false, inventoryOnly = true, silent = true)
    }

    @UiThread
    fun refreshInventory()
    {
        inventoryRefreshJob?.cancel()
        inventoryRefreshJob =
            scope.launch {
                delay(350)
                fetchConfig(config, save = false, inventoryOnly = true, silent = true)
            }
    }

    @UiThread
    fun fetchConfig(
        config: Config,
        save: Boolean,
    )
    {
        fetchConfig(config, save, inventoryOnly = false, silent = false)
    }

    @UiThread
    private fun fetchConfig(
        config: Config,
        save: Boolean,
        inventoryOnly: Boolean,
        silent: Boolean,
    )
    {
        if (configFetchJob?.isActive == true)
        {
            if (silent || (!fetchingSilently && fetchingConfig == config)) return
            configFetchJob?.cancel()
        }
        fetchingConfig = config
        fetchingSilently = silent
        if (!silent) mConfigUpdateResult.value = null
        val configToSave =
            if (save)
            {
                if (config.savePassword())
                {
                    config
                }
                else
                {
                    when (val c = config)
                    {
                        // is Config.Old -> c.copy(password = "")
                        is Config.New -> c.copy(accessToken = "")
                    }
                }
            }
            else
            {
                null
            }

        configFetchJob =
            scope.launch(Dispatchers.IO) {
            try
            {
                val url =
                    when (val c = config)
                    {
                        // is Config.Old -> c.configUrl
                        is Config.New ->
                        {
                            c.merchantUrl
                                .toUri()
                                .buildUpon()
                                .appendPath("private")
                                .appendPath("pos")
                                .build()
                                .toString()
                        }
                    }

                val rawToken =
                    when (val c = config)
                    {
                        is Config.New -> c.accessToken
                    }

                beforeConnection(url)
                val (posConfig, apiKey) = fetchPosConfig(url, rawToken)

                val merchantConfig =
                    when (config)
                    {
                        // is Config.Old -> posConfig.merchantConfig!!
                        is Config.New -> MerchantConfig(config.merchantUrl, apiKey)
                    }

                val backendConfig: MerchantBackendConfigResponse =
                    httpClient
                        .get(merchantConfig.urlFor("config")) {
                        configTimeout()
                    }.body()
                onMerchantConfigReceived(
                    configToSave,
                    posConfig,
                    merchantConfig,
                    backendConfig,
                    inventoryOnly,
                    silent,
                )
            }
            catch (e: CancellationException)
            {
                throw e
            }
            catch (_: LocalNetworkAccessDenied)
            {
                if (!silent)
                {
                    withContext(Dispatchers.Main) {
                        mConfigUpdateResult.value =
                            ConfigUpdateResult.Error(
                            context.getString(R.string.config_local_network_required),
                            returnToConfig = true,
                        )
                    }
                }
            }
            catch (e: Exception)
            {
                Log.e(TAG, "Error retrieving merchant config", e)
                val msg =
                    if (e is ClientRequestException)
                    {
                        context.getString(
                            if (e.response.status == Unauthorized)
                            {
                                R.string.config_auth_error
                            }
                            else
                            {
                                R.string.config_error_network
                            },
                        )
                    }
                    else
                    {
                        context.getString(
                            if (e is SerializationException || e is ContentConvertException)
                            {
                                R.string.config_error_malformed
                            }
                            else
                            {
                                R.string.config_error_network
                            },
                        )
                    }
                if (!silent) onNetworkError(msg)
            }
        }
    }

    @WorkerThread
    private suspend fun fetchPosConfig(
        url: String,
        rawToken: String,
    ): Pair<PosConfig, String>
    {
        try
        {
            val posConfig: PosConfig =
                httpClient
                    .get(url) {
                        configTimeout()
                        header(Authorization, "Bearer $rawToken")
                    }.body()
            return posConfig to rawToken
        }
        catch (e: ClientRequestException)
        {
            if (e.response.status != Unauthorized ||
                rawToken.startsWith("secret-token:"))
            {
                throw e
            }
        }
        val prefixedToken = "secret-token:$rawToken"
        val posConfig: PosConfig =
            httpClient
                .get(url) {
                    configTimeout()
                    header(Authorization, "Bearer $prefixedToken")
                }.body()
        return posConfig to prefixedToken
    }

    @WorkerThread
    private suspend fun onMerchantConfigReceived(
        newConfig: Config?,
        posConfig: PosConfig,
        merchantConfig: MerchantConfig,
        configResponse: MerchantBackendConfigResponse,
        inventoryOnly: Boolean,
        silent: Boolean,
    )
    {
        val versionIncompatible =
            VERSION.getIncompatibleStringOrNull(context, configResponse.version)
        if (versionIncompatible != null)
        {
            Log.e(TAG, "Versions incompatible $configResponse")
            if (!silent) onNetworkError(versionIncompatible)
            return
        }
        val currencySpec = configResponse.currencies[configResponse.currency]
        val posConfigWithCachedStock = applyCachedStockData(posConfig)
        for (receiver in configurationReceivers)
        {
            val result =
                try
                {
                    if (inventoryOnly)
                    {
                        receiver.onInventoryUpdated(posConfigWithCachedStock, configResponse.currency, currencySpec)
                    }
                    else
                    {
                        receiver.onConfigurationReceived(
                            posConfigWithCachedStock,
                            configResponse.currency,
                            currencySpec,
                        )
                    }
                }
                catch (e: Exception)
                {
                    if (e is CancellationException) throw e
                    Log.e(TAG, "Error handling configuration by ${receiver::class.java.simpleName}", e)
                    context.getString(R.string.config_error_unknown)
                }
            if (result != null)
            { // error
                if (!silent) onNetworkError(result)
                return
            }
        }
        withContext(Dispatchers.Main) {
            newConfig?.let {
                config = it
                saveConfig(it)
            }
            this@ConfigManager.merchantConfig = merchantConfig
            this@ConfigManager.currency = configResponse.currency
            this@ConfigManager.currencySpec = currencySpec
            this@ConfigManager.backendVersion = configResponse.version
            saveCachedRuntimeConfig(
                CachedRuntimeConfig(
                    posConfig = posConfigWithCachedStock,
                    merchantConfig = merchantConfig,
                    currency = configResponse.currency,
                    currencySpec = currencySpec,
                ),
            )
            if (!silent)
            {
                mConfigUpdateResult.value = ConfigUpdateResult.Success(configResponse.currency)
            }
        }
        scope.launch(Dispatchers.IO) {
            val enrichedPosConfig = enrichProductsWithStockDetails(posConfig, merchantConfig)
            if (enrichedPosConfig !== posConfig)
            {
                for (receiver in configurationReceivers)
                {
                    try
                    {
                        receiver.onInventoryUpdated(enrichedPosConfig, configResponse.currency, currencySpec)
                    }
                    catch (e: Exception)
                    {
                        if (e is CancellationException) throw e
                        Log.e(TAG, "Error updating stock for ${receiver::class.java.simpleName}", e)
                    }
                }
                saveCachedRuntimeConfig(
                    CachedRuntimeConfig(
                        posConfig = enrichedPosConfig,
                        merchantConfig = merchantConfig,
                        currency = configResponse.currency,
                        currencySpec = currencySpec,
                    ),
                )
            }
        }
    }

    private fun applyCachedStockData(posConfig: PosConfig): PosConfig
    {
        val cached = cachedRuntimeConfig ?: return posConfig
        val cachedByProductId =
            cached.posConfig.products
                .filter { it.productId != null }
                .associateBy { it.productId }
        var changed = false
        val products =
            posConfig.products.map { product ->
                val cachedProduct =
                    product.productId?.let { cachedByProductId[it] }
                        ?: return@map product
                if (cachedProduct.totalSold == null &&
                    cachedProduct.totalLost == null &&
                    cachedProduct.unitTotalSold == null &&
                    cachedProduct.unitTotalLost == null)
                {
                    return@map product
                }
                changed = true
                product.copy(
                    totalSold = cachedProduct.totalSold,
                    totalLost = cachedProduct.totalLost,
                    unitTotalSold = cachedProduct.unitTotalSold,
                    unitTotalLost = cachedProduct.unitTotalLost,
                )
            }
        return if (changed) posConfig.copy(products = products) else posConfig
    }

    @WorkerThread
    private suspend fun enrichProductsWithStockDetails(
        posConfig: PosConfig,
        merchantConfig: MerchantConfig,
    ): PosConfig
    {
        val enrichedProducts =
            posConfig.products.map { product ->
                val pid = product.productId ?: return@map product
                if (product.stockLimit == null) return@map product
                try
                {
                    val url = merchantConfig.urlFor("private/products/" + pid)
                    val detail: ProductDetail =
                        httpClient
                            .get(url) {
                                configTimeout()
                                header(Authorization, "Bearer " + merchantConfig.apiKey)
                            }.body()
                    product.copy(
                        totalLost = detail.totalLost ?: product.totalLost,
                        totalSold = detail.totalSold ?: product.totalSold,
                        unitTotalLost = detail.unitTotalLost ?: product.unitTotalLost,
                        unitTotalSold = detail.unitTotalSold ?: product.unitTotalSold,
                    )
                }
                catch (e: Exception)
                {
                    if (e is CancellationException) throw e
                    Log.w(TAG, "Failed to fetch stock details for " + pid, e)
                    product
                }
            }
        if (enrichedProducts == posConfig.products) return posConfig
        return posConfig.copy(products = enrichedProducts)
    }

    /**
     * POSTs to /instances/{username}/private/token with the user’s raw secret,
     * returns the new "write" token (without the "secret-token:" prefix).
     */
    @WorkerThread
    suspend fun fetchLimitedAccessToken(
        baseUrl: String,
        username: String,
        initialSecret: String,
        duration: TokenDuration,
        challengeIds: List<String> = emptyList(),
    ): String
    {
        val tokenUrl =
            baseUrl
                .toUri()
                .buildUpon()
                .appendPath("instances")
                .appendPath(username)
                .appendPath("private")
                .appendPath("token")
                .build()
                .toString()

        beforeConnection(baseUrl)
        val bearer = "Bearer secret-token:$initialSecret"
        val response =
            httpClient.post(tokenUrl) {
                configTimeout()
                header(HttpHeaders.Authorization, bearer)
                if (challengeIds.isNotEmpty())
                {
                    header("Taler-Challenge-Ids", challengeIds.joinToString(","))
                }
                contentType(ContentType.Application.Json)
                setBody(TokenRequest(scope = "write", duration = duration))
            }

        if (response.status == HttpStatusCode.Accepted)
        {
            val challenge: ChallengesResponse = response.body()
            throw ChallengeRequiredException(challenge)
        }

        val resp: LimitedTokenResponse = response.body()

        return resp.token
    }

    suspend fun requestChallenge(
        baseUrl: String,
        username: String,
        challengeId: String,
    )
    {
        val challengeUrl =
            baseUrl
                .toUri()
                .buildUpon()
                .appendPath("instances")
                .appendPath(username)
                .appendPath("challenge")
                .appendPath(challengeId)
                .build()
                .toString()

        beforeConnection(baseUrl)
        httpClient.post(challengeUrl) {
            configTimeout()
            contentType(ContentType.Application.Json)
            setBody(buildJsonObject { })
        }
    }

    suspend fun confirmChallenge(
        baseUrl: String,
        username: String,
        challengeId: String,
        tan: String,
    )
    {
        val confirmUrl =
            baseUrl
                .toUri()
                .buildUpon()
                .appendPath("instances")
                .appendPath(username)
                .appendPath("challenge")
                .appendPath(challengeId)
                .appendPath("confirm")
                .build()
                .toString()

        beforeConnection(baseUrl)
        httpClient.post(confirmUrl) {
            configTimeout()
            contentType(ContentType.Application.Json)
            setBody(ChallengeConfirmRequest(tan = tan))
        }
    }

    @UiThread
    fun forgetPassword()
    {
        config =
            when (val c = config)
            {
                // is Config.Old -> c.copy(password = "")
                is Config.New -> c.copy(accessToken = "")
            }
        saveConfig(config)
        clearCachedRuntimeConfig()
        merchantConfig = null
        currency = null
        currencySpec = null
    }

    @UiThread
    fun logout()
    {
        inventoryRefreshJob?.cancel()
        val savePassword = config.savePassword()
        config =
            Config.New(
                merchantUrl = "",
                accessToken = "",
                savePassword = savePassword,
            )
        saveConfig(config)
        clearCachedRuntimeConfig()
        merchantConfig = null
        currency = null
        currencySpec = null
        mConfigUpdateResult.value = null
    }

    @UiThread
    private fun saveConfig(config: Config)
    {
        when (val c = config)
        {
//            is Config.Old -> prefs.edit()
//                .putInt(SETTINGS_CONFIG_VERSION, CONFIG_VERSION_OLD)
//                .putString(SETTINGS_CONFIG_URL, c.configUrl)
//                .putString(SETTINGS_USERNAME, c.username)
//                .putString(SETTINGS_PASSWORD, c.password)
//                .putBoolean(SETTINGS_SAVE_PASSWORD, c.savePassword)
//                .apply()
            is Config.New ->
            {
                prefs
                    .edit()
                    .putInt(SETTINGS_CONFIG_VERSION, CONFIG_VERSION_NEW)
                    .putString(SETTINGS_MERCHANT_URL, c.merchantUrl)
                    .putString(SETTINGS_ACCESS_TOKEN, c.accessToken)
                    .putBoolean(SETTINGS_SAVE_PASSWORD, c.savePassword)
                    .apply()
            }
        }
    }

    private suspend fun onNetworkError(msg: String) =
        withContext(Dispatchers.Main) {
            mConfigUpdateResult.value = ConfigUpdateResult.Error(msg)
        }

    private fun restoreCachedRuntimeConfig()
    {
        if (!config.isValid() ||
            !config.hasPassword())
        {
            clearCachedRuntimeConfig()
            return
        }
        val encoded = prefs.getString(SETTINGS_CACHED_RUNTIME_CONFIG, null) ?: return
        val restored =
            runCatching {
                json.decodeFromString<CachedRuntimeConfig>(encoded)
            }.getOrElse { error ->
                Log.e(TAG, "Failed to restore cached runtime config", error)
                clearCachedRuntimeConfig()
                return
            }
        cachedRuntimeConfig = restored
        merchantConfig = restored.merchantConfig
        currency = restored.currency
        currencySpec = restored.currencySpec
    }

    private fun saveCachedRuntimeConfig(snapshot: CachedRuntimeConfig)
    {
        cachedRuntimeConfig = snapshot
        prefs
            .edit()
            .putString(SETTINGS_CACHED_RUNTIME_CONFIG, json.encodeToString(CachedRuntimeConfig.serializer(), snapshot))
            .apply()
    }

    private fun clearCachedRuntimeConfig()
    {
        cachedRuntimeConfig = null
        prefs.edit().remove(SETTINGS_CACHED_RUNTIME_CONFIG).apply()
    }

    @UiThread
    internal fun handleUnauthorized(requestUrl: String, authorization: String?)
    {
        val merchant = merchantConfig ?: return
        val privateUrl = merchant.urlFor("private").trimEnd('/') + "/"
        // Login, MFA and token-prefix probes share the HTTP client with the
        // active merchant. Their failures must not invalidate its session.
        if (!requestUrl.startsWith(privateUrl) ||
            requestUrl.substringBefore('?') == privateUrl + "token" ||
            authorization != "Bearer ${merchant.apiKey}")
        {
            return
        }
        forgetPassword()
        _sessionExpired.value = Event(Unit)
    }
}

sealed class ConfigUpdateResult
{
    data class Error(
        val msg: String,
        val returnToConfig: Boolean = false,
    ) : ConfigUpdateResult()

    data class Success(
        val currency: String,
    ) : ConfigUpdateResult()
}