SumUpOption.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.paymentoptions.sumup

import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build

import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CreditCard
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner

import com.sumup.merchant.reader.api.SumUpAPI
import com.sumup.merchant.reader.api.SumUpCardReaderPageContract
import com.sumup.merchant.reader.api.SumUpCheckoutContract
import com.sumup.merchant.reader.api.SumUpLogin
import com.sumup.merchant.reader.api.SumUpLoginContract
import com.sumup.merchant.reader.api.SumUpPayment
import com.sumup.merchant.reader.models.SavedCardReaderDetailsResult.SavedCardReaderDetails

import net.taler.common.Amount
import net.taler.merchantpos.BuildConfig
import net.taler.merchantpos.R
import net.taler.merchantpos.paymentoptions.ChargeResult
import net.taler.merchantpos.paymentoptions.ExternalCharge
import net.taler.merchantpos.paymentoptions.PaymentDevice
import net.taler.merchantpos.paymentoptions.PaymentOption
import net.taler.merchantpos.paymentoptions.PaymentOptionActions
import net.taler.merchantpos.paymentoptions.PaymentOptionState
import net.taler.merchantpos.paymentoptions.PaymentOptionStatus
import net.taler.merchantpos.paymentoptions.PaymentOptionsManager
import net.taler.merchantpos.paymentoptions.RefundOutcome

import java.math.BigDecimal
import java.math.RoundingMode
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale

import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

internal interface ReaderConnection
{
    /** Whether the reader SDK has a signed-in merchant session. */
    fun isLoggedIn(): Boolean

    /** Merchant code of the SumUp account signed in on this device, if any. */
    fun merchant(): String?

    /** Hands the SDK a freshly minted access token for the session it already holds. */
    fun updateAccessToken(token: String)

    /** Saved readers with their last known device details and current connection state. */
    fun devices(): List<PaymentDevice>

    /** Clears the reader settings and signs out of the SDK. */
    fun logout()
}

/**
 * Per-installation SumUp settings.
 *
 * The affiliate key identifies the *application* to SumUp, not the merchant: every
 * merchant signs into their own SumUp account on their own device. A key built into
 * the app is therefore the normal case, but a merchant running their own SumUp
 * partner registration can override it here, and a build that ships without a key
 * stays usable.
 */
internal class SumUpSettings(
    context: Context,
    /** Key this build ships with; injected so behaviour does not depend on the build environment. */
    private val buildKey: String = BuildConfig.SUMUP_AFFILIATE_KEY,
)
{
    private val preferences = context.getSharedPreferences("payment-terminals", Context.MODE_PRIVATE)

    fun affiliateKey(): String = storedKey() ?: buildKey

    fun hasCustomKey(): Boolean = storedKey() != null

    fun setAffiliateKey(key: String)
    {
        val trimmed = key.trim()
        if (trimmed.isEmpty())
        {
            preferences.edit().remove(KEY_AFFILIATE).apply()
        }
        else
        {
            preferences.edit().putString(KEY_AFFILIATE, trimmed).apply()
        }
    }

    private fun storedKey() = preferences.getString(KEY_AFFILIATE, null)?.takeIf { it.isNotBlank() }

    private companion object
    {
        const val KEY_AFFILIATE = "provider.sumup.affiliate-key"
    }
}

private class SumUpConnection : ReaderConnection
{
    override fun isLoggedIn(): Boolean = SumUpAPI.isLoggedIn()

    override fun merchant(): String? = SumUpAPI.getCurrentMerchant()?.merchantCode

    override fun updateAccessToken(token: String) = SumUpAPI.updateAccessToken(token)

    override fun devices(): List<PaymentDevice>
    {
        val reader = SumUpAPI.getSavedCardReaderDetails() as? SavedCardReaderDetails ?: return emptyList()
        val serial = reader.serialNumber
        return listOf(
            PaymentDevice(
                id = serial ?: SUMUP_ID,
                name = reader.readerType?.name?.replace('_', ' '),
                serialNumber = serial,
                batteryPercent = reader.lastKnownBatteryPercentage,
                connected = SumUpAPI.isCardReaderConnected(),
            ),
        )
    }

    override fun logout()
    {
        SumUpAPI.clearCardTerminalSettings()
        SumUpAPI.logout()
    }
}

internal const val SUMUP_ID = "sumup"

class SumUpOption internal constructor(
    private val context: Context,
    private val connection: ReaderConnection,
    private val settings: SumUpSettings,
    internal val auth: SumUpAuth,
    private val rest: SumUpRestApi = SumUpHttpRestApi(),
) : PaymentOption
{
    constructor(context: Context) : this(
        context.applicationContext,
        SumUpConnection(),
        SumUpSettings(context.applicationContext),
        SumUpAuth(context.applicationContext),
    )

    private var transparentLoginTried = false
    private var host: Host? = null
    private var pendingCharge: ((ChargeResult) -> Unit)? = null

    override val id = SUMUP_ID
    override val nameRes = R.string.terminal_sumup
    override val descriptionRes = R.string.terminal_sumup_description
    override val hasSettings = true
    override val tracksConnection = true
    override val chargesExternally = true
    override val activationNoticeRes = R.string.terminal_sumup_activation_notice

    /** Refunds require the REST API and the separately approved `payments` scope. */
    override val canRefund = false

    override val icon: ImageVector
        @Composable get() = Icons.Default.CreditCard

    override fun isConfigured() = settings.affiliateKey().isNotBlank()

    override fun probe(): PaymentOptionState
    {
        if (!isConfigured()) return PaymentOptionState(id, status = PaymentOptionStatus.NotConfigured)
        if (!connection.isLoggedIn()) return PaymentOptionState(id, status = PaymentOptionStatus.SignInRequired)
        val account = connection.merchant()
        if (!hasBluetoothPermission())
        {
            return PaymentOptionState(id, status = PaymentOptionStatus.PermissionRequired, account = account)
        }
        val devices = connection.devices()
        return PaymentOptionState(
            id = id,
            status =
                when
                {
                    devices.any { it.connected } -> PaymentOptionStatus.Connected
                    devices.isEmpty() -> PaymentOptionStatus.NotPaired
                    else -> PaymentOptionStatus.Disconnected
                },
            account = account,
            devices = devices,
        )
    }

    private fun hasBluetoothPermission(): Boolean =
        Build.VERSION.SDK_INT < 31 || ContextCompat.checkSelfPermission(
            context,
            Manifest.permission.BLUETOOTH_CONNECT,
        ) == PackageManager.PERMISSION_GRANTED

    /**
     * Renews the access token before it expires and updates the SDK session.
     * Revoked grants still require the merchant to authorize again.
     */
    override suspend fun maintainSession()
    {
        if (!isConfigured() ||
            !auth.isAuthorized())
        {
            return
        }
        auth.refreshIfNeeded()
        val token = auth.validAccessToken() ?: return
        if (connection.isLoggedIn())
        {
            // Already signed in: swapping the token in place keeps the session alive.
            runCatching { connection.updateAccessToken(token) }
        }
        else
        {
            // Signed out: SumUp accepts the token instead of credentials, but that is
            // an activity launch, so only try it once per activity.
            requestTransparentLogin(token)
        }
    }

    override suspend fun handleRedirect(uri: Uri): Boolean
    {
        if (uri.scheme != "taler-pos" ||
            uri.host != SUMUP_REDIRECT_HOST)
        {
            return false
        }
        if (!auth.consumeAuthorizationState(uri.getQueryParameter("state"))) return true
        val code = uri.getQueryParameter("code")
        if (code.isNullOrBlank()) return true
        auth.exchangeCode(code)
        auth.validAccessToken()?.let { token ->
            if (!connection.isLoggedIn())
            {
                transparentLoginTried = false
                requestTransparentLogin(token)
            }
        }
        return true
    }

    private fun requestTransparentLogin(token: String)
    {
        if (transparentLoginTried) return
        val host = host ?: return
        transparentLoginTried = true
        runCatching { host.openLogin(token) }
    }

    override fun onLogout()
    {
        auth.clearAll()
        connection.logout()
    }

    // Activity result plumbing, rebound on every activity creation.

    private class Host(
        val activity: ComponentActivity,
        val manager: PaymentOptionsManager,
        val openReaderPage: () -> Unit,
        val openLogin: (String?) -> Unit,
        val openCheckout: (SumUpPayment) -> Unit,
    )

    override fun registerLaunchers(
        activity: ComponentActivity,
        manager: PaymentOptionsManager,
    )
    {
        val readerLauncher =
            activity.registerForActivityResult(SumUpCardReaderPageContract()) { result ->
                val code = result.data?.getIntExtra(SumUpAPI.Response.RESULT_CODE, 0) ?: 0
                if (code != 0 &&
                    code != SumUpAPI.Response.ResultCode.SUCCESSFUL)
                {
                    manager.reportActionFailure()
                }
                manager.refresh()
            }
        val loginLauncher =
            activity.registerForActivityResult(SumUpLoginContract()) { result ->
                val code = result.data?.getIntExtra(SumUpAPI.Response.RESULT_CODE, 0) ?: 0
                if (code == SumUpAPI.Response.ResultCode.SUCCESSFUL)
                {
                    launchSetup()
                }
                else if (code != 0)
                {
                    manager.reportActionFailure()
                }
                manager.refresh()
            }
        val checkoutLauncher =
            activity.registerForActivityResult(SumUpCheckoutContract()) { result ->
                val data = result.data
                val outcome =
                    when (data?.getIntExtra(SumUpAPI.Response.RESULT_CODE, 0) ?: 0)
                    {
                        SumUpAPI.Response.ResultCode.SUCCESSFUL ->
                        {
                            ChargeResult.Success(data?.getStringExtra(SumUpAPI.Response.TX_CODE))
                        }

                        // Neither can be booked: the reference may or may not have been charged.
                        SumUpAPI.Response.ResultCode.ERROR_UNKNOWN_TRANSACTION_STATUS,
                        SumUpAPI.Response.ResultCode.ERROR_DUPLICATE_FOREIGN_TX_ID,
                            ->
                        {
                            ChargeResult.Unknown(data?.getStringExtra(SumUpAPI.Response.MESSAGE))
                        }

                        0 ->
                        {
                            ChargeResult.Cancelled
                        }

                        else ->
                        {
                            ChargeResult.Failed(data?.getStringExtra(SumUpAPI.Response.MESSAGE))
                        }
                    }
                val pending = pendingCharge
                pendingCharge = null
                pending?.invoke(outcome)
                manager.refresh()
            }
        transparentLoginTried = false
        host =
            Host(
                activity = activity,
                manager = manager,
                openReaderPage = { readerLauncher.launch(null) },
                openLogin = { token ->
                    val login =
                        SumUpLogin
                            .builder(settings.affiliateKey())
                            .let { if (token != null) it.accessToken(token) else it }
                            .build()
                    loginLauncher.launch(login)
                },
                openCheckout = { payment -> checkoutLauncher.launch(payment) },
            )
        activity.lifecycle.addObserver(
            object : DefaultLifecycleObserver
            {
                override fun onDestroy(owner: LifecycleOwner)
                {
                    if (host?.activity === activity) host = null
                }
            },
        )
    }

    /**
     * Refunds through SumUp's REST API: the reader SDK cannot do it. That needs an
     * authorised device, since SumUp refuses refunds on client credentials tokens.
     */
    override suspend fun refund(
        transactionCode: String?,
        amount: Amount,
        reason: String,
    ): RefundOutcome
    {
        if (transactionCode.isNullOrBlank())
        {
            return RefundOutcome.Failed("This payment has no SumUp transaction to refund")
        }
        auth.refreshIfNeeded()
        val token =
            auth.validAccessToken()
                ?: return RefundOutcome.Failed("Authorise this device with SumUp before refunding")
        val merchantCode =
            runCatching { connection.merchant() }.getOrNull()
                ?: return RefundOutcome.Failed("Sign in to SumUp before refunding")
        val transactionId =
            when (val found = rest.transactionId(merchantCode, transactionCode, token))
            {
                is RestResult.Success -> found.value
                is RestResult.Failure -> return RefundOutcome.Failed(found.message)
            }
        return when (val refunded = rest.refund(merchantCode, transactionId, amount, token))
        {
            is RestResult.Success -> RefundOutcome.Success
            is RestResult.Failure -> RefundOutcome.Failed(refunded.message)
        }
    }

    override fun startCharge(
        charge: ExternalCharge,
        onResult: (ChargeResult) -> Unit,
    ): Boolean
    {
        val host = host ?: return false
        if (pendingCharge != null ||
            !isConfigured())
        {
            return false
        }
        val currency =
            runCatching { SumUpPayment.Currency.valueOf(charge.amount.currency) }.getOrNull()
                ?: return false
        // SumUp refuses more than two decimals; a finer amount cannot be charged at all.
        val total =
            runCatching {
                BigDecimal
                    .valueOf(charge.amount.value)
                    .add(BigDecimal.valueOf(charge.amount.fraction.toLong(), 8))
                    .setScale(2, RoundingMode.UNNECESSARY)
            }.getOrNull() ?: return false
        if (!runCatching { connection.isLoggedIn() }.getOrDefault(false)) return false
        val payment =
            SumUpPayment
                .builder()
                .total(total)
                .currency(currency)
                .title(charge.title)
                .foreignTransactionId(charge.reference)
                .skipSuccessScreen()
                .build()
        pendingCharge = onResult
        return try
        {
            host.openCheckout(payment)
            true
        }
        catch (_: RuntimeException)
        {
            pendingCharge = null
            false
        }
    }

    /** Signs in when needed, then opens SumUp's own reader setup. */
    fun launchSetup()
    {
        val host = host ?: return
        host.manager.refresh()
        if (!host.manager.state(id).enabled ||
            !isConfigured())
        {
            return
        }
        host.manager.dismissError()
        try
        {
            if (connection.isLoggedIn())
            {
                host.openReaderPage()
            }
            else
            {
                host.openLogin(auth.validAccessToken())
            }
        }
        catch (_: RuntimeException)
        {
            host.manager.reportActionFailure()
        }
    }

    @Composable
    override fun DetailContent(
        state: PaymentOptionState,
        actions: PaymentOptionActions,
    )
    {
        Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
            if (state.enabled &&
                state.status != PaymentOptionStatus.NotConfigured)
            {
                Button(
                    onClick = ::launchSetup,
                    modifier = Modifier.fillMaxWidth(),
                    shape = RoundedCornerShape(14.dp),
                    colors =
                        ButtonDefaults.buttonColors(
                            containerColor = MaterialTheme.colorScheme.primaryContainer,
                            contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
                        ),
                ) {
                    Text(
                        stringResource(
                            when (state.status)
                            {
                                PaymentOptionStatus.SignInRequired ->
                                {
                                    R.string.terminal_sumup_sign_in
                                }

                                else ->
                                {
                                    if (state.devices.isEmpty())
                                    {
                                        R.string.terminal_pair
                                    }
                                    else
                                    {
                                        R.string.terminal_manage
                                    }
                                }
                            },
                        ),
                    )
                }
                if (state.status != PaymentOptionStatus.SignInRequired)
                {
                    OutlinedButton(
                        onClick = {
                            signOut(actions)
                            actions.refresh()
                        },
                        modifier = Modifier.fillMaxWidth(),
                        shape = RoundedCornerShape(14.dp),
                    ) {
                        Text(stringResource(R.string.terminal_sumup_sign_out))
                    }
                }
            }
            UnattendedSession(actions)
            AdvancedSettings(actions)
        }
    }

    /** Displays token expiry and refresh status. */
    @Composable
    private fun UnattendedSession(actions: PaymentOptionActions)
    {
        val authState by auth.state.observeAsState(SumUpAuthState())
        val scope = rememberCoroutineScope()
        val context = LocalContext.current
        var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
        LaunchedEffect(Unit) {
            while (true)
            {
                now = System.currentTimeMillis()
                delay(1_000)
            }
        }

        Text(
            text = stringResource(R.string.terminal_sumup_session_help),
            style = MaterialTheme.typography.bodyMedium,
            color = MaterialTheme.colorScheme.onSurfaceVariant,
        )

        if (!authState.hasClientCredentials)
        {
            Text(
                text = stringResource(R.string.terminal_sumup_session_no_client),
                style = MaterialTheme.typography.bodyMedium,
            )
            return
        }

        if (!authState.authorized)
        {
            Text(
                text = stringResource(R.string.terminal_sumup_session_not_authorized),
                style = MaterialTheme.typography.bodyMedium,
            )
        }
        else
        {
            val remaining = ((authState.expiresAt - now) / 1000).coerceAtLeast(0)
            Text(
                text =
                    stringResource(
                        R.string.terminal_sumup_session_expires,
                        "%d:%02d".format(remaining / 60, remaining % 60),
                    ),
                style = MaterialTheme.typography.bodyMedium,
            )
            Text(
                text =
                    stringResource(
                        R.string.terminal_sumup_session_refreshes,
                        authState.refreshCount,
                        if (authState.lastRefresh == 0L) "-" else timeOfDay(authState.lastRefresh),
                    ),
                style = MaterialTheme.typography.bodyMedium,
            )
        }
        authState.lastError?.let {
            Text(
                text = stringResource(R.string.terminal_sumup_session_error, it),
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.error,
            )
        }

        Button(
            onClick = {
                context.startActivity(
                    Intent(Intent.ACTION_VIEW, Uri.parse(auth.authorizeUrl())),
                )
            },
            modifier = Modifier.fillMaxWidth(),
            shape = RoundedCornerShape(14.dp),
            colors =
                ButtonDefaults.buttonColors(
                    containerColor = MaterialTheme.colorScheme.primaryContainer,
                    contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
                ),
        ) {
            Text(
                stringResource(
                    if (authState.authorized)
                    {
                        R.string.terminal_sumup_reauthorize
                    }
                    else
                    {
                        R.string.terminal_sumup_authorize
                    },
                ),
            )
        }

        if (authState.authorized)
        {
            // Forces the refresh the app would otherwise do on its own, so the counter
            // and the countdown above can be watched moving without waiting.
            OutlinedButton(
                onClick = {
                    scope.launch {
                        auth.refreshIfNeeded(force = true)
                        auth.validAccessToken()?.let { token ->
                            runCatching { connection.updateAccessToken(token) }
                        }
                        actions.refresh()
                    }
                },
                modifier = Modifier.fillMaxWidth(),
                shape = RoundedCornerShape(14.dp),
            ) {
                Text(stringResource(R.string.terminal_sumup_refresh_now))
            }
            OutlinedButton(
                onClick = {
                    auth.clearAuthorization()
                    actions.refresh()
                },
                modifier = Modifier.fillMaxWidth(),
                shape = RoundedCornerShape(14.dp),
            ) {
                Text(stringResource(R.string.terminal_sumup_revoke))
            }
        }
    }

    private fun timeOfDay(millis: Long): String = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(millis))

    /**
     * The affiliate key identifies the app rather than the merchant, and the OAuth
     * client belongs to the merchant. Neither is ever displayed: this only allows
     * replacing them.
     */
    @Composable
    private fun AdvancedSettings(actions: PaymentOptionActions)
    {
        var expanded by rememberSaveable { mutableStateOf(false) }
        var key by rememberSaveable { mutableStateOf("") }
        var revealed by rememberSaveable { mutableStateOf(false) }
        // Held as state: refreshing may leave the option's status unchanged, which
        // would not on its own recompose this section after the key is replaced.
        var custom by rememberSaveable { mutableStateOf(settings.hasCustomKey()) }

        TextButton(
            onClick = { expanded = !expanded },
            modifier = Modifier.fillMaxWidth(),
        ) {
            Icon(
                imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
                contentDescription = null,
            )
            Spacer(Modifier.width(8.dp))
            Text(stringResource(R.string.terminal_sumup_advanced), modifier = Modifier.weight(1f))
        }
        if (!expanded) return

        Text(
            text = stringResource(R.string.terminal_sumup_advanced_help),
            style = MaterialTheme.typography.bodyMedium,
            color = MaterialTheme.colorScheme.onSurfaceVariant,
        )
        Text(
            text =
                stringResource(
                    if (custom)
                    {
                        R.string.terminal_sumup_key_custom
                    }
                    else
                    {
                        R.string.terminal_sumup_key_builtin
                    },
                ),
            style = MaterialTheme.typography.bodyMedium,
        )
        OutlinedTextField(
            value = key,
            onValueChange = { key = it },
            modifier = Modifier.fillMaxWidth(),
            singleLine = true,
            shape = RoundedCornerShape(14.dp),
            label = { Text(stringResource(R.string.terminal_sumup_key_hint)) },
            visualTransformation =
                if (revealed) VisualTransformation.None else PasswordVisualTransformation(),
            keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
            trailingIcon = {
                IconButton(onClick = { revealed = !revealed }) {
                    Icon(
                        imageVector = if (revealed) Icons.Default.VisibilityOff else Icons.Default.Visibility,
                        contentDescription =
                            stringResource(
                                if (revealed)
                                {
                                    R.string.terminal_sumup_key_hide
                                }
                                else
                                {
                                    R.string.terminal_sumup_key_show
                                },
                            ),
                    )
                }
            },
        )
        Text(
            text = stringResource(R.string.terminal_sumup_key_warning),
            style = MaterialTheme.typography.bodySmall,
            color = MaterialTheme.colorScheme.onSurfaceVariant,
        )
        OutlinedButton(
            onClick = {
                // The session belongs to the previous application identity, so drop it.
                signOut(actions)
                settings.setAffiliateKey(key)
                custom = settings.hasCustomKey()
                key = ""
                revealed = false
                actions.refresh()
            },
            enabled = key.isNotBlank(),
            modifier = Modifier.fillMaxWidth(),
            shape = RoundedCornerShape(14.dp),
        ) {
            Text(stringResource(R.string.terminal_sumup_save_key))
        }
        if (custom)
        {
            OutlinedButton(
                onClick = {
                    signOut(actions)
                    settings.setAffiliateKey("")
                    custom = false
                    key = ""
                    actions.refresh()
                },
                modifier = Modifier.fillMaxWidth(),
                shape = RoundedCornerShape(14.dp),
            ) {
                Text(stringResource(R.string.terminal_sumup_reset_key))
            }
        }

        OAuthClientFields()
    }

    /**
     * The merchant's own OAuth application, registered in their SumUp dashboard. Its
     * secret is what lets this installation renew the session without a person.
     */
    @Composable
    private fun OAuthClientFields()
    {
        var clientId by rememberSaveable { mutableStateOf(auth.clientId) }
        var clientSecret by rememberSaveable { mutableStateOf("") }
        var saved by rememberSaveable { mutableStateOf(auth.hasClientCredentials()) }

        Text(
            text = stringResource(R.string.terminal_sumup_oauth_help, SUMUP_REDIRECT_URI),
            style = MaterialTheme.typography.bodyMedium,
            color = MaterialTheme.colorScheme.onSurfaceVariant,
        )
        Text(
            text =
                stringResource(
                    if (saved) R.string.terminal_sumup_oauth_set else R.string.terminal_sumup_oauth_unset,
                ),
            style = MaterialTheme.typography.bodyMedium,
        )
        OutlinedTextField(
            value = clientId,
            onValueChange = { clientId = it },
            modifier = Modifier.fillMaxWidth(),
            singleLine = true,
            shape = RoundedCornerShape(14.dp),
            label = { Text(stringResource(R.string.terminal_sumup_client_id)) },
        )
        OutlinedTextField(
            value = clientSecret,
            onValueChange = { clientSecret = it },
            modifier = Modifier.fillMaxWidth(),
            singleLine = true,
            shape = RoundedCornerShape(14.dp),
            label = { Text(stringResource(R.string.terminal_sumup_client_secret)) },
            visualTransformation = PasswordVisualTransformation(),
            keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
        )
        OutlinedButton(
            onClick = {
                auth.setClientCredentials(clientId, clientSecret)
                clientSecret = ""
                saved = auth.hasClientCredentials()
            },
            enabled = clientId.isNotBlank() && clientSecret.isNotBlank(),
            modifier = Modifier.fillMaxWidth(),
            shape = RoundedCornerShape(14.dp),
        ) {
            Text(stringResource(R.string.terminal_sumup_save_client))
        }
    }

    private fun signOut(actions: PaymentOptionActions)
    {
        try
        {
            connection.logout()
        }
        catch (_: RuntimeException)
        {
            actions.reportFailure()
        }
    }
}