/*
 * 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 io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
import kotlinx.coroutines.runBlocking
import tech.libeufin.bank.*
import tech.libeufin.bank.db.AccountDAO.AccountCreationResult
import tech.libeufin.bank.db.Database
import tech.libeufin.common.*
import tech.libeufin.common.test.*
import tech.libeufin.common.db.dbInit
import tech.libeufin.common.db.pgDataSource
import java.nio.file.NoSuchFileException
import kotlin.io.path.Path
import kotlin.io.path.deleteExisting
import kotlin.io.path.readText
import kotlin.random.Random
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import java.time.Duration
import java.time.Instant

/* ----- Setup ----- */

val merchantPayto = IbanPayto.rand()
val exchangePayto = IbanPayto.rand()
val customerPayto = IbanPayto.rand()
val unknownPayto  = IbanPayto.rand()
var tmpPayTo      = IbanPayto.rand()
lateinit var adminPayto: String
val paytos = mapOf(
    "merchant" to merchantPayto, 
    "exchange" to exchangePayto, 
    "customer" to customerPayto
)

fun genTmpPayTo(): IbanPayto {
    tmpPayTo = IbanPayto.rand()
    return tmpPayTo
}

fun setup(
    conf: String = "test.conf",
    lambda: suspend (Database, BankConfig) -> Unit
) = runBlocking {
    globalTestTokens.clear()
    val cfg = bankConfig(Path("conf/$conf"))
    pgDataSource(cfg.dbCfg.dbConnStr).run {
        dbInit(cfg.dbCfg, "libeufin-bank", true)
        dbInit(cfg.dbCfg, "libeufin-nexus", true)
    }
    cfg.withDb { db, cfg ->
        db.conn { conn ->
            val sqlProcedures = Path("${cfg.dbCfg.sqlDir}/libeufin-conversion-setup.sql")
            conn.execSQLUpdate(sqlProcedures.readText())
        }
        lambda(db, cfg)
    }
}

fun bankSetup(
    conf: String = "test.conf",    
    lambda: suspend ApplicationTestBuilder.(Database) -> Unit
) = setup(conf) { db, cfg -> 
    // Creating the exchange and merchant accounts first.
    val bonus = TalerAmount.zero("KUDOS")
    assertIs<AccountCreationResult.Success>(db.account.create(
        username = "merchant",
        password = "merchant-password",
        name = "Merchant",
        internalPayto = merchantPayto,
        maxDebt = TalerAmount("KUDOS:10"),
        isTalerExchange = false,
        isPublic = false,
        bonus = bonus,
        checkPaytoIdempotent = false,
        email = null,
        phone = null,
        cashoutPayto = null,
        tanChannels = emptySet(),
        conversionRateClassId = null,
        pwCrypto = cfg.pwCrypto
    ))
    assertIs<AccountCreationResult.Success>(db.account.create(
        username = "exchange",
        password = "exchange-password",
        name = "Exchange",
        internalPayto = exchangePayto,
        maxDebt = TalerAmount("KUDOS:10"),
        isTalerExchange = true,
        isPublic = false,
        bonus = bonus,
        checkPaytoIdempotent = false,
        email = null,
        phone = null,
        cashoutPayto = null,
        tanChannels = emptySet(),
        conversionRateClassId = null,
        pwCrypto = cfg.pwCrypto
    ))
    assertIs<AccountCreationResult.Success>(db.account.create(
        username = "customer",
        password = "customer-password",
        name = "Customer",
        internalPayto = customerPayto,
        maxDebt = TalerAmount("KUDOS:10"),
        isTalerExchange = false,
        isPublic = false,
        bonus = bonus,
        checkPaytoIdempotent = false,
        email = null,
        phone = null,
        cashoutPayto = null,
        tanChannels = emptySet(),
        conversionRateClassId = null,
        pwCrypto = cfg.pwCrypto
    ))
    // Create admin account
    val result = assertIs<AccountCreationResult.Success>(createAdminAccount(db, cfg, "admin-password"))
    adminPayto = result.payto
    testApplication {
        application {
            corebankWebApp(db, cfg)
        }
        if (cfg.allowConversion) {
            // 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.26"
                    "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()
        }
        lambda(db)
        // GC everything
        db.gc.collect(Instant.now(), Duration.ZERO, Duration.ZERO, Duration.ZERO)
    }
}

fun dbSetup(lambda: suspend (Database) -> Unit) =
    setup { db, _ -> lambda(db) }

/* ----- Common actions ----- */

/** Set [account] debit threshold to [maxDebt] amount */
suspend fun ApplicationTestBuilder.setMaxDebt(account: String, maxDebt: String) {
    client.patchAdmin("/accounts/$account") { 
        json { "debit_threshold" to maxDebt }
    }.assertNoContent()
}

/** Check [account] balance is [amount], [amount] is prefixed with + for credit and - for debit */
suspend fun ApplicationTestBuilder.assertBalance(account: String, amount: String) {
    client.getAdmin("/accounts/$account").assertOkJson<AccountData> {
        val balance = it.balance
        val fmt = "${if (balance.credit_debit_indicator == CreditDebitInfo.debit) '-' else '+'}${balance.amount}"
        assertEquals(amount, fmt, "For $account")
    }
}

/** Check [account] tan channel and info */
suspend fun ApplicationTestBuilder.tanInfo(account: String): Pair<TanChannel?, String?> {
    val res = client.getA("/accounts/$account").assertOkJson<AccountData>()
    val channel: TanChannel? = res.tan_channel
    return Pair(channel, when (channel) {
        TanChannel.sms -> res.contact_data!!.phone.get()
        TanChannel.email -> res.contact_data!!.email.get()
        null -> null
    })
}

/** Perform a bank transaction of [amount] [from] account [to] account with [subject} */
suspend fun ApplicationTestBuilder.tx(from: String, amount: String, to: String, subject: String = "payout"): Long {
    return client.postA("/accounts/$from/transactions") {
        json {
            "payto_uri" to "${paytos[to] ?: tmpPayTo}?message=${subject.encodeURLParameter()}&amount=$amount"
        }
    }.maybeChallenge().assertOkJson<TransactionCreateResponse>().row_id
}

/** Perform a taler outgoing transaction of [amount] from exchange to merchant */
suspend fun ApplicationTestBuilder.transfer(amount: String, payto: IbanPayto = merchantPayto, metadata: String? = null) {
    client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
        json {
            "request_uid" to HashCode.rand()
            "amount" to TalerAmount(amount)
            "exchange_base_url" to "http://exchange.example.com/"
            "wtid" to ShortHashCode.rand()
            "credit_account" to payto
            "metadata" to metadata
        }
    }.assertOk()
}

/** Perform a taler incoming transaction of [amount] from merchant to exchange */
suspend fun ApplicationTestBuilder.addIncoming(amount: String) {
    client.postA("/accounts/exchange/taler-wire-gateway/admin/add-incoming") {
        json {
            "amount" to TalerAmount(amount)
            "reserve_pub" to EddsaPublicKey.randEdsaKey()
            "debit_account" to merchantPayto
        }
    }.assertOk()
}

/** Perform a taler kyc transaction of [amount] from merchant to exchange */
suspend fun ApplicationTestBuilder.addKyc(amount: String) {
    client.postA("/accounts/exchange/taler-wire-gateway/admin/add-kycauth") {
        json {
            "amount" to TalerAmount(amount)
            "account_pub" to EddsaPublicKey.randEdsaKey()
            "debit_account" to merchantPayto
        }
    }.assertOk()
}

/** Perform a cashout operation of [amount] from customer */
suspend fun ApplicationTestBuilder.cashout(amount: String) {
    val res = client.postA("/accounts/customer/cashouts") {
        json {
            "request_uid" to ShortHashCode.rand()
            "amount_debit" to amount
            "amount_credit" to convert(amount)
        }
    } 
    if (res.status == HttpStatusCode.Conflict) {
        // Retry with cashout info
        fillCashoutInfo("customer")
        client.postA("/accounts/customer/cashouts") {
            json {
                "request_uid" to ShortHashCode.rand()
                "amount_debit" to amount
                "amount_credit" to convert(amount)
            }
        } 
    } else { 
        res
    }.assertOk()
}

/** Perform a whithrawal operation of [amount] from customer */
suspend fun ApplicationTestBuilder.withdrawal(amount: String) {
    client.postA("/accounts/merchant/withdrawals") {
        json { "amount" to amount } 
    }.assertOkJson<BankAccountCreateWithdrawalResponse> {
        val uuid = it.taler_withdraw_uri.split("/").last()
        withdrawalSelect(uuid)
        client.postA("/accounts/merchant/withdrawals/${uuid}/confirm")
            .assertNoContent()
    }
}

suspend fun ApplicationTestBuilder.fillCashoutInfo(account: String) {
    client.patchAdmin("/accounts/$account") {
        json {
            "cashout_payto_uri" to unknownPayto
            "contact_data" to obj {
                "phone" to "+99"
            }
        }
    }.assertNoContent()
}

suspend fun ApplicationTestBuilder.fillTanInfo(username: String) {
    // Create a token before we require 2fa for it
    client.cachedToken(username)
    client.patchAdmin("/accounts/$username") {
        json {
            "contact_data" to obj {
                "phone" to "+${Random.nextInt(0, 10000)}"
            }
            "tan_channel" to "sms"
        }
    }.assertNoContent()
}

suspend fun ApplicationTestBuilder.withdrawalSelect(uuid: String): EddsaPublicKey {
    val reservePub = EddsaPublicKey.randEdsaKey()
    client.post("/taler-integration/withdrawal-operation/$uuid") {
        json {
            "reserve_pub" to reservePub
            "selected_exchange" to exchangePayto
        }
    }.assertOk()
    return reservePub
}

private var nbClass = 0;

suspend fun ApplicationTestBuilder.createConversionRateClass(
    cashout_min_amount: TalerAmount? = null
): Long {
    nbClass += 1
    return client.postAdmin("/conversion-rate-classes") {
        json {
            "name" to "Gen class $nbClass"
            "cashout_min_amount" to cashout_min_amount
        }
    }.assertOkJson<ConversionRateClassResponse>().conversion_rate_class_id
}


suspend fun ApplicationTestBuilder.convert(amount: String): TalerAmount {
    return client.get("/conversion-info/cashout-rate?amount_debit=$amount")
        .assertOkJson<ConversionResponse>().amount_credit
}

fun tanCode(info: String): String? {
    try {
        val file = Path("/tmp/tan-$info.txt")
        val code = file.readText().split(" ", limit=2).first()
        file.deleteExisting()
        return code
    } catch (e: Exception) {
        if (e is NoSuchFileException) return null
        throw e
    }
}


/* ----- Assert ----- */

suspend fun HttpResponse.maybeChallenge(): HttpResponse {
    return if (this.status == HttpStatusCode.Accepted) {
        this.assertChallenge()
    } else {
        this
    }
}

suspend fun HttpResponse.assertChallenge(
    check: suspend (ChallengeResponse) -> Unit = {}
): HttpResponse {
    val res = assertAcceptedJson<ChallengeResponse>()
    val username = call.request.url.segments[1]

    val challenge = res.challenges.random()

    if (res.combi_and) {
        for (challenge in res.challenges) {
            call.client.postA("/accounts/$username/challenge/${challenge.challenge_id}").assertOk()
        }
    } else {
        call.client.postA("/accounts/$username/challenge/${challenge.challenge_id}").assertOk()
    }

    check(res)

    if (res.combi_and) {
        for (challenge in res.challenges) {
            val code = assertNotNull(tanCode(challenge.tan_info))
            call.client.postA("/accounts/$username/challenge/${challenge.challenge_id}/confirm") {
                json { "tan" to code }
            }.assertNoContent()
        }
    } else {
        val code = assertNotNull(tanCode(challenge.tan_info))
        call.client.postA("/accounts/$username/challenge/${challenge.challenge_id}/confirm") {
            json { "tan" to code }
        }.assertNoContent()
    }

    // Recover body from request
    val requestBody = this.request.content
    val ids = res.challenges.map { it.challenge_id }.joinToString(", ")
    return call.client.request(this.call.request.url) {
        tokenAuth(call.client, username)
        method = call.request.method
        headers[TALER_CHALLENGE_IDS] = ids
        setBody(requestBody)
    }
}

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

/* ----- Random data generation ----- */

fun randBase32Crockford(length: Int) = Base32Crockford.encode(ByteArray(length).rand())
