/*
 * This file is part of LibEuFin.
 * Copyright (C) 2023-2024 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.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.*
import java.time.Instant
import kotlin.io.path.*
import org.jline.terminal.*
import org.jline.reader.*
import org.jline.reader.impl.history.*

val nexusCmd = LibeufinNexus()
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 platform by argument()

    override fun run() {
        // List available platform
        val platforms = Path("test").listDirectoryEntries().filter { it.isDirectory() }.map { it.fileName.toString() }
        if (!platforms.contains(platform)) {
            println("Unknown platform '$platform', expected one of $platforms")
            throw ProgramResult(1)
        }

        // Augment config
        val simpleCfg = Path("test/$platform/ebics.conf").readText()
        val conf = Path("test/$platform/ebics.edited.conf")
        conf.writeText(
        """$simpleCfg
        [paths]
        LIBEUFIN_NEXUS_HOME = test/$platform

        [nexus-fetch]
        FREQUENCY = 5m
        CHECKPOINT_TIME_OF_DAY = 16:52

        [nexus-submit]
        FREQUENCY = 5m

        [libeufin-nexusdb-postgres]
        CONFIG = postgres:///libeufintestbench
        """)
        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/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"

        // 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)
            }
        }

        runBlocking {
            step("Init ${kind.name}")
            
            assert(nexusCmd.run("dbinit $flags"))

            val cmds = buildMap {
                fun putCmd(name: String, step: String, lambda: suspend (List<String>) -> Unit) {
                    put(name, Pair(step, lambda))
                }
                fun put(name: String, step: String, lambda: suspend () -> Unit) {
                    putCmd(name, step, { 
                        lambda()
                        Unit
                    })
                }
                fun put(name: String, step: String, args: String) {
                    put(name, step, {
                        nexusCmd.run(args)
                        Unit
                    })
                }
                fun putArgs(name: String, step: String, parser: (List<String>) -> String) {
                    putCmd(name, step, { args: List<String> ->
                        nexusCmd.run(parser(args))
                        Unit
                    })
                }
                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", "testing list $flags incoming")
                put("list-outgoing", "List outgoing transaction", "testing list $flags outgoing")
                put("list-initiated", "List initiated payments", "testing list $flags initiated")
                put("wss", "Listen to notification over websocket", "testing wss $debugFlags")
                put("submit", "Submit pending transactions", "ebics-submit $ebicsFlags")
                put("export", "Export pending batches as pain001 messages", "manual export $flags payments.zip")
                put("setup", "Setup", "ebics-setup $debugFlags")
                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()
                    Unit
                }
                put("tx", "Initiate a new transaction") {
                    val now = Instant.now()
                    nexusCmd.run("initiate-payment $flags --amount=$currency:0.1 --subject \"single $now\" \"$payto\"")
                    Unit
                }
                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\"")
                    Unit
                }
                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\"")
                    Unit
                }
                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\"")
                    Unit
                }
                put("tx-check", "Check transaction semantic", "testing tx-check $flags")
            }
            while (true) {
                // Automatic setup
                if (host != null) {
                    var clientKeys = loadClientKeys(clientKeysPath)
                    val bankKeys = loadBankKeys(bankKeysPath)
                    if (!kind.test && clientKeys == null) {
                        throw Exception("Clients keys are required to run netzbon tests")
                    } else if (clientKeys == null || !clientKeys.submitted_ini || !clientKeys.submitted_hia || bankKeys == null || !bankKeys.accepted) {
                        step("Run EBICS setup")
                        if (!nexusCmd.run("ebics-setup --auto-accept-keys $debugFlags")) {
                            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:")
                            for ((name, cmd) in cmds) {
                                println("  $name - ${cmd.first}")
                            }
                        }
                        else -> err("Unknown command '$cmdArg'")
                    }
                }
            }
        }
    }
}

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