/*
 * 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/>
 */

import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.testing.test
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.coroutines.runBlocking
import org.junit.Test
import tech.libeufin.bank.BankAccountTransactionsResponse
import tech.libeufin.bank.CashoutResponse
import tech.libeufin.bank.ConversionResponse
import tech.libeufin.bank.RegisterAccountResponse
import tech.libeufin.bank.cli.LibeufinBank
import tech.libeufin.common.*
import tech.libeufin.common.db.*
import tech.libeufin.common.test.*
import tech.libeufin.common.api.engine
import tech.libeufin.nexus.*
import tech.libeufin.nexus.cli.LibeufinNexus
import tech.libeufin.nexus.cli.registerIncomingPayment
import tech.libeufin.nexus.iso20022.*
import java.time.Instant
import kotlin.io.path.Path
import kotlin.io.path.readText
import kotlin.test.*
import tech.libeufin.nexus.db.Database as NexusDb

const val UNIX_SOCKET_PATH: String = "/tmp/libeufin.sock";

fun CliktCommand.run(cmd: String) {
    val result = test(cmd)
    if (result.statusCode != 0)
        throw Exception(result.output)
    println(result.output)
}

fun HttpResponse.assertNoContent() {
    assertEquals(HttpStatusCode.NoContent, this.status)
}

fun server(client: HttpClient, lambda: () -> Unit) {
    globalTestTokens.clear()
    // Start the HTTP server in another thread
    kotlin.concurrent.thread(isDaemon = true)  {
        lambda()
    }
    // Wait for the HTTP server to be up
    runBlocking {
        client.get("/config")
    }
   
}

fun setup(conf: String, lambda: suspend (NexusDb) -> Unit) {
    try {
        runBlocking {
            nexusConfig(Path(conf)).withDb { db, _ ->
                lambda(db)
            }
        }
    } finally {
        engine?.stop(0, 0) // Stop http server if started
    }
}

inline fun assertException(msg: String, lambda: () -> Unit) {
    try {
        lambda()
        throw Exception("Expected failure: $msg")
    } catch (e: Exception) {
        assert(e.message!!.startsWith(msg)) { "${e.message}" }
    }
}

class IntegrationTest {
    val nexusCmd = LibeufinNexus()
    val bankCmd = LibeufinBank()
    val client = HttpClient(CIO) {
        install(HttpRequestRetry) {
            maxRetries = 10
            constantDelay(200, 100)
        }
        defaultRequest {
            url("http://socket/")
            unixSocket(UNIX_SOCKET_PATH)
        }
    }

    @Test
    fun mini() {
        val client = HttpClient(CIO) {
            install(HttpRequestRetry) {
                maxRetries = 10
                constantDelay(200, 100)
            }
            defaultRequest {
                url("http://0.0.0.0:8080/")
            }
        }
        val flags = "-c conf/mini.conf -L DEBUG"
        bankCmd.run("dbinit $flags -r")
        bankCmd.run("passwd admin admin-password $flags")
        bankCmd.run("dbinit $flags") // Idempotent
        
        server(client) {
            bankCmd.run("serve $flags")
        }
        
        setup("conf/mini.conf") {
            // Check bank is running
            client.get("/public-accounts").assertNoContent()
        }

        bankCmd.run("gc $flags")

        server(client) {
            nexusCmd.run("serve $flags")
        }
        engine?.stop(0, 0) 
    }


    @Test
    fun errors() {
        val flags = "-c conf/integration.conf -L DEBUG"
        nexusCmd.run("dbinit $flags -r")
        bankCmd.run("dbinit $flags -r")
        bankCmd.run("passwd admin admin-password $flags")

        suspend fun NexusDb.checkCount(nbIncoming: Int, nbBounce: Int, nbTalerable: Int) {
            serializable(
                """
                    SELECT (SELECT count(*) FROM incoming_transactions) AS incoming,
                        (SELECT count(*) FROM bounced_transactions) AS bounce,
                        (SELECT count(*) FROM talerable_incoming_transactions) AS talerable;
                """
            ) {
                one {
                    assertEquals(
                        Triple(nbIncoming, nbBounce, nbTalerable),
                        Triple(it.getInt("incoming"), it.getInt("bounce"), it.getInt("talerable"))
                    )
                }
            }
        }

        setup("conf/integration.conf") { db ->
            val cfg = NexusIngestConfig.default(AccountType.exchange)
            val userPayTo = IbanPayto.rand("Sir Florian")
    
            // Load conversion setup manually as the server would refuse to start without an exchange account
            val sqlProcedures = Path("../database-versioning/libeufin-conversion-setup.sql")
            db.conn { 
                it.execSQLUpdate(sqlProcedures.readText())
                it.execSQLUpdate("SET search_path TO libeufin_nexus;")
            }

            val reservePub = EddsaPublicKey.randEdsaKey()
            val reservePayment = IncomingPayment(
                amount = TalerAmount("EUR:10"),
                debtor = userPayTo,
                subject = "Error test $reservePub",
                executionTime = Instant.now(),
                id = IncomingId(null, "reserve_error", null)
            )

            assertException("ERROR: cashin failed: missing exchange account") {
                registerIncomingPayment(db, cfg, reservePayment)
            }
            db.checkCount(0, 0, 0)

            // But KYC works
            registerIncomingPayment(
                db, cfg, 
                reservePayment.copy(
                    id = IncomingId(null, "kyc", null),
                    subject = "Error test KYC:${EddsaPublicKey.randEdsaKey()}"
                )
            )
            db.checkCount(1, 0, 1)

            // Create exchange account
            bankCmd.run("create-account $flags -u exchange -p exchange-password --name 'Mr Money' --exchange")
    
          
            // Missing rates
            registerIncomingPayment(db, cfg, reservePayment.copy(id = IncomingId(null, "rate_error", null)))
            db.checkCount(2, 1, 1)

            // Start server
            server(client) {
                bankCmd.run("serve $flags")
            }

            // Set conversion rates
            client.postAdmin("/conversion-info/conversion-rate") {
                json {
                    "cashin_ratio" to "0.8"
                    "cashin_fee" to "KUDOS:0.02"
                    "cashin_tiny_amount" to "KUDOS:0.01"
                    "cashin_rounding_mode" to "nearest"
                    "cashin_min_amount" to "EUR:0"
                    "cashout_ratio" to "1.25"
                    "cashout_fee" to "EUR:0.003"
                    "cashout_tiny_amount" to "EUR:0.01"
                    "cashout_rounding_mode" to "zero"
                    "cashout_min_amount" to "KUDOS:0.1"
                }
            }.assertNoContent()
            
            assertException("ERROR: cashin failed: admin balance insufficient") {
                db.payment.registerTalerableIncoming(reservePayment, IncomingSubject.Reserve(reservePub))
            }

            // Allow admin debt
            bankCmd.run("edit-account admin --debit_threshold KUDOS:100 $flags")

            // Too small amount
            db.checkCount(2, 1, 1)
            registerIncomingPayment(db, cfg, reservePayment.copy(
                amount = TalerAmount("EUR:0.01"),
            ))
            db.checkCount(3, 2, 1)
            client.getA("/accounts/exchange/transactions").assertNoContent()

            // Check success
            val validPayment = reservePayment.copy(
                subject = "Success $reservePub",
                id = IncomingId(null, "success", null),
            )
            registerIncomingPayment(db, cfg, validPayment)
            db.checkCount(4, 2, 2)
            client.getA("/accounts/exchange/transactions")
                .assertOkJson<BankAccountTransactionsResponse>()

            // Check idempotency
            registerIncomingPayment(db, cfg, validPayment)
            registerIncomingPayment(db, cfg, validPayment.copy(
                subject="Success 2 $reservePub"
            ))
            db.checkCount(4, 2, 2)
        }
    }

    @Test
    fun conversion() {
        suspend fun NexusDb.checkInitiated(amount: TalerAmount, name: String?) {
            serializable(
                """
                SELECT 
                    (amount).val AS amount_val,
                    (amount).frac AS amount_frac,
                    credit_payto,
                    subject
                FROM initiated_outgoing_transactions
                ORDER BY initiation_time DESC
                """
            ) {
                one {
                    val am = it.getAmount("amount", amount.currency)
                    println(it.getString("credit_payto"))
                    val payto = it.getIbanPayto("credit_payto")
                    val subject = it.getString("subject")
                    assertEquals(amount, am)
                    assertEquals(payto.receiverName, name)
                }
            }
        }
        val flags = "-c conf/integration.conf -L DEBUG"
        nexusCmd.run("dbinit $flags -r")
        bankCmd.run("dbinit $flags -r")
        bankCmd.run("passwd admin admin-password $flags")
        bankCmd.run("edit-account admin --debit_threshold KUDOS:1000 $flags")
        bankCmd.run("create-account $flags -u exchange -p exchange-password --name 'Mr Money' --exchange")
        nexusCmd.run("dbinit $flags") // Idempotent
        bankCmd.run("dbinit $flags") // Idempotent

        server(client) {
            bankCmd.run("serve $flags")
        }
        
        setup("conf/integration.conf") { db -> 
            val userPayTo = IbanPayto.rand("Sir Christian")
            val fiatPayTo = IbanPayto.rand()

            // Create user
            client.postAdmin("/accounts") {
                json {
                    "username" to "customer"
                    "password" to "customer-password"
                    "name" to "John Smith"
                    "internal_payto_uri" to userPayTo
                    "cashout_payto_uri" to fiatPayTo
                    "debit_threshold" to "KUDOS:100"
                    "contact_data" to obj {
                        "phone" to "+99"
                    }
                }
            }.assertOkJson<RegisterAccountResponse>()

            // Set conversion rates
            client.postAdmin("/conversion-info/conversion-rate") {
                json {
                    "cashin_ratio" to "0.8"
                    "cashin_fee" to "KUDOS:0.02"
                    "cashin_tiny_amount" to "KUDOS:0.01"
                    "cashin_rounding_mode" to "nearest"
                    "cashin_min_amount" to "EUR:0"
                    "cashout_ratio" to "1.25"
                    "cashout_fee" to "EUR:0.003"
                    "cashout_tiny_amount" to "EUR:0.01"
                    "cashout_rounding_mode" to "zero"
                    "cashout_min_amount" to "KUDOS:0.1"
                }
            }.assertNoContent()

            // Cashin
            repeat(3) { i ->
                val reservePub = EddsaPublicKey.randEdsaKey()
                val amount = TalerAmount("EUR:${20+i}")
                val subject = "cashin test $i: $reservePub"
                nexusCmd.run("testing fake-incoming $flags --subject \"$subject\" --amount $amount $userPayTo")
                val converted = client.get("/conversion-info/cashin-rate?amount_debit=EUR:${20 + i}")
                    .assertOkJson<ConversionResponse>().amount_credit
                client.getA("/accounts/exchange/transactions").assertOkJson<BankAccountTransactionsResponse> {
                    val tx = it.transactions.first()
                    assertEquals(subject, tx.subject)
                    assertEquals(converted, tx.amount)
                }
                client.getA("/accounts/exchange/taler-wire-gateway/history/incoming").assertOkJson<IncomingHistory> {
                    val tx = it.incoming_transactions.first()
                    assertEquals(converted, tx.amount)
                    assertIs<IncomingReserveTransaction>(tx)
                    assertEquals(reservePub, tx.reserve_pub)
                }
            }

            // Cashout
            repeat(3) { i ->  
                val requestUid = ShortHashCode.rand()
                val amount = TalerAmount("KUDOS:${10+i}")
                val converted = client.get("/conversion-info/cashout-rate?amount_debit=$amount")
                    .assertOkJson<ConversionResponse>().amount_credit
                client.postA("/accounts/customer/cashouts") {
                    json {
                        "request_uid" to requestUid
                        "amount_debit" to amount
                        "amount_credit" to converted
                    }
                }.assertOkJson<CashoutResponse>()
                db.checkInitiated(converted, "John Smith")
            }

            // Exchange bounce no name
            repeat(3) { i ->
                val reservePub = EddsaPublicKey.randEdsaKey()
                val amount = TalerAmount("EUR:${30+i}")
                val subject = "exchange bounce test $i: $reservePub"

                // Cashin
                nexusCmd.run("testing fake-incoming $flags --subject \"$subject\" --amount $amount $userPayTo")
                val converted = client.get("/conversion-info/cashin-rate?amount_debit=EUR:${30 + i}")
                    .assertOkJson<ConversionResponse>().amount_credit
                client.getA("/accounts/exchange/transactions").assertOkJson<BankAccountTransactionsResponse> {
                    val tx = it.transactions.first()
                    assertEquals(subject, tx.subject)
                    assertEquals(converted, tx.amount)
                }
                client.getA("/accounts/exchange/taler-wire-gateway/history/incoming").assertOkJson<IncomingHistory> {
                    val tx = it.incoming_transactions.first()
                    assertEquals(converted, tx.amount)
                    assertIs<IncomingReserveTransaction>(tx)
                    assertEquals(reservePub, tx.reserve_pub)
                }

                // Bounce
                client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
                    json {
                        "request_uid" to HashCode.rand()
                        "amount" to converted
                        "exchange_base_url" to "http://exchange.example.com/"
                        "wtid" to reservePub
                        "credit_account" to "payto://x-taler-bank/localhost/admin"
                    }
                }.assertOkJson<TransferResponse>()
                
                db.checkInitiated(amount, "Sir Christian")
            }
            
            // Exchange bounce with name
            repeat(3) { i ->
                val reservePub = EddsaPublicKey.randEdsaKey()
                val amount = TalerAmount("EUR:${40+i}")
                val subject = "exchange bounce test $i: $reservePub"

                // Cashin
                nexusCmd.run("testing fake-incoming $flags --subject \"$subject\" --amount $amount $userPayTo")
                val converted = client.get("/conversion-info/cashin-rate?amount_debit=EUR:${40 + i}")
                    .assertOkJson<ConversionResponse>().amount_credit
                client.getA("/accounts/exchange/transactions").assertOkJson<BankAccountTransactionsResponse> {
                    val tx = it.transactions.first()
                    assertEquals(subject, tx.subject)
                    assertEquals(converted, tx.amount)
                }
                client.getA("/accounts/exchange/taler-wire-gateway/history/incoming").assertOkJson<IncomingHistory> {
                    val tx = it.incoming_transactions.first()
                    assertEquals(converted, tx.amount)
                    assertIs<IncomingReserveTransaction>(tx)
                    assertEquals(reservePub, tx.reserve_pub)
                }

                // Bounce
                client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
                    json {
                        "request_uid" to HashCode.rand()
                        "amount" to converted
                        "exchange_base_url" to "http://exchange.example.com/"
                        "wtid" to reservePub
                        "credit_account" to "payto://x-taler-bank/localhost/admin"
                    }
                }.assertOkJson<TransferResponse>()
                
                db.checkInitiated(amount, "Sir Christian")
            }
        }
    }
}
