package cash.z.ecc.android.sdk.internal.block
import cash.z.ecc.android.sdk.exception.LightWalletException
import cash.z.ecc.android.sdk.internal.ext.retryUpTo
import cash.z.ecc.android.sdk.internal.ext.tryWarn
import cash.z.ecc.android.sdk.internal.service.LightWalletService
import cash.z.ecc.android.sdk.internal.twig
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.wallet.sdk.rpc.Service
import io.grpc.StatusRuntimeException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

/**
 * Serves as a source of compact blocks received from the light wallet server. Once started, it will use the given
 * lightwallet service to request all the appropriate blocks and compact block store to persist them. By delegating to
 * these dependencies, the downloader remains agnostic to the particular implementation of how to retrieve and store
 * data; although, by default the SDK uses gRPC and SQL.
 *
 * @property lightWalletService the service used for requesting compact blocks
 * @property compactBlockStore responsible for persisting the compact blocks that are received
 */
open class CompactBlockDownloader private constructor(val compactBlockStore: CompactBlockStore) {

    lateinit var lightWalletService: LightWalletService
        private set

    constructor(
        lightWalletService: LightWalletService,
        compactBlockStore: CompactBlockStore
    ) : this(compactBlockStore) {
        this.lightWalletService = lightWalletService
    }

    /**
     * Requests the given range of blocks from the lightwalletService and then persists them to the
     * compactBlockStore.
     *
     * @param heightRange the inclusive range of heights to request. For example 10..20 would
     * request 11 blocks (including block 10 and block 20).
     *
     * @return the number of blocks that were returned in the results from the lightwalletService.
     */
    suspend fun downloadBlockRange(heightRange: ClosedRange<BlockHeight>): Int = withContext(IO) {
        val result = lightWalletService.getBlockRange(heightRange)
        compactBlockStore.write(result)
    }

    /**
     * Rewind the storage to the given height, usually to handle reorgs. Deletes all blocks above
     * the given height.
     *
     * @param height the height to which the data will rewind.
     */
    suspend fun rewindToHeight(height: BlockHeight) =
        // TODO: cancel anything in flight
        compactBlockStore.rewindTo(height)

    /**
     * Return the latest block height known by the lightwalletService.
     *
     * @return the latest block height.
     */
    suspend fun getLatestBlockHeight() =
        lightWalletService.getLatestBlockHeight()

    /**
     * Return the latest block height that has been persisted into the [CompactBlockStore].
     *
     * @return the latest block height that has been persisted.
     */
    suspend fun getLastDownloadedHeight() =
        compactBlockStore.getLatestHeight()

    suspend fun getServerInfo(): Service.LightdInfo = withContext<Service.LightdInfo>(IO) {
        lateinit var result: Service.LightdInfo
        try {
            result = lightWalletService.getServerInfo()
        } catch (e: StatusRuntimeException) {
            retryUpTo(6) {
                twig("WARNING: reconnecting to service in response to failure (retry #${it + 1}): $e")
                lightWalletService.reconnect()
                result = lightWalletService.getServerInfo()
            }
        }
        result
    }

    suspend fun changeService(
        newService: LightWalletService,
        errorHandler: (Throwable) -> Unit = { throw it }
    ) = withContext(IO) {
        try {
            val existing = lightWalletService.getServerInfo()
            val new = newService.getServerInfo()
            val nonMatching = existing.essentialPropertyDiff(new)

            if (nonMatching.size > 0) {
                errorHandler(
                    LightWalletException.ChangeServerException.ChainInfoNotMatching(
                        nonMatching.joinToString(),
                        existing,
                        new
                    )
                )
            }

            gracefullyShutdown(lightWalletService)
            lightWalletService = newService
        } catch (s: StatusRuntimeException) {
            errorHandler(LightWalletException.ChangeServerException.StatusException(s.status))
        } catch (t: Throwable) {
            errorHandler(t)
        }
    }

    /**
     * Stop this downloader and cleanup any resources being used.
     */
    suspend fun stop() {
        withContext(Dispatchers.IO) {
            lightWalletService.shutdown()
        }
        compactBlockStore.close()
    }

    /**
     * Fetch the details of a known transaction.
     *
     * @return the full transaction info.
     */
    fun fetchTransaction(txId: ByteArray) = lightWalletService.fetchTransaction(txId)

    //
    // Convenience functions
    //

    private suspend fun CoroutineScope.gracefullyShutdown(service: LightWalletService) = launch {
        delay(2_000L)
        tryWarn("Warning: error while shutting down service") {
            service.shutdown()
        }
    }

    /**
     * Return a list of critical properties that do not match.
     */
    private fun Service.LightdInfo.essentialPropertyDiff(other: Service.LightdInfo) =
        mutableListOf<String>().also {
            if (!consensusBranchId.equals(other.consensusBranchId, true)) {
                it.add("consensusBranchId")
            }
            if (saplingActivationHeight != other.saplingActivationHeight) {
                it.add("saplingActivationHeight")
            }
            if (!chainName.equals(other.chainName, true)) {
                it.add("chainName")
            }
        }
}
