package cash.z.ecc.android.sdk

import android.content.Context
import cash.z.ecc.android.sdk.exception.InitializerException
import cash.z.ecc.android.sdk.ext.ZcashSdk
import cash.z.ecc.android.sdk.internal.SdkDispatchers
import cash.z.ecc.android.sdk.internal.ext.getCacheDirSuspend
import cash.z.ecc.android.sdk.internal.ext.getDatabasePathSuspend
import cash.z.ecc.android.sdk.internal.model.Checkpoint
import cash.z.ecc.android.sdk.internal.twig
import cash.z.ecc.android.sdk.jni.RustBackend
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.LightWalletEndpoint
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.tool.CheckpointTool
import cash.z.ecc.android.sdk.tool.DerivationTool
import cash.z.ecc.android.sdk.type.UnifiedViewingKey
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import java.io.File

/**
 * Simplified Initializer focused on starting from a ViewingKey.
 */
@Suppress("LongParameterList")
class Initializer private constructor(
    val context: Context,
    internal val rustBackend: RustBackend,
    val network: ZcashNetwork,
    val alias: String,
    val lightWalletEndpoint: LightWalletEndpoint,
    val viewingKeys: List<UnifiedViewingKey>,
    val overwriteVks: Boolean,
    internal val checkpoint: Checkpoint
) {

    suspend fun erase() = erase(context, network, alias)

    class Config private constructor(
        val viewingKeys: MutableList<UnifiedViewingKey> = mutableListOf(),
        var alias: String = ZcashSdk.DEFAULT_ALIAS
    ) {
        var birthdayHeight: BlockHeight? = null
            private set

        lateinit var network: ZcashNetwork
            private set

        lateinit var lightWalletEndpoint: LightWalletEndpoint
            private set

        /**
         * Determines the default behavior for null birthdays. When null, nothing has been specified
         * so a null birthdayHeight value is an error. When false, null birthdays will be replaced
         * with the most recent checkpoint height available (typically, the latest `*.json` file in
         * `assets/co.electriccoin.zcash/checkpoint/`). When true, null birthdays will be replaced with the oldest
         * reasonable height where a transaction could exist (typically, sapling activation but
         * better approximations could be devised in the future, such as the date when the first
         * BIP-39 zcash wallets came online).
         */
        var defaultToOldestHeight: Boolean? = null
            private set

        var overwriteVks: Boolean = false
            private set

        constructor(block: (Config) -> Unit) : this() {
            block(this)
        }

        //
        // Birthday functions
        //

        /**
         * Set the birthday height for this configuration. When the height is not known, the wallet
         * can either default to the latest known birthday (in order to sync new wallets faster) or
         * the oldest possible birthday (in order to import a wallet with an unknown birthday
         * without skipping old transactions).
         *
         * @param height nullable birthday height to use for this configuration.
         * @param defaultToOldestHeight determines how a null birthday height will be
         * interpreted. Typically, `false` for new wallets and `true` for restored wallets because
         * new wallets want to load quickly but restored wallets want to find all possible
         * transactions. Again, this value is only considered when [height] is null.
         *
         */
        fun setBirthdayHeight(height: BlockHeight?, defaultToOldestHeight: Boolean): Config =
            apply {
                this.birthdayHeight = height
                this.defaultToOldestHeight = defaultToOldestHeight
            }

        /**
         * Load the most recent checkpoint available. This is useful for new wallets.
         */
        fun newWalletBirthday(): Config = apply {
            birthdayHeight = null
            defaultToOldestHeight = false
        }

        /**
         * Load the birthday checkpoint closest to the given wallet birthday. This is useful when
         * importing a pre-existing wallet. It is the same as calling
         * `birthdayHeight = importedHeight`.
         */
        fun importedWalletBirthday(importedHeight: BlockHeight?): Config = apply {
            birthdayHeight = importedHeight
            defaultToOldestHeight = true
        }

        //
        // Viewing key functions
        //

        /**
         * Add viewing keys to the set of accounts to monitor. Note: Using more than one viewing key
         * is not currently well supported. Consider it an alpha-preview feature that might work but
         * probably has serious bugs.
         */
        fun setViewingKeys(
            vararg unifiedViewingKeys: UnifiedViewingKey,
            overwrite: Boolean = false
        ): Config = apply {
            overwriteVks = overwrite
            viewingKeys.apply {
                clear()
                addAll(unifiedViewingKeys)
            }
        }

        fun setOverwriteKeys(isOverwrite: Boolean) {
            overwriteVks = isOverwrite
        }

        /**
         * Add viewing key to the set of accounts to monitor. Note: Using more than one viewing key
         * is not currently well supported. Consider it an alpha-preview feature that might work but
         * probably has serious bugs.
         */
        fun addViewingKey(unifiedFullViewingKey: UnifiedViewingKey): Config = apply {
            viewingKeys.add(unifiedFullViewingKey)
        }

        //
        // Convenience functions
        //

        /**
         * Set the server and the network property at the same time to prevent them from getting out
         * of sync. Ultimately, this determines which host a synchronizer will use in order to
         * connect to lightwalletd. In most cases, the default host is sufficient but an override
         * can be provided. The host cannot be changed without explicitly setting the network.
         *
         * @param network the Zcash network to use. Either testnet or mainnet.
         * @param host the lightwalletd host to use.
         * @param port the lightwalletd port to use.
         */
        fun setNetwork(
            network: ZcashNetwork,
            lightWalletEndpoint: LightWalletEndpoint
        ): Config = apply {
            this.network = network
            this.lightWalletEndpoint = lightWalletEndpoint
        }

        /**
         * Import a wallet using the first viewing key derived from the given seed.
         */
        suspend fun importWallet(
            seed: ByteArray,
            birthday: BlockHeight?,
            network: ZcashNetwork,
            lightWalletEndpoint: LightWalletEndpoint,
            alias: String = ZcashSdk.DEFAULT_ALIAS
        ): Config =
            importWallet(
                DerivationTool.deriveUnifiedViewingKeys(seed, network = network)[0],
                birthday,
                network,
                lightWalletEndpoint,
                alias
            )

        /**
         * Default function for importing a wallet.
         */
        fun importWallet(
            viewingKey: UnifiedViewingKey,
            birthday: BlockHeight?,
            network: ZcashNetwork,
            lightWalletEndpoint: LightWalletEndpoint,
            alias: String = ZcashSdk.DEFAULT_ALIAS
        ): Config = apply {
            setViewingKeys(viewingKey)
            setNetwork(network, lightWalletEndpoint)
            importedWalletBirthday(birthday)
            this.alias = alias
        }

        /**
         * Create a new wallet using the first viewing key derived from the given seed.
         */
        suspend fun newWallet(
            seed: ByteArray,
            network: ZcashNetwork,
            lightWalletEndpoint: LightWalletEndpoint,
            alias: String = ZcashSdk.DEFAULT_ALIAS
        ): Config = newWallet(
            DerivationTool.deriveUnifiedViewingKeys(seed, network)[0],
            network,
            lightWalletEndpoint,
            alias
        )

        /**
         * Default function for creating a new wallet.
         */
        fun newWallet(
            viewingKey: UnifiedViewingKey,
            network: ZcashNetwork,
            lightWalletEndpoint: LightWalletEndpoint,
            alias: String = ZcashSdk.DEFAULT_ALIAS
        ): Config = apply {
            setViewingKeys(viewingKey)
            setNetwork(network, lightWalletEndpoint)
            newWalletBirthday()
            this.alias = alias
        }

        /**
         * Convenience method for setting thew viewingKeys from a given seed. This is the same as
         * calling `setViewingKeys` with the keys that match this seed.
         */
        suspend fun setSeed(
            seed: ByteArray,
            network: ZcashNetwork,
            numberOfAccounts: Int = 1
        ): Config =
            apply {
                setViewingKeys(
                    *DerivationTool.deriveUnifiedViewingKeys(
                        seed,
                        network,
                        numberOfAccounts
                    )
                )
            }

        /**
         * Sets the network from a network id, throwing an exception if the id is not recognized.
         *
         * @param networkId the ID of the network corresponding to the [ZcashNetwork] enum.
         * Typically, it is 0 for testnet and 1 for mainnet.
         */
        fun setNetworkId(networkId: Int): Config = apply {
            network = ZcashNetwork.from(networkId)
        }

        //
        // Validation helpers
        //

        fun validate(): Config = apply {
            validateAlias(alias)
            validateViewingKeys()
            validateBirthday()
        }

        private fun validateBirthday() {
            // if birthday is missing then we need to know how to interpret it
            // so defaultToOldestHeight ought to be set, in that case
            if (birthdayHeight == null && defaultToOldestHeight == null) {
                throw InitializerException.MissingDefaultBirthdayException
            }
            // allow either null or a value greater than the activation height
            if (
                (birthdayHeight?.value ?: network.saplingActivationHeight.value)
                < network.saplingActivationHeight.value
            ) {
                throw InitializerException.InvalidBirthdayHeightException(birthdayHeight, network)
            }
        }

        private fun validateViewingKeys() {
            require(viewingKeys.isNotEmpty()) {
                "Unified Viewing keys are required. Ensure that the unified viewing keys or seed" +
                    " have been set on this Initializer."
            }
            viewingKeys.forEach {
                DerivationTool.validateUnifiedViewingKey(it)
            }
        }

        companion object
    }

    companion object : SdkSynchronizer.Erasable {

        suspend fun new(appContext: Context, config: Config) = new(appContext, null, config)

        fun newBlocking(appContext: Context, config: Config) = runBlocking {
            new(
                appContext,
                null,
                config
            )
        }

        suspend fun new(
            appContext: Context,
            onCriticalErrorHandler: ((Throwable?) -> Boolean)? = null,
            block: (Config) -> Unit
        ) = new(appContext, onCriticalErrorHandler, Config(block))

        suspend fun new(
            context: Context,
            onCriticalErrorHandler: ((Throwable?) -> Boolean)?,
            config: Config
        ): Initializer {
            config.validate()

            val loadedCheckpoint = run {
                val height = config.birthdayHeight
                    ?: if (config.defaultToOldestHeight == true) {
                        config.network.saplingActivationHeight
                    } else {
                        null
                    }

                CheckpointTool.loadNearest(
                    context,
                    config.network,
                    height
                )
            }

            val rustBackend = initRustBackend(context, config.network, config.alias, loadedCheckpoint.height)

            return Initializer(
                context.applicationContext,
                rustBackend,
                config.network,
                config.alias,
                config.lightWalletEndpoint,
                config.viewingKeys,
                config.overwriteVks,
                loadedCheckpoint
            )
        }

        private fun onCriticalError(onCriticalErrorHandler: ((Throwable?) -> Boolean)?, error: Throwable) {
            twig("********")
            twig("********  INITIALIZER ERROR: $error")
            if (error.cause != null) twig("******** caused by ${error.cause}")
            if (error.cause?.cause != null) twig("******** caused by ${error.cause?.cause}")
            twig("********")
            twig(error)

            if (onCriticalErrorHandler == null) {
                twig(
                    "WARNING: a critical error occurred on the Initializer but no callback is " +
                        "registered to be notified of critical errors! THIS IS PROBABLY A MISTAKE. To " +
                        "respond to these errors (perhaps to update the UI or alert the user) set " +
                        "initializer.onCriticalErrorHandler to a non-null value or use the secondary " +
                        "constructor: Initializer(context, handler) { ... }. Note that the synchronizer " +
                        "and initializer BOTH have error handlers and since the initializer exists " +
                        "before the synchronizer, it needs its error handler set separately."
                )
            }

            onCriticalErrorHandler?.invoke(error)
        }

        private suspend fun initRustBackend(
            context: Context,
            network: ZcashNetwork,
            alias: String,
            blockHeight: BlockHeight
        ): RustBackend {
            return RustBackend.init(
                cacheDbPath(context, network, alias),
                dataDbPath(context, network, alias),
                File(context.getCacheDirSuspend(), "params").absolutePath,
                network,
                blockHeight
            )
        }

        /**
         * Delete the databases associated with this wallet. This removes all compact blocks and
         * data derived from those blocks. For most wallets, this should not result in a loss of
         * funds because the seed and spending keys are stored separately. This call just removes
         * the associated data but not the seed or spending key, themselves, because those are
         * managed separately by the wallet.
         *
         * @param appContext the application context.
         * @param network the network associated with the data to be erased.
         * @param alias the alias used to create the local data.
         *
         * @return true when one of the associated files was found. False most likely indicates
         * that the wrong alias was provided.
         */
        override suspend fun erase(
            appContext: Context,
            network: ZcashNetwork,
            alias: String
        ): Boolean {
            val cacheDeleted = deleteDb(cacheDbPath(appContext, network, alias))
            val dataDeleted = deleteDb(dataDbPath(appContext, network, alias))
            return dataDeleted || cacheDeleted
        }

        //
        // Path Helpers
        //

        /**
         * Returns the path to the cache database that would correspond to the given alias.
         *
         * @param appContext the application context
         * @param network the network associated with the data in the cache database.
         * @param alias the alias to convert into a database path
         */
        private suspend fun cacheDbPath(
            appContext: Context,
            network: ZcashNetwork,
            alias: String
        ): String =
            aliasToPath(appContext, network, alias, ZcashSdk.DB_CACHE_NAME)

        /**
         * Returns the path to the data database that would correspond to the given alias.
         * @param appContext the application context
         * * @param network the network associated with the data in the database.
         * @param alias the alias to convert into a database path
         */
        private suspend fun dataDbPath(
            appContext: Context,
            network: ZcashNetwork,
            alias: String
        ): String =
            aliasToPath(appContext, network, alias, ZcashSdk.DB_DATA_NAME)

        private suspend fun aliasToPath(
            appContext: Context,
            network: ZcashNetwork,
            alias: String,
            dbFileName: String
        ): String {
            val parentDir: String =
                appContext.getDatabasePathSuspend("unused.db").parentFile?.absolutePath
                    ?: throw InitializerException.DatabasePathException
            val prefix = if (alias.endsWith('_')) alias else "${alias}_"
            return File(parentDir, "$prefix${network.networkName}_$dbFileName").absolutePath
        }

        /**
         * Delete a database and it's potential journal file at the given path.
         *
         * @param filePath the path of the db to erase.
         * @return true when a file exists at the given path and was deleted.
         */
        private suspend fun deleteDb(filePath: String): Boolean {
            // just try the journal file. Doesn't matter if it's not there.
            delete("$filePath-journal")

            return delete(filePath)
        }

        /**
         * Delete the file at the given path.
         *
         * @param filePath the path of the file to erase.
         * @return true when a file exists at the given path and was deleted.
         */
        private suspend fun delete(filePath: String): Boolean {
            return File(filePath).let {
                withContext(SdkDispatchers.DATABASE_IO) {
                    if (it.exists()) {
                        twig("Deleting ${it.name}!")
                        it.delete()
                        true
                    } else {
                        false
                    }
                }
            }
        }
    }
}

/**
 * Validate that the alias doesn't contain malicious characters by enforcing simple rules which
 * permit the alias to be used as part of a file name for the preferences and databases. This
 * enables multiple wallets to exist on one device, which is also helpful for sweeping funds.
 *
 * @param alias the alias to validate.
 *
 * @throws IllegalArgumentException whenever the alias is not less than 100 characters or
 * contains something other than alphanumeric characters. Underscores are allowed but aliases
 * must start with a letter.
 */
internal fun validateAlias(alias: String) {
    require(
        alias.length in 1..99 && alias[0].isLetter() &&
            alias.all { it.isLetterOrDigit() || it == '_' }
    ) {
        "ERROR: Invalid alias ($alias). For security, the alias must be shorter than 100 " +
            "characters and only contain letters, digits or underscores and start with a letter."
    }
}
