package cash.z.ecc.android.sdk.internal

import android.content.Context
import cash.z.ecc.android.sdk.exception.TransactionEncoderException
import cash.z.ecc.android.sdk.internal.ext.deleteRecursivelySuspend
import cash.z.ecc.android.sdk.internal.ext.deleteSuspend
import cash.z.ecc.android.sdk.internal.ext.existsSuspend
import cash.z.ecc.android.sdk.internal.ext.getCacheDirSuspend
import cash.z.ecc.android.sdk.internal.ext.getSha1Hash
import cash.z.ecc.android.sdk.internal.ext.mkdirsSuspend
import cash.z.ecc.android.sdk.internal.ext.renameToSuspend
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
import java.io.IOException
import java.net.URL
import java.nio.channels.Channels
import kotlin.time.Duration.Companion.milliseconds

internal class SaplingParamTool(val properties: SaplingParamToolProperties) {
    companion object {
        /**
         * Maximum file size for the sapling spend params - 50MB
         */
        internal const val SPEND_PARAM_FILE_MAX_BYTES_SIZE = 50L * 1024L * 1024L

        /**
         * Maximum file size for the sapling spend params - 5MB
         */
        internal const val OUTPUT_PARAM_FILE_MAX_BYTES_SIZE = 5L * 1024L * 1024L

        /**
         * Subdirectory name, in which are the sapling params files stored.
         */
        internal const val SAPLING_PARAMS_LEGACY_SUBDIRECTORY = "params"

        /**
         * File name for the sapling spend params
         */
        internal const val SPEND_PARAM_FILE_NAME = "sapling-spend.params"

        /**
         * File name for the sapling output params
         */
        internal const val OUTPUT_PARAM_FILE_NAME = "sapling-output.params"

        /**
         *  Temporary file prefix to fulfill atomicity requirement of file handling
         */
        private const val TEMPORARY_FILE_NAME_PREFIX = "_"

        /**
         * File SHA1 hash for the sapling spend params
         */
        internal const val SPEND_PARAM_FILE_SHA1_HASH = "a15ab54c2888880e53c823a3063820c728444126"

        /**
         * File SHA1 hash for the sapling output params
         */
        internal const val OUTPUT_PARAM_FILE_SHA1_HASH = "0ebc5a1ef3653948e1c46cf7a16071eac4b7e352"

        /**
         * The Url that is used by default in zcashd
         */
        private const val CLOUD_PARAM_DIR_URL = "https://z.cash/downloads/"

        private val checkFilesMutex = Mutex()

        /**
         * Initialization of needed properties. This is necessary entry point for other operations from {@code
         * SaplingParamTool}. This type of implementation also simplifies its testing.
         *
         * @param context
         */
        internal suspend fun new(context: Context): SaplingParamTool {
            val paramsDirectory = Files.getZcashNoBackupSubdirectory(context)
            val toolProperties = SaplingParamToolProperties(
                paramsDirectory = paramsDirectory,
                paramsLegacyDirectory = File(context.getCacheDirSuspend(), SAPLING_PARAMS_LEGACY_SUBDIRECTORY),
                saplingParams = listOf(
                    SaplingParameters(
                        paramsDirectory,
                        SPEND_PARAM_FILE_NAME,
                        SPEND_PARAM_FILE_MAX_BYTES_SIZE,
                        SPEND_PARAM_FILE_SHA1_HASH
                    ),
                    SaplingParameters(
                        paramsDirectory,
                        OUTPUT_PARAM_FILE_NAME,
                        OUTPUT_PARAM_FILE_MAX_BYTES_SIZE,
                        OUTPUT_PARAM_FILE_SHA1_HASH
                    )
                )
            )
            return SaplingParamTool(toolProperties)
        }

        /**
         * Returns file object pointing to the parameters files parent directory. We need to check if the parameters
         * files don't sit in the legacy folder first. If they do, then we move the files to the currently used
         * directory and validate files hashes.
         *
         * @return params destination directory file
         */
        internal suspend fun initAndGetParamsDestinationDir(toolProperties: SaplingParamToolProperties): File {
            checkFilesMutex.withLock {
                toolProperties.saplingParams.forEach {
                    val legacyFile = File(toolProperties.paramsLegacyDirectory, it.fileName)
                    val currentFile = File(toolProperties.paramsDirectory, it.fileName)

                    if (legacyFile.existsSuspend() && isFileHashValid(legacyFile, it.fileHash)) {
                        Twig.debug {
                            "Moving params file: ${it.fileName} from legacy folder to the currently used " +
                                "folder."
                        }
                        currentFile.parentFile?.mkdirsSuspend()
                        if (!renameParametersFile(legacyFile, currentFile)) {
                            Twig.debug {
                                "Failed while moving the params file: ${it.fileName} to the preferred " +
                                    "location."
                            }
                        }
                    } else {
                        Twig.debug {
                            "Legacy file either does not exist or is not valid. Will be fetched to the preferred " +
                                "location."
                        }
                    }
                }
                // remove the params folder and its files - a new sapling files will be fetched to the preferred
                // location
                toolProperties.paramsLegacyDirectory.deleteRecursivelySuspend()
            }

            return toolProperties.paramsDirectory
        }

        /**
         * Compares the input file parameter SHA1 hash with the given input hash.
         *
         * @param parametersFile file of which SHA1 hash will be checked
         * @param fileHash hash to compare with
         *
         * @return true in case of hashes are the same, false otherwise
         */
        private suspend fun isFileHashValid(parametersFile: File, fileHash: String): Boolean {
            return try {
                fileHash == parametersFile.getSha1Hash()
            } catch (e: IOException) {
                Twig.debug { "Failed in comparing file's hashes with: ${e.message}, caused by: ${e.cause}." }
                false
            }
        }

        /**
         * The purpose of this function is to rename parameters file from the old name (given by the {@code
         * fromParamFile} parameter) to the new name (given by {@code toParamFile}). This operation covers also the file
         * move, if it's in a different location.
         *
         * @param fromParamFile the previously used file name/location
         * @param toParamFile the newly used file name/location
         */
        private suspend fun renameParametersFile(
            fromParamFile: File,
            toParamFile: File
        ): Boolean {
            return runCatching {
                return@runCatching fromParamFile.renameToSuspend(toParamFile)
            }.onFailure {
                Twig.debug(it) { "Failed while renaming parameters file" }
            }.getOrDefault(false)
        }
    }

    /**
     * Checks the given directory for the output and spending params and calls [fetchParams] for those, which are
     * missing.
     *
     * Note: Don't forget to call the entry point function {@code initSaplingParamTool} first. Make sure you also
     * called {@code initAndGetParamsDestinationDir} previously, as it's always better to check the
     * legacy destination folder first.
     *
     * @param destinationDir the directory where the params should be stored.
     *
     * @throws TransactionEncoderException.MissingParamsException in case of failure while checking sapling params
     * @throws TransactionEncoderException.FetchParamsException
     * @throws TransactionEncoderException.ValidateParamsException
     * files
     */
    @Throws(
        TransactionEncoderException.ValidateParamsException::class,
        TransactionEncoderException.FetchParamsException::class,
        TransactionEncoderException.MissingParamsException::class
    )
    internal suspend fun ensureParams(destinationDir: File) {
        properties.saplingParams.filter {
            !File(it.destinationDirectory, it.fileName).existsSuspend()
        }.forEach {
            try {
                Twig.debug { "Attempting to download missing params: ${it.fileName}." }
                fetchParams(it)
            } catch (e: TransactionEncoderException.FetchParamsException) {
                Twig.debug {
                    "Failed to fetch param file ${it.fileName} due to: $e. The second attempt is starting with a " +
                        "little delay."
                }
                // Re-run the fetch with a little delay, if it failed previously (as it can be caused by network
                // conditions). We do it only once, the next failure is delivered to the caller of this method.
                delay(200.milliseconds)
                fetchParams(it)
            } catch (e: TransactionEncoderException.ValidateParamsException) {
                Twig.debug {
                    "Failed to validate fetched param file ${it.fileName} due to: $e. The second attempt is starting" +
                        " now."
                }
                // Re-run the fetch for invalid param file immediately, if it failed previously. We do it again only
                // once, the next failure is delivered to the caller of this method.
                fetchParams(it)
            }
        }

        if (!validate(destinationDir)) {
            Twig.debug { "Fetching sapling params files failed." }
            throw TransactionEncoderException.MissingParamsException
        }
    }

    /**
     * Download and store the params file into the given directory. It also checks the file size to eliminate the
     * risk of downloading potentially large file from a malicious server.
     *
     * @param paramsToFetch parameters wrapper class, which holds information about it
     *
     * @throws TransactionEncoderException.FetchParamsException if any error while downloading the params file occurs
     * @throws TransactionEncoderException.ValidateParamsException if a failure in validation of fetched file occurs
     */
    @Throws(
        TransactionEncoderException.ValidateParamsException::class,
        TransactionEncoderException.FetchParamsException::class
    )
    internal suspend fun fetchParams(paramsToFetch: SaplingParameters) {
        val url = URL("$CLOUD_PARAM_DIR_URL/${paramsToFetch.fileName}")
        val temporaryFile = File(
            paramsToFetch.destinationDirectory,
            "$TEMPORARY_FILE_NAME_PREFIX${paramsToFetch.fileName}"
        )

        withContext(Dispatchers.IO) {
            runCatching {
                Channels.newChannel(url.openStream()).use { readableByteChannel ->
                    temporaryFile.outputStream().use { fileOutputStream ->
                        fileOutputStream.channel.use { fileChannel ->
                            // Transfers bytes from stream to file from position 0 to end position or to max
                            // file size limit. This eliminates the risk of downloading potentially large files
                            // from a malicious server. We need to make a check of the file hash then.
                            fileChannel.transferFrom(readableByteChannel, 0, paramsToFetch.fileMaxSizeBytes)
                        }
                    }
                }
            }.onFailure { exception ->
                // IllegalArgumentException - If the preconditions on the parameters do not hold
                // NonReadableChannelException - If the source channel was not opened for reading
                // NonWritableChannelException - If this channel was not opened for writing
                // ClosedChannelException - If either this channel or the source channel is closed
                // AsynchronousCloseException - If another thread closes either channel while the transfer is
                // in progress
                // ClosedByInterruptException - If another thread interrupts the current thread while the
                // transfer is in progress, thereby closing both channels and setting the current thread's
                // interrupt status
                // IOException - If some other I/O error occurs
                finalizeAndReportError(
                    temporaryFile,
                    exception = TransactionEncoderException.FetchParamsException(
                        paramsToFetch,
                        "Error while fetching ${paramsToFetch.fileName}, caused by $exception."
                    )
                )
            }.onSuccess {
                Twig.debug {
                    "Fetch and write of the temporary ${temporaryFile.name} succeeded. Validating and moving it to " +
                        "the final destination."
                }
                if (!isFileHashValid(temporaryFile, paramsToFetch.fileHash)) {
                    finalizeAndReportError(
                        temporaryFile,
                        exception = TransactionEncoderException.ValidateParamsException(
                            paramsToFetch,
                            "Failed while validating fetched param file: ${paramsToFetch.fileName}."
                        )
                    )
                }
                val resultFile = File(paramsToFetch.destinationDirectory, paramsToFetch.fileName)
                if (!renameParametersFile(temporaryFile, resultFile)) {
                    finalizeAndReportError(
                        temporaryFile,
                        resultFile,
                        exception = TransactionEncoderException.ValidateParamsException(
                            paramsToFetch,
                            "Failed while renaming result param file: ${paramsToFetch.fileName}."
                        )
                    )
                }
            }
        }
    }

    @Throws(TransactionEncoderException.FetchParamsException::class)
    private suspend fun finalizeAndReportError(vararg files: File, exception: TransactionEncoderException) {
        files.forEach {
            it.deleteSuspend()
        }
        exception.also {
            Twig.debug(it) { "Error while fetching sapling params files." }
            throw it
        }
    }

    internal suspend fun validate(destinationDir: File): Boolean {
        return arrayOf(
            SPEND_PARAM_FILE_NAME,
            OUTPUT_PARAM_FILE_NAME
        ).all { paramFileName ->
            File(destinationDir, paramFileName).existsSuspend()
        }.also {
            Twig.debug { "Param files ${if (!it) "did not" else ""} both exist!" }
        }
    }
}

/**
 * Sapling file parameter class to hold each sapling file attributes.
 */
internal data class SaplingParameters(
    val destinationDirectory: File,
    val fileName: String,
    val fileMaxSizeBytes: Long,
    val fileHash: String
)

/**
 * Sapling param tool helper properties. The goal of this implementation is to ease its testing.
 */
internal data class SaplingParamToolProperties(
    val saplingParams: List<SaplingParameters>,
    val paramsDirectory: File,
    val paramsLegacyDirectory: File
)
