HistoryManager.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.history

import androidx.annotation.StringRes
import androidx.annotation.UiThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData

import net.taler.lib.android.assertUiThread
import net.taler.merchantlib.CheckPaymentResponse
import net.taler.merchantlib.MerchantApi
import net.taler.merchantlib.MerchantUnit
import net.taler.merchantlib.OrderHistoryEntry
import net.taler.merchantpos.R
import net.taler.merchantpos.config.ConfigManager

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch

sealed class HistoryResult
{
    class Success(
        val items: List<OrderHistoryEntry>,
    ) : HistoryResult()
}

internal sealed interface OrderDetailsResult
{
    data object Loading : OrderDetailsResult

    data class Success(
        val response: CheckPaymentResponse,
    ) : OrderDetailsResult

    data class Error(
        val message: String? = null,
    ) : OrderDetailsResult
}

class HistoryError(
    @StringRes val mainResId: Int,
    val msg: String,
)

class HistoryManager(
    private val configManager: ConfigManager,
    private val scope: CoroutineScope,
    private val api: MerchantApi,
)
{
    companion object
    {
        private const val PAGE_SIZE = 20
        private const val LOAD_MORE_THRESHOLD = 5
    }

    private val pagination = HistoryPagination()
    private var historyLoadJob: Job? = null
    private var historyLoadGeneration = 0
    private val mIsLoading = MutableLiveData(false)
    private val mIsLoadingMore = MutableLiveData(false)
    private val mItems = MutableLiveData<HistoryResult>()
    private val mError = MutableLiveData<HistoryError?>(null)
    private val mForceDeleteOrderId = MutableLiveData<String?>(null)

    val isLoading: LiveData<Boolean> = mIsLoading
    val isLoadingMore: LiveData<Boolean> = mIsLoadingMore
    val items: LiveData<HistoryResult> = mItems
    val error: LiveData<HistoryError?> = mError
    val forceDeleteOrderId: LiveData<String?> = mForceDeleteOrderId

    internal suspend fun fetchUnits(): Map<String, MerchantUnit>
    {
        val config = configManager.merchantConfig ?: return emptyMap()
        var units = emptyMap<String, MerchantUnit>()
        // Older backends and restricted tokens may not expose the catalogue.
        api.getUnits(config).handle(onSuccess = { units = it.units.associateBy(MerchantUnit::unit) })
        return units
    }

    /** Called by the details composition, so leaving the page cancels the request. */
    internal suspend fun fetchOrderDetails(orderId: String): OrderDetailsResult
    {
        val config = configManager.merchantConfig ?: return OrderDetailsResult.Error()
        var result: OrderDetailsResult = OrderDetailsResult.Error()
        api.checkOrder(config, orderId).handle(
            { result = OrderDetailsResult.Error(it) },
        ) { response ->
            updateItem(orderId) { it.withPaymentDetails(response) }
            result = OrderDetailsResult.Success(response)
        }
        return result
    }

    @UiThread
    internal fun fetchHistory(onFinished: (() -> Unit)? = null)
    {
        startHistoryLoad(reset = true, onFinished = onFinished)
    }

    @UiThread
    internal fun loadMoreHistoryIfNeeded(lastVisibleIndex: Int)
    {
        val currentItems = (mItems.value as? HistoryResult.Success)?.items ?: return
        if (mIsLoading.value == true ||
            mIsLoadingMore.value == true ||
            pagination.reachedEnd)
        {
            return
        }
        if (currentItems.isEmpty()) return
        if (lastVisibleIndex < currentItems.lastIndex - LOAD_MORE_THRESHOLD) return
        startHistoryLoad(reset = false)
    }

    @UiThread
    private fun startHistoryLoad(
        reset: Boolean,
        onFinished: (() -> Unit)? = null,
    )
    {
        if (!reset &&
            historyLoadJob?.isActive == true)
        {
            return
        }

        val generation = ++historyLoadGeneration
        historyLoadJob?.cancel()
        historyLoadJob =
            scope.launch {
                val succeeded = fetchHistoryPages(reset, generation)
                if (succeeded &&
                    generation == historyLoadGeneration)
                {
                    onFinished?.invoke()
                }
            }
    }

    /**
     * Publishes the first page immediately, then keeps loading the remaining
     * history sequentially. This lets timestamp sorting cover the complete
     * history without requiring the user to scroll through every page first.
     */
    private suspend fun fetchHistoryPages(
        reset: Boolean,
        generation: Int,
    ): Boolean
    {
        if (reset)
        {
            mIsLoading.value = true
            mIsLoadingMore.value = false
        }
        else
        {
            mIsLoadingMore.value = true
        }
        val merchantConfig = configManager.merchantConfig!!

        var resetPage = reset
        while (true)
        {
            val offset = if (resetPage) null else pagination.nextOffset
            val response =
                api.getOrderHistory(
                    merchantConfig = merchantConfig,
                    limit = -PAGE_SIZE,
                    offset = offset,
                )

            // A refresh may have superseded this request while it was in flight.
            if (generation != historyLoadGeneration) return false

            var succeeded = false
            response.handle({ onError(R.string.error_history, it) }) { history ->
                assertUiThread()
                succeeded = true
                val newItems = pagination.addPage(history.orders, resetPage)
                val hasMorePages = !pagination.reachedEnd
                mIsLoading.value = false
                mIsLoadingMore.value = hasMorePages
                publishItems()
                enrichOrders(newItems, generation)
            }

            if (!succeeded) return false
            if (pagination.reachedEnd) return true
            resetPage = false
        }
    }

    /** Called only after both confirmations. A paid order must never be deleted. */
    internal suspend fun deleteConfirmedOrder(item: OrderHistoryEntry): String?
    {
        val config = configManager.merchantConfig ?: return "Merchant is not configured"
        var current: CheckPaymentResponse? = null
        var error: String? = null
        api.checkOrder(config, item.orderId).handle({ error = it }) { current = it }
        if (error != null) return error
        val checked = current ?: return "Could not verify order status"
        if (checked.paid) return "This order has been paid and cannot be deleted"
        val force =
            checked is CheckPaymentResponse.Claimed ||
                (checked.paymentDetails?.amountExternal?.isNotEmpty() ?: item.hasExternalPayments)
        api.deleteOrder(config, item.orderId, force = force).handle({ error = it }) {
            configManager.refreshInventory()
            fetchHistory()
        }
        return error
    }

    @UiThread
    internal fun deleteOrder(orderId: String) =
        scope.launch {
            mIsLoading.value = true
            mIsLoadingMore.value = false
            val merchantConfig = configManager.merchantConfig!!
            api.deleteOrder(merchantConfig, orderId).handle(
                { _ ->
                    assertUiThread()
                    mIsLoading.value = false
                    mForceDeleteOrderId.value = orderId
                }
            ) {
                assertUiThread()
                configManager.refreshInventory()
                fetchHistory()
            }
        }

    @UiThread
    internal fun forceDeleteOrder(orderId: String) =
        scope.launch {
            mForceDeleteOrderId.postValue(null)
            mIsLoading.value = true
            val merchantConfig = configManager.merchantConfig!!
            api.deleteOrder(merchantConfig, orderId, force = true).handle(
                { errorMsg ->
                    onError(R.string.error_delete_order, errorMsg)
                }
            ) {
                assertUiThread()
                configManager.refreshInventory()
                fetchHistory()
            }
        }

    @UiThread
    internal fun clearForceDeletePrompt()
    {
        mForceDeleteOrderId.value = null
    }

    @UiThread
    internal fun clearError()
    {
        mError.value = null
    }

    private fun publishItems()
    {
        mItems.value = HistoryResult.Success(pagination.items)
    }

    @UiThread
    internal fun debugSetHistory(items: List<OrderHistoryEntry>)
    {
        pagination.replaceItems(items)
        publishItems()
    }

    private fun enrichOrders(
        items: List<OrderHistoryEntry>,
        generation: Int,
    ) = scope.launch {
        val merchantConfig = configManager.merchantConfig ?: return@launch
        items.forEach { item ->
            if (generation != historyLoadGeneration) return@launch
            api.checkOrder(merchantConfig, item.orderId).handle(null) { response ->
                assertUiThread()
                if (generation != historyLoadGeneration) return@handle
                updateItem(item.orderId) { it.withPaymentDetails(response) }
            }
        }
    }

    private fun updateItem(
        orderId: String,
        transform: (OrderHistoryEntry) -> OrderHistoryEntry,
    )
    {
        if (!pagination.updateItem(orderId, transform)) return
        publishItems()
    }

    private fun onError(
        @StringRes mainResId: Int,
        msg: String,
    )
    {
        assertUiThread()
        mIsLoading.value = false
        mIsLoadingMore.value = false
        mError.value = HistoryError(mainResId, msg)
    }
}

internal class HistoryPagination
{
    private val newestFirst =
        compareByDescending<OrderHistoryEntry> { it.timestamp.ms }
            .thenByDescending { it.rowId ?: Long.MIN_VALUE }
            .thenByDescending { it.orderId }

    private val loadedItems = mutableListOf<OrderHistoryEntry>()

    var nextOffset: Long? = null
        private set

    var reachedEnd: Boolean = false
        private set

    val items: List<OrderHistoryEntry>
        get() = loadedItems.toList()

    /**
     * Adds one backend page and returns only entries that were not loaded before.
     *
     * The backend's limit is an upper bound, so a short page does not prove that
     * iteration is complete. Depending on backend cursor semantics, a page may
     * also repeat the order at [nextOffset]. In that case it is de-duplicated.
     */
    fun addPage(
        page: List<OrderHistoryEntry>,
        reset: Boolean,
    ): List<OrderHistoryEntry>
    {
        if (reset)
        {
            loadedItems.clear()
            nextOffset = null
            reachedEnd = false
        }

        val previousOffset = nextOffset
        val existingOrderIds = loadedItems.mapTo(mutableSetOf()) { it.orderId }
        val newItems = page.filterNot { it.orderId in existingOrderIds }
        loadedItems += newItems
        loadedItems.sortWith(newestFirst)

        // Pagination follows the backend's raw page order, independently from
        // the timestamp order used to present the combined list.
        val pageOffset = page.lastOrNull()?.rowId
        val cursorAdvanced = pageOffset != null && (reset || pageOffset != previousOffset)
        if (pageOffset != null) nextOffset = pageOffset

        reachedEnd = page.isEmpty() || newItems.isEmpty() || !cursorAdvanced
        return newItems
    }

    fun replaceItems(items: List<OrderHistoryEntry>)
    {
        loadedItems.clear()
        loadedItems.addAll(items)
        loadedItems.sortWith(newestFirst)
        nextOffset = items.lastOrNull()?.rowId
        reachedEnd = false
    }

    fun updateItem(
        orderId: String,
        transform: (OrderHistoryEntry) -> OrderHistoryEntry,
    ): Boolean
    {
        val index = loadedItems.indexOfFirst { it.orderId == orderId }
        if (index == -1) return false
        loadedItems[index] = transform(loadedItems[index])
        loadedItems.sortWith(newestFirst)
        return true
    }
}