PaymentOptionsManager.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

import android.content.Context
import android.net.Uri
import android.os.Looper
import android.util.Log

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData

import kotlinx.coroutines.CancellationException

data class PaymentOptionsSummary(
    val options: List<PaymentOptionState> = emptyList(),
)
{
    val enabled: List<PaymentOptionState> get() = options.filter { it.enabled }

    val enabledCount: Int get() = enabled.size
}

/**
 * Owns the per-option switches and the polled state of each option in [options].
 *
 * Essential options stay enabled; others are reached while their switch is on.
 * Turning an optional method off keeps its
 * stored settings and any session, so that re-enabling does not require setting it
 * up again; only [logout] clears sessions.
 */
class PaymentOptionsManager(
    context: Context,
    val options: List<PaymentOption>,
)
{
    private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
    private val mutableSummary = MutableLiveData(PaymentOptionsSummary())
    private val mutableActionFailed = MutableLiveData(false)

    val summary: LiveData<PaymentOptionsSummary> = mutableSummary
    val actionFailed: LiveData<Boolean> = mutableActionFailed

    /** Card providers and enabled options that currently report payment devices. */
    fun connectionStates(summary: PaymentOptionsSummary): List<Pair<PaymentOption, PaymentOptionState>> =
        options.mapNotNull { option ->
            summary.options
                .firstOrNull { it.id == option.id }
                ?.takeIf { it.enabled && (option.tracksConnection || it.devices.isNotEmpty()) }
                ?.let { option to it }
        }

    init
    {
        // Defer SDK and Bluetooth connection checks until the first poll.
        publish(options.map { PaymentOptionState(it.id, enabled = isEnabled(it), status = setupStatus(it)) })
    }

    private fun setupStatus(option: PaymentOption): PaymentOptionStatus =
        try
        {
            if (option.isConfigured()) PaymentOptionStatus.Disabled else PaymentOptionStatus.NotConfigured
        }
        catch (_: RuntimeException)
        {
            PaymentOptionStatus.Unavailable
        }

    fun option(id: String): PaymentOption? = options.firstOrNull { it.id == id }

    fun state(id: String): PaymentOptionState =
        mutableSummary.value?.options?.firstOrNull { it.id == id } ?: PaymentOptionState(id)

    fun isEnabled(option: PaymentOption): Boolean =
        option.alwaysEnabled || preferences.getBoolean(optionKey(option.id), option.enabledByDefault)

    fun setEnabled(
        id: String,
        enabled: Boolean,
    )
    {
        val option = option(id) ?: return
        preferences.edit().putBoolean(optionKey(id), option.alwaysEnabled || enabled).apply()
        dismissError()
        refresh()
    }

    fun refresh()
    {
        val states =
            options.map { option ->
                val enabled = isEnabled(option)
                try
                {
                    if (!enabled)
                    {
                        // Setup can change while an option is off. Publish that
                        // change so its enable switch updates without probing a reader.
                        PaymentOptionState(
                            option.id,
                            enabled = false,
                            status = setupStatus(option),
                        )
                    }
                    else
                    {
                        option.probe().copy(id = option.id, enabled = true)
                    }
                }
                catch (_: RuntimeException)
                {
                    PaymentOptionState(option.id, enabled = enabled, status = PaymentOptionStatus.Unavailable)
                }
            }
        publish(states)
    }

    private fun publish(states: List<PaymentOptionState>)
    {
        val updated = PaymentOptionsSummary(states)
        if (mutableSummary.value == updated) return
        logTransitions(mutableSummary.value, updated)
        // Probing talks to payment SDKs, so it is polled off the main thread; only the
        // one-off calls from the settings screens arrive on it.
        if (Looper.myLooper() == Looper.getMainLooper())
        {
            mutableSummary.value = updated
        }
        else
        {
            mutableSummary.postValue(updated)
        }
    }

    /**
     * Reports terminal status, wooooow :)
     */
    fun terminalStatus(summary: PaymentOptionsSummary): PaymentOptionStatus =
        connectionStates(summary).maxOfOrNull { (option, state) ->
            if (option.tracksConnection)
            {
                state.status
            }
            else if (state.devices.any { it.connected })
            {
                PaymentOptionStatus.Connected
            }
            else
            {
                PaymentOptionStatus.Disconnected
            }
        } ?: PaymentOptionStatus.Disabled

    fun hasEnabledTerminal(summary: PaymentOptionsSummary): Boolean = connectionStates(summary).isNotEmpty()

    /** Renews any refreshable session the enabled options hold, then re-reads status. */
    suspend fun maintainSessions()
    {
        options.forEach { option ->
            if (!isEnabled(option)) return@forEach
            try
            {
                option.maintainSession()
            }
            catch (e: CancellationException)
            {
                throw e
            }
            catch (_: Exception)
            {
                // An option that cannot renew its session must not stop the others.
            }
        }
        refresh()
    }

    /** Offers a deep link to each option; true once one has taken it. */
    suspend fun handleRedirect(uri: Uri): Boolean
    {
        options.forEach { option ->
            val consumed =
                try
                {
                    option.handleRedirect(uri)
                }
                catch (e: CancellationException)
                {
                    throw e
                }
                catch (_: Exception)
                {
                    false
                }
            if (consumed)
            {
                refresh()
                return true
            }
        }
        return false
    }

    /**
     * One line per status change, so that a session lapsing can be placed on a
     * timeline afterwards instead of guessed at. Never logs tokens or account data.
     */
    private fun logTransitions(
        old: PaymentOptionsSummary?,
        new: PaymentOptionsSummary,
    )
    {
        new.options.forEach { state ->
            val before = old?.options?.firstOrNull { it.id == state.id }?.status
            if (before != state.status)
            {
                Log.d(LOG_TAG, "${state.id}: $before → ${state.status} (enabled=${state.enabled})")
            }
        }
    }

    fun reportActionFailure()
    {
        mutableActionFailed.value = true
        refresh()
    }

    fun dismissError()
    {
        if (mutableActionFailed.value != false) mutableActionFailed.value = false
    }

    /** Merchant logout: resets every switch to its default and drops every session. */
    fun logout()
    {
        preferences.edit().clear().apply()
        options.forEach { option ->
            try
            {
                option.onLogout()
            }
            catch (_: RuntimeException)
            {
                // An option that cannot clear its session must not block merchant logout.
            }
        }
        dismissError()
        refresh()
    }

    private companion object
    {
        const val LOG_TAG = "taler-pos-payments"
        const val PREFERENCES = "payment-terminals"

        fun optionKey(id: String) = "option.$id.enabled"
    }
}