/*
 * This file is part of LibEuFin.
 * Copyright (C) 2023, 2024, 2025, 2026 Taler Systems S.A.
 *
 * LibEuFin is free software; you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation; either version 3, or
 * (at your option) any later version.
 *
 * LibEuFin 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 Affero General
 * Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public
 * License along with LibEuFin; see the file COPYING.  If not, see
 * <http://www.gnu.org/licenses/>
 */

package tech.libeufin.testbench

import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.Context
import com.github.ajalt.clikt.core.ProgramResult
import com.github.ajalt.clikt.core.main
import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.types.enum
import com.github.ajalt.clikt.testing.*
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.http.*
import kotlinx.coroutines.*
import kotlinx.serialization.Serializable
import tech.libeufin.common.*
import tech.libeufin.nexus.*
import tech.libeufin.nexus.cli.LibeufinNexus
import tech.libeufin.ebisync.cli.LibeufinEbisync
import tech.libeufin.ebics.*
import java.time.Instant
import kotlin.io.path.*
import org.jline.terminal.*
import org.jline.reader.*
import org.jline.reader.impl.history.*

enum class Component { Nexus, Ebisync }

val nexusCmd = LibeufinNexus()
val ebisyncCmd = LibeufinEbisync()

val client = HttpClient(CIO)
var thread: Thread? = null
var deferred: CompletableDeferred<CliktCommandTestResult> = CompletableDeferred()

class Interrupt: Exception("Interrupt")

fun step(name: String) {
    println(ANSI.magenta(name))
}

fun msg(msg: String) {
    println(ANSI.yellow(msg))
}

fun err(msg: String) {
    println(ANSI.red(msg))
}

suspend fun CliktCommand.run(arg: String): Boolean {
    deferred = CompletableDeferred()
    val task = kotlin.concurrent.thread {
        deferred.complete(this@run.test(arg))
    }
    thread = task
    task.join()
    thread = null
    val res = deferred.await()
    print(res.output)
    val success = res.statusCode == 0
    if (success) {
        println(ANSI.green("OK"))
    } else {
        err("ERROR ${res.statusCode}")
    }
    return success
}

data class Kind(val name: String, val settings: String?) {
    val test get() = settings != null
}

@Serializable
data class Config(
    val payto: Map<String, String>
)

private val WORDS_REGEX = Regex("\\s+")

class Cli : CliktCommand() {
    override fun help(context: Context) = "Run integration tests on banks provider"

    val component by argument().enum<Component>()
    val platform by argument()

    override fun run() {
        // List available platform
        val platforms = Path("test/platform").listDirectoryEntries().mapNotNull {
            val fileName = it.fileName.toString()
            if (fileName == "config.json") {
                null
            } else {
                fileName.removeSuffix(".conf")
            }
        }
        if (!platforms.contains(platform)) {
            println("Unknown platform '$platform', expected one of $platforms")
            throw ProgramResult(1)
        }

        // Augment config
        val simpleCfg = Path("test/platform/$platform.conf").readText()
        val conf = Path("test/$platform/ebics.conf")
        conf.writeText(
        """$simpleCfg
        ${simpleCfg.replace("[nexus-ebics]", "[ebisync]").replace("[nexus-setup]", "[ebisync-setup]")}
        [paths]
        LIBEUFIN_NEXUS_HOME = test/$platform
        EBISYNC_HOME = test/$platform

        [nexus-fetch]
        FREQUENCY = 1h
        CHECKPOINT_TIME_OF_DAY = 16:52

        [ebisync-fetch]
        FREQUENCY = 1h
        CHECKPOINT_TIME_OF_DAY = 16:52
        DESTINATION = azure-blob-storage
        AZURE_API_URL = http://localhost:10000/devstoreaccount1/
        AZURE_ACCOUNT_NAME = devstoreaccount1
        AZURE_ACCOUNT_KEY = Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==
        AZURE_CONTAINER = test

        [ebisync-submit]
        SOURCE = ebisync-api
        AUTH_METHOD = none

        [libeufin-nexusdb-postgres]
        CONFIG = postgres:///libeufintestbench

        [ebisyncdb-postgres]
        CONFIG = postgres:///libeufintestbench
        """)

        // Prepare shell
        val terminal = TerminalBuilder.builder().system(true).build()//.signalHandler(Terminal.SignalHandler.SIG_IGN).build()
        val history = DefaultHistory()
        val reader = LineReaderBuilder.builder().terminal(terminal).history(history).build();

        terminal.handle(Terminal.Signal.INT) {
            thread?.let {
                thread = null
                it.interrupt()
            } ?: run {
                kotlin.system.exitProcess(0)
            }
        }

        val cfg = nexusConfig(conf)

        // Check if platform is known
        val host = cfg.cfg.section("nexus-ebics").string("host_base_url").orNull()
        val kind = when (host) {
            "https://isotest.postfinance.ch/ebicsweb/ebicsweb" -> 
                Kind("PostFinance IsoTest", "https://isotest.postfinance.ch/corporates/user/settings/ebics")
            "https://iso20022test.credit-suisse.com/ebicsweb/ebicsweb" ->
                Kind("Credit Suisse isoTest", "https://iso20022test.credit-suisse.com/user/settings/ebics")   
            "https://ebics.postfinance.ch/ebics/ebics.aspx" -> 
                Kind("PostFinance", null)
            else -> Kind("Unknown", null)
        }

        // Read testbench config 
        val benchCfg: Config = loadJsonFile(Path("test/platform/config.json"), "testbench config")
            ?: Config(emptyMap())

        // Prepare cmds
        val log = "DEBUG"
        val flags = " -c $conf -L $log"
        val debugFlags = "$flags --debug-ebics test/$platform"
        val ebicsFlags = "$debugFlags --transient"
        val clientKeysPath = cfg.ebics.clientPrivateKeysPath
        val bankKeysPath = cfg.ebics.bankPublicKeysPath
        val currency = cfg.currency

        val dummyPaytos = mapOf(
            "CHF" to "payto://iban/GENODED1SPW/DE48330605920000686018?receiver-name=Christian%20Grothoff",
            "EUR" to "payto://iban/GENODED1SPW/DE48330605920000686018?receiver-name=Christian%20Grothoff"
        )
        val dummyPayto = requireNotNull(dummyPaytos[currency]) {
            "Missing dummy payto for $currency"
        }
        val payto = benchCfg.payto[currency] ?: dummyPayto
        val recoverDoc = "report statement notification"

        runBlocking {
            step("Init ${kind.name}")

            val (setup, cmds) = when (component) {
                Component.Ebisync -> {
                    assert(ebisyncCmd.run("dbinit $flags"))
                    val cmds = buildCmds(ebisyncCmd) {
                        put("reset-db", "Reset DB", "dbinit -r $flags")
                        put("recover", "Recover old transactions", "fetch $ebicsFlags --pinned-start 2024-01-01")
                        put("fetch", "Fetch all documents", "fetch $ebicsFlags")
                        put("fetch-wait", "Fetch all documents", "fetch $debugFlags")
                        put("checkpoint", "Run a transient checkpoint", "fetch $ebicsFlags --checkpoint")
                        put("peek", "Run a transient peek", "fetch $ebicsFlags --peek")
                        put("reset-keys", "Reset EBICS keys") {
                            if (kind.test) {
                                clientKeysPath.deleteIfExists()
                            }
                            bankKeysPath.deleteIfExists()
                        }
                    }
                    Pair(suspend { ebisyncCmd.run("setup $debugFlags") }, cmds)
                }
                Component.Nexus -> {
                    assert(nexusCmd.run("dbinit $flags"))
                    val cmds = buildCmds(nexusCmd) {
                        put("reset-db", "Reset DB", "dbinit -r $flags")
                        put("recover", "Recover old transactions", "ebics-fetch $ebicsFlags --pinned-start 2024-01-01 $recoverDoc")
                        put("fetch", "Fetch all documents", "ebics-fetch $ebicsFlags")
                        put("fetch-wait", "Fetch all documents", "ebics-fetch $debugFlags")
                        put("checkpoint", "Run a transient checkpoint", "ebics-fetch $ebicsFlags --checkpoint")
                        put("peek", "Run a transient peek", "ebics-fetch $ebicsFlags --peek")
                        put("ack", "Fetch CustomerAcknowledgement", "ebics-fetch $ebicsFlags acknowledgement")
                        put("status", "Fetch CustomerPaymentStatusReport", "ebics-fetch $ebicsFlags status")
                        put("report", "Fetch BankToCustomerAccountReport", "ebics-fetch $ebicsFlags report")
                        put("notification", "Fetch BankToCustomerDebitCreditNotification", "ebics-fetch $ebicsFlags notification")
                        put("statement", "Fetch BankToCustomerStatement", "ebics-fetch $ebicsFlags statement")
                        put("list-incoming", "List incoming transaction", "list incoming $flags")
                        put("list-outgoing", "List outgoing transaction", "list outgoing $flags")
                        put("list-initiated", "List initiated payments", "list initiated $flags")
                        put("list-ack", "List initiated payments pending manual submission acknowledgement", "list initiated $flags --awaiting-ack")
                        put("wss", "Listen to notification over websocket", "testing wss $debugFlags")
                        put("submit", "Submit pending transactions", "ebics-submit $ebicsFlags")
                        put("submit-wait", "Submit pending transaction", "ebics-submit $debugFlags")
                        put("export", "Export pending batches as pain001 messages", "manual export $flags payments.zip")
                        putArgs("import", "Import xml files in root directory") {
                            buildString {
                                append("manual import $flags ")
                                for (file in Path("..").listDirectoryEntries()) {
                                    if (file.extension == "xml") {
                                        append(file)
                                        append(" ")
                                    }
                                }
                            }
                        }
                        putArgs("status", "Set batch or transaction status") {
                            "manual status $flags " + it.joinToString(" ")
                        }
                        put("reset-keys", "Reset EBICS keys") {
                            if (kind.test) {
                                clientKeysPath.deleteIfExists()
                            }
                            bankKeysPath.deleteIfExists()
                        }
                        put("tx", "Initiate a new transaction") {
                            val now = Instant.now()
                            nexusCmd.run("initiate-payment $flags --amount=$currency:0.1 --subject \"single $now\" \"$payto\"")
                        }
                        put("txs", "Initiate four new transactions") {
                            val now = Instant.now()
                            repeat(4) {
                                nexusCmd.run("initiate-payment $flags --amount=$currency:${(10.0+it)/100} --subject \"multi $it $now\" \"$payto\"")
                            }
                        }
                        put("tx-bad-name", "Initiate a new transaction with a bad name") {
                            val badPayto = URLBuilder().takeFrom(payto)
                            badPayto.parameters["receiver-name"] = "John Smith"
                            val now = Instant.now()
                            nexusCmd.run("initiate-payment $flags --amount=$currency:0.21 --subject \"bad name $now\" \"$badPayto\"")
                        }
                        put("tx-bad-iban", "Initiate a new transaction to a bad IBAN") {
                            val badPayto = URLBuilder().takeFrom("payto://iban/XX18500105173385245165")
                            badPayto.parameters["receiver-name"] = "John Smith"
                            val now = Instant.now()
                            nexusCmd.run("initiate-payment $flags --amount=$currency:0.22 --subject \"bad iban $now\" \"$badPayto\"")
                        }
                        put("tx-dummy-iban", "Initiate a new transaction to a dummy IBAN") {
                            val now = Instant.now()
                            nexusCmd.run("initiate-payment $flags --amount=$currency:0.23 --subject \"dummy iban $now\" \"$dummyPayto\"")
                        }
                        put("tx-check", "Check transaction semantic", "testing tx-check $flags")
                    }
                    Pair(suspend { nexusCmd.run("ebics-setup $debugFlags") }, cmds)
                }
            }
            
            
            while (true) {
                // Automatic setup
                if (host != null) {
                    var clientKeys = loadClientKeys(clientKeysPath)
                    val bankKeys = loadBankKeys(bankKeysPath)
                    if (!kind.test && clientKeys == null) {
                        msg("Manual setup is required for non test environment")
                    } else if (clientKeys == null || !clientKeys.submitted_ini || !clientKeys.submitted_hia || bankKeys == null || !bankKeys.accepted) {
                        step("Run EBICS setup")
                        if (!setup()) {
                            clientKeys = loadClientKeys(clientKeysPath)
                            if (kind.test) {
                                if (clientKeys == null || !clientKeys.submitted_ini || !clientKeys.submitted_hia) {
                                    msg("Got to ${kind.settings} and click on 'Reset EBICS user'")
                                } else {
                                    msg("Got to ${kind.settings} and click on 'Activate EBICS user'")
                                }
                            } else {
                                msg("Activate your keys at your bank")
                            }
                        }
                    }
                }
                // REPL
                val line = try {
                    reader.readLine("testbench> ")!!
                } catch (e: UserInterruptException) {
                    print(ANSI.red("^C"))
                    System.out.flush()
                    throw ProgramResult(1)
                }
                val args = line.split(WORDS_REGEX).toMutableList()
                val cmdArg = args.removeFirstOrNull()
                val cmd = cmds[cmdArg]
                if (cmd != null) {
                    step(cmd.first)
                    cmd.second(args)
                } else {
                    when (cmdArg) {
                        "" -> continue
                        "exit" -> break
                        "?", "help" -> {
                            println("Commands:")
                            println("  setup - Setup")
                            for ((name, cmd) in cmds) {
                                println("  $name - ${cmd.first}")
                            }
                        }
                        "setup" -> {
                            step("Setup")
                            setup()
                        }
                        else -> err("Unknown command '$cmdArg'")
                    }
                }
            }
        }
    }
}

fun main(args: Array<String>) {
    setupSecurityProperties()
    Cli().main(args)
}

typealias Cmds = Map<String, Pair<String, suspend (List<String>) -> Unit>>

data class CmdsBuilder(
    private val cmd: CliktCommand,
    val map: MutableMap<String, Pair<String, suspend (List<String>
) -> Unit>>) {
    fun putCmd(name: String, step: String, lambda: suspend (List<String>) -> Unit) {
        map[name] = Pair(step, lambda)
    }
    fun put(name: String, step: String, lambda: suspend () -> Unit) {
        putCmd(name = name, step = step, lambda = {
            lambda()
        })
    }
    fun put(name: String, step: String, args: String) {
        put(name, step) {
            cmd.run(args)
        }
    }
    fun putArgs(name: String, step: String, parser: (List<String>) -> String) {
        putCmd(name, step) { args: List<String> ->
            cmd.run(parser(args))
        }
    }
}

fun buildCmds(cmd: CliktCommand, actions: CmdsBuilder.() -> Unit): Cmds {
    val builder = CmdsBuilder(cmd, mutableMapOf())
    builder.actions()
    return builder.map
}