/*
 * 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.bank.api

import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.github.smiley4.ktoropenapi.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.future.await
import kotlinx.coroutines.withContext
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import tech.libeufin.bank.*
import tech.libeufin.bank.auth.*
import tech.libeufin.bank.db.*
import tech.libeufin.bank.db.AccountDAO.*
import tech.libeufin.bank.db.CashoutDAO.CashoutCreationResult
import tech.libeufin.bank.db.TanDAO.*
import tech.libeufin.bank.db.TokenDAO.TokenCreationResult
import tech.libeufin.bank.db.TransactionDAO.BankTransactionResult
import tech.libeufin.bank.db.WithdrawalDAO.*
import tech.libeufin.bank.db.ConversionDAO.*
import tech.libeufin.common.*
import tech.libeufin.common.crypto.*
import java.time.Duration
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.*

private val logger: Logger = LoggerFactory.getLogger("libeufin-bank-api")

fun Routing.coreBankApi(db: Database, cfg: BankConfig) {
    get("/config", {
        operationId = "getConfig"
        description = "Get bank configuration"
        tags = listOf("Core Bank")
        response {
            code(HttpStatusCode.OK) { description = "Bank configuration"; body<Config>() }
        }
    }) {
        call.respond(
            Config(
                bank_name = cfg.name,
                base_url = cfg.baseUrl,
                currency = cfg.regionalCurrency,
                currency_specification = cfg.regionalCurrencySpec,
                allow_conversion = cfg.allowConversion,
                allow_registrations = cfg.allowRegistration,
                allow_deletions = cfg.allowAccountDeletion,
                default_debit_threshold = cfg.defaultDebtLimit,
                supported_tan_channels = cfg.tanChannels.keys,
                allow_edit_name = cfg.allowEditName,
                allow_edit_cashout_payto_uri = cfg.allowEditCashout,
                wire_type = cfg.wireMethod,
                wire_transfer_fees = cfg.wireTransferFees,
                min_wire_transfer_amount = cfg.minAmount,
                max_wire_transfer_amount = cfg.maxAmount
            )
        )
    }
    authAdmin(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat) {
        get("/monitor", {
            operationId = "getMonitor"
            description = "Get bank monitoring data (admin only)"
            tags = listOf("Core Bank")
            protected = true
            securitySchemeNames("bearerAuth", "basicAuth")
            request {
                queryParameter<String>("timeframe") {
                    description = "Timeframe for monitoring data (hour, day, month, year). Default: hour"
                    required = false
                }
                queryParameter<Int>("which") {
                    description = "Deprecated. Which period to query within the timeframe"
                    required = false
                    deprecated = true
                }
                queryParameter<Long>("date_s") {
                    description = "Timestamp in seconds to specify the period"
                    required = false
                }
            }
            response {
                code(HttpStatusCode.OK) { description = "Monitoring data"; body<MonitorResponse>() }
            }
        }) {
            val params = MonitorParams.extract(call.request.queryParameters)
            call.respond(db.monitor(params))
        }
    }
    coreBankTokenApi(db, cfg)
    coreBankAccountsApi(db, cfg)
    coreBankTransactionsApi(db, cfg)
    coreBankWithdrawalApi(db, cfg)
    coreBankCashoutApi(db, cfg)
    coreBankTanApi(db, cfg)
    coreBankConversionApi(db, cfg)
}

private fun Routing.coreBankTokenApi(db: Database, cfg: BankConfig) {
    val TOKEN_DEFAULT_DURATION: Duration = Duration.ofDays(1L)
    auth(db, cfg.pwCrypto, TokenLogicalScope.refreshable, cfg.basicAuthCompat, allowPw = true) {
        post("/accounts/{USERNAME}/token", {
            operationId = "createToken"
            description = "Create or refresh an authentication token"
            tags = listOf("Core Bank - Tokens")
            protected = true
            securitySchemeNames("bearerAuth", "basicAuth")
            request {
                pathParameter<String>("USERNAME") { description = "Account username" }
                body<TokenRequest>()
            }
            response {
                code(HttpStatusCode.OK) { description = "Token created"; body<TokenSuccessResponse>() }
            }
        }) {
            val (req, challenge) = call.receiveChallenge<TokenRequest>(db, Operation.create_token)

            val existingToken = call.authToken
            if (existingToken != null) {
                // This block checks permissions ONLY IF the call was authenticated with a token
                val refreshingToken = db.token.access(existingToken, Instant.now()) ?: throw internalServerError(
                    "Token used to auth not found in the database!"
                )
                if (!validScope(req.scope.logical(), refreshingToken.scope))
                    throw forbidden(
                        "Impossible to refresh a token with a larger scope",
                        TalerErrorCode.GENERIC_TOKEN_PERMISSION_INSUFFICIENT
                    )
            }
            val token = Base32Crockford32B.secureRand()
            val tokenDuration: Duration = req.duration?.duration ?: TOKEN_DEFAULT_DURATION

            val creationTime = Instant.now()
            val expirationTimestamp = 
                if (tokenDuration == ChronoUnit.FOREVER.duration) {
                    Instant.MAX
                } else {
                    try {
                        creationTime.plus(tokenDuration)
                    } catch (e: Exception) {
                        throw badRequest("Bad token duration: ${e.message}")
                    }
                }
            when (db.token.create(
                username = call.pathUsername,
                content = token.raw,
                creationTime = creationTime,
                expirationTime = expirationTimestamp,
                scope = req.scope,
                isRefreshable = req.refreshable,
                description = req.description,
                is2fa = existingToken != null || challenge != null 
            )) {
                TokenCreationResult.TanRequired -> call.respondMfa(db, Operation.create_token)
                TokenCreationResult.Success -> call.respond(
                    TokenSuccessResponse(
                        access_token = "$TOKEN_PREFIX$token",
                        expiration = TalerTimestamp(expirationTimestamp)
                    )
                )
            }
        }
    }
    auth(db, cfg.pwCrypto, TokenLogicalScope.readwrite, cfg.basicAuthCompat, allowAdmin = true) {
        delete("/accounts/{USERNAME}/tokens/{TOKEN_ID}", {
            operationId = "deleteTokenById"
            description = "Delete a specific authentication token by ID"
            tags = listOf("Core Bank - Tokens")
            protected = true
            securitySchemeNames("bearerAuth", "basicAuth")
            request {
                pathParameter<String>("USERNAME") { description = "Account username" }
                pathParameter<Long>("TOKEN_ID") { description = "Token identifier" }
            }
            response {
                code(HttpStatusCode.NoContent) { description = "Token deleted" }
                code(HttpStatusCode.NotFound) { description = "Token not found" }
            }
        }) {
            val id = call.longPath("TOKEN_ID")
            if (db.token.deleteById(id)) {
                call.respond(HttpStatusCode.NoContent)
            } else {
                throw notFound(
                    "Token '$id' not found",
                    TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
                )
            }
        }
    }
    auth(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat) {
        delete("/accounts/{USERNAME}/token", {
            operationId = "deleteToken"
            description = "Delete the current authentication token"
            tags = listOf("Core Bank - Tokens")
            protected = true
            securitySchemeNames("bearerAuth", "basicAuth")
            request {
                pathParameter<String>("USERNAME") { description = "Account username" }
            }
            response {
                code(HttpStatusCode.NoContent) { description = "Token deleted" }
            }
        }) {
            val token = call.authToken ?: throw badRequest("Basic auth not supported here.")
            db.token.delete(token)
            call.respond(HttpStatusCode.NoContent)
        }
    }
    auth(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat, allowAdmin = true) {
        get("/accounts/{USERNAME}/tokens", {
            operationId = "listTokens"
            description = "List authentication tokens for an account"
            tags = listOf("Core Bank - Tokens")
            protected = true
            securitySchemeNames("bearerAuth", "basicAuth")
            request {
                pathParameter<String>("USERNAME") { description = "Account username" }
                queryParameter<Int>("limit") {
                    description = "Maximum number of results to return (default: -20, negative means descending)"
                    required = false
                }
                queryParameter<Long>("offset") {
                    description = "Row ID offset for pagination"
                    required = false
                }
            }
            response {
                code(HttpStatusCode.OK) { description = "List of tokens"; body<TokenInfos>() }
                code(HttpStatusCode.NoContent) { description = "No tokens found" }
            }
        }) {
            val params = PageParams.extract(call.request.queryParameters)
            val tokens = db.token.page(params, call.pathUsername, Instant.now())
            if (tokens.isEmpty()) {
                call.respond(HttpStatusCode.NoContent)
            } else {
                call.respond(TokenInfos(tokens))
            }
        }
    }
}

suspend fun createAccount(
    db: Database, 
    cfg: BankConfig, 
    req: RegisterAccountRequest, 
    isAdmin: Boolean
): AccountCreationResult  {
    // Prohibit reserved usernames:
    if (RESERVED_ACCOUNTS.contains(req.username))
        throw conflict(
            "Username '${req.username}' is reserved",
            TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT
        )

    // Check tan channels has the corresponding info
    val channels = req.channels

    if (!isAdmin) {
        if (req.debit_threshold != null)
            throw conflict(
                "only admin account can choose the debit limit",
                TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT
            )
        
        if (req.conversion_rate_class_id != null)
            throw conflict(
                "only admin account can choose the conversion rate class",
                TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS
            )

        if (channels.isNotEmpty())
            throw conflict(
                "only admin account can enable 2fa on creation",
                TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL
            )
    }

    for (channel in channels) {
        if (cfg.tanChannels[channel] == null) {
            throw unsupportedTanChannel(channel)
        }
        val info = when (channel) {
            TanChannel.sms -> req.contact_data?.phone?.get()
            TanChannel.email -> req.contact_data?.email?.get()
        }
        if (info == null) {
            throw conflict(
                "missing info for tan channel $channel",
                TalerErrorCode.BANK_MISSING_TAN_INFO
            )
        }
    }

    if (req.username == "exchange" && !req.is_taler_exchange)
        throw conflict(
            "'exchange' account must be a taler exchange account",
            TalerErrorCode.END
        )
    
    val password = req.password.checkPw(cfg.pwdCheckQuality)

    suspend fun doDb(internalPayto: Payto) = db.account.create(
        username = req.username,
        name = req.name,
        email = req.contact_data?.email?.get(),
        phone = req.contact_data?.phone?.get(),
        cashoutPayto = req.cashout_payto_uri,
        password = password.pw,
        internalPayto = internalPayto,
        isPublic = req.is_public,
        isTalerExchange = req.is_taler_exchange,
        maxDebt = req.debit_threshold ?: cfg.defaultDebtLimit,
        bonus = if (!req.is_taler_exchange) cfg.registrationBonus 
                else TalerAmount(0, 0, cfg.regionalCurrency),
        tanChannels = req.channels,
        checkPaytoIdempotent = req.payto_uri != null,
        pwCrypto = cfg.pwCrypto,
        conversionRateClassId = req.conversion_rate_class_id
    )

    when (cfg.wireMethod) {
        WireMethod.IBAN -> {
            req.payto_uri?.expectIban()
            var retry = if (req.payto_uri == null) IBAN_ALLOCATION_RETRY_COUNTER else 0

            while (true) {
                val internalPayto = req.payto_uri ?: IbanPayto.rand() as Payto
                val res = doDb(internalPayto)
                // Retry with new IBAN
                if (res == AccountCreationResult.PayToReuse && retry > 0) {
                    retry--
                    continue
                }
                return res
            }
        }
        WireMethod.X_TALER_BANK -> {
            if (req.payto_uri != null) {
                val payto = req.payto_uri.expectXTalerBank()
                if (payto.username != req.username)
                    throw badRequest("Expected a payto uri for '${req.username}' got one for '${payto.username}'")
            }
         
            val internalPayto = XTalerBankPayto.forUsername(req.username)
            return doDb(internalPayto)
        }
    }
}

suspend fun patchAccount(
    db: Database, 
    cfg: BankConfig, 
    req: AccountReconfiguration, 
    username: String, 
    isAdmin: Boolean, 
    is2fa: Boolean
): AccountPatchResult {
    req.debit_threshold?.run { cfg.checkRegionalCurrency(this) }

    if (username == "admin" && req.is_public == true)
        throw conflict(
            "'admin' account cannot be public",
            TalerErrorCode.END
        )

    if (username == "exchange" && req.is_taler_exchange == false)
        throw conflict(
            "'exchange' account must be a taler exchange account",
            TalerErrorCode.END
        )
    
    val channels = req.channels.get()

     if (channels != null) {
        for (channel in channels) {
            if (cfg.tanChannels[channel] == null) {
                throw unsupportedTanChannel(channel)
            }
        }
    }
    
    return db.account.reconfig( 
        username = username,
        req = req,
        isAdmin = isAdmin,
        is2fa = is2fa,
        allowEditName = cfg.allowEditName,
        allowEditCashout = cfg.allowEditCashout
    )
}

private fun Routing.coreBankAccountsApi(db: Database, cfg: BankConfig) {
    authAdmin(db, cfg.pwCrypto, TokenLogicalScope.readwrite, cfg.basicAuthCompat, !cfg.allowRegistration) {
        post("/accounts", {
            operationId = "createAccount"
            description = "Create a new bank account"
            tags = listOf("Core Bank - Accounts")
            protected = true
            securitySchemeNames("bearerAuth", "basicAuth")
            request {
                body<RegisterAccountRequest>()
            }
            response {
                code(HttpStatusCode.OK) { description = "Account created"; body<RegisterAccountResponse>() }
                code(HttpStatusCode.Conflict) { description = "Account creation conflict" }
            }
        }) {
            val req = call.receive<RegisterAccountRequest>()
            when (val result = createAccount(db, cfg, req, call.isAdmin)) {
                AccountCreationResult.BonusBalanceInsufficient -> throw conflict(
                    "Insufficient admin funds to grant bonus",
                    TalerErrorCode.BANK_UNALLOWED_DEBIT
                )
                AccountCreationResult.UsernameReuse -> throw conflict(
                    "Account username reuse '${req.username}'",
                    TalerErrorCode.BANK_REGISTER_USERNAME_REUSE
                )
                AccountCreationResult.PayToReuse -> throw conflict(
                    "Bank internalPayToUri reuse",
                    TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE
                )
                AccountCreationResult.UnknownConversionClass -> 
                    throw unknownConversionClass(req.conversion_rate_class_id)
                is AccountCreationResult.Success -> call.respond(RegisterAccountResponse(result.payto))
            } 
        }
    }
    auth(
        db,
        cfg.pwCrypto, 
        TokenLogicalScope.readwrite,
        cfg.basicAuthCompat,
        allowAdmin = true,
        requireAdmin = !cfg.allowAccountDeletion
    ) {
        delete("/accounts/{USERNAME}", {
            operationId = "deleteAccount"
            description = "Delete a bank account"
            tags = listOf("Core Bank - Accounts")
            protected = true
            securitySchemeNames("bearerAuth", "basicAuth")
            request {
                pathParameter<String>("USERNAME") { description = "Account username" }
            }
            response {
                code(HttpStatusCode.NoContent) { description = "Account deleted" }
                code(HttpStatusCode.NotFound) { description = "Account not found" }
                code(HttpStatusCode.Conflict) { description = "Account balance not zero or reserved" }
            }
        }) {
            val (_, challenge) = call.receiveChallenge(db, Operation.account_delete, Unit)

            // Not deleting reserved names.
            if (RESERVED_ACCOUNTS.contains(call.pathUsername))
                throw conflict(
                    "Cannot delete reserved accounts",
                    TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT
                )
            if (call.pathUsername == "exchange" && cfg.allowConversion)
                throw conflict(
                    "Cannot delete 'exchange' accounts when conversion is enabled",
                    TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT
                )

            when (db.account.delete(call.pathUsername, call.isAdmin || challenge != null)) {
                AccountDeletionResult.UnknownAccount -> throw unknownAccount(call.pathUsername)
                AccountDeletionResult.BalanceNotZero -> throw conflict(
                    "Account balance is not zero.",
                    TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO
                )
                AccountDeletionResult.TanRequired -> call.respondMfa(db, Operation.account_delete)
                AccountDeletionResult.Success -> call.respond(HttpStatusCode.NoContent)
            }
        }
    }
    auth(db, cfg.pwCrypto, TokenLogicalScope.readwrite, cfg.basicAuthCompat, allowAdmin = true) {
        patch("/accounts/{USERNAME}", {
            operationId = "reconfigureAccount"
            description = "Reconfigure a bank account"
            tags = listOf("Core Bank - Accounts")
            protected = true
            securitySchemeNames("bearerAuth", "basicAuth")
            request {
                pathParameter<String>("USERNAME") { description = "Account username" }
                body<AccountReconfiguration>()
            }
            response {
                code(HttpStatusCode.NoContent) { description = "Account reconfigured" }
                code(HttpStatusCode.NotFound) { description = "Account not found" }
                code(HttpStatusCode.Conflict) { description = "Reconfiguration conflict" }
            }
        }) {
            val (req, pendingValidation) = call.receiveChallenge<AccountReconfiguration>(db, Operation.account_reconfig)
            
            if (pendingValidation != null && pendingValidation.isNotEmpty()) {
                return@patch call.respondValidation(db, Operation.account_reconfig, pendingValidation)
            }

            val res = patchAccount(db, cfg, req, call.pathUsername, call.isAdmin, pendingValidation != null)
            when (res) {
                AccountPatchResult.Success -> call.respond(HttpStatusCode.NoContent)
                is AccountPatchResult.Challenges -> {
                    if (res.validations.isNotEmpty()) {
                        call.respondValidation(db, Operation.account_reconfig, res.validations)
                    } else {
                        call.respondMfa(db, Operation.account_reconfig)
                    }
                }
                AccountPatchResult.UnknownAccount -> throw unknownAccount(call.pathUsername)
                AccountPatchResult.NonAdminName -> throw conflict(
                    "non-admin user cannot change their legal name",
                    TalerErrorCode.BANK_NON_ADMIN_PATCH_LEGAL_NAME
                )
                AccountPatchResult.NonAdminCashout -> throw conflict(
                    "non-admin user cannot change their cashout account",
                    TalerErrorCode.BANK_NON_ADMIN_PATCH_CASHOUT
                )
                AccountPatchResult.NonAdminDebtLimit -> throw conflict(
                    "non-admin user cannot change their debt limit",
                    TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT
                )
                AccountPatchResult.NonAdminConversionRateClass -> throw conflict(
                    "non-admin user cannot change their conversion rate class",
                    TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS
                )
                AccountPatchResult.UnknownConversionClass -> 
                    throw unknownConversionClass(req.conversion_rate_class_id.get())
                AccountPatchResult.MissingTanInfo -> throw conflict(
                    "missing info for tan channel ${req.tan_channel.get()}",
                    TalerErrorCode.BANK_MISSING_TAN_INFO
                )
            }
        }
        patch("/accounts/{USERNAME}/auth", {
            operationId = "changePassword"
            description = "Change account password"
            tags = listOf("Core Bank - Accounts")
            protected = true
            securitySchemeNames("bearerAuth", "basicAuth")
            request {
                pathParameter<String>("USERNAME") { description = "Account username" }
                body<AccountPasswordChange>()
            }
            response {
                code(HttpStatusCode.NoContent) { description = "Password changed" }
                code(HttpStatusCode.NotFound) { description = "Account not found" }
                code(HttpStatusCode.Conflict) { description = "Old password mismatch" }
            }
        }) {
            val (req, challenge) = call.receiveChallenge<AccountPasswordChange>(db, Operation.account_auth_reconfig)

            if (!call.isAdmin && req.old_password == null) {
                throw conflict(
                    "non-admin user cannot change password without providing old password",
                    TalerErrorCode.BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD
                )
            }
            val newPassword = req.new_password.checkPw(cfg.pwdCheckQuality)

            when (db.account.reconfigPassword(call.pathUsername, newPassword, req.old_password, call.isAdmin || challenge != null, cfg.pwCrypto)) {
                AccountPatchAuthResult.Success -> call.respond(HttpStatusCode.NoContent)
                AccountPatchAuthResult.TanRequired -> call.respondMfa(db, Operation.account_auth_reconfig)
                AccountPatchAuthResult.UnknownAccount -> throw unknownAccount(call.pathUsername)
                AccountPatchAuthResult.OldPasswordMismatch -> throw conflict(
                    "old password does not match",
                    TalerErrorCode.BANK_PATCH_BAD_OLD_PASSWORD
                )
            }
        }
    }
    get("/public-accounts", {
        operationId = "listPublicAccounts"
        description = "List public bank accounts"
        tags = listOf("Core Bank - Accounts")
        request {
            queryParameter<Int>("limit") {
                description = "Maximum number of results to return (default: -20, negative means descending)"
                required = false
            }
            queryParameter<Long>("offset") {
                description = "Row ID offset for pagination"
                required = false
            }
            queryParameter<String>("filter_name") {
                description = "Filter accounts by username substring"
                required = false
            }
            queryParameter<Long>("conversion_rate_class_id") {
                description = "Filter accounts by conversion rate class ID"
                required = false
            }
        }
        response {
            code(HttpStatusCode.OK) { description = "List of public accounts"; body<PublicAccountsResponse>() }
            code(HttpStatusCode.NoContent) { description = "No public accounts found" }
        }
    }) {
        val params = AccountParams.extract(call.request.queryParameters)
        val publicAccounts = db.account.pagePublic(params)
        if (publicAccounts.isEmpty()) {
            call.respond(HttpStatusCode.NoContent)
        } else {
            call.respond(PublicAccountsResponse(publicAccounts))
        }
    }
    authAdmin(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat) {
        get("/accounts", {
            operationId = "listAccounts"
            description = "List all bank accounts (admin only)"
            tags = listOf("Core Bank - Accounts")
            protected = true
            securitySchemeNames("bearerAuth", "basicAuth")
            request {
                queryParameter<Int>("limit") {
                    description = "Maximum number of results to return (default: -20, negative means descending)"
                    required = false
                }
                queryParameter<Long>("offset") {
                    description = "Row ID offset for pagination"
                    required = false
                }
                queryParameter<String>("filter_name") {
                    description = "Filter accounts by username substring"
                    required = false
                }
                queryParameter<Long>("conversion_rate_class_id") {
                    description = "Filter accounts by conversion rate class ID"
                    required = false
                }
            }
            response {
                code(HttpStatusCode.OK) { description = "List of accounts"; body<ListBankAccountsResponse>() }
                code(HttpStatusCode.NoContent) { description = "No accounts found" }
            }
        }) {
            val params = AccountParams.extract(call.request.queryParameters)
            val accounts = db.account.pageAdmin(params)
            if (accounts.isEmpty()) {
                call.respond(HttpStatusCode.NoContent)
            } else {
                call.respond(ListBankAccountsResponse(accounts))
            }
        }
    }
    auth(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat, allowAdmin = true) {
        get("/accounts/{USERNAME}", {
            operationId = "getAccount"
            description = "Get account details"
            tags = listOf("Core Bank - Accounts")
            protected = true
            securitySchemeNames("bearerAuth", "basicAuth")
            request {
                pathParameter<String>("USERNAME") { description = "Account username" }
            }
            response {
                code(HttpStatusCode.OK) { description = "Account details"; body<AccountData>() }
                code(HttpStatusCode.NotFound) { description = "Account not found" }
            }
        }) {
            val account = db.account.get(call.pathUsername) ?: throw unknownAccount(call.pathUsername)
            call.respond(account)
        }
    }
}

private fun Routing.coreBankTransactionsApi(db: Database, cfg: BankConfig) {
    auth(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat, allowAdmin = true) {
        get("/accounts/{USERNAME}/transactions", {
            operationId = "getTransactions"
            description = "Get transaction history for an account"
            tags = listOf("Core Bank - Transactions")
            protected = true
            securitySchemeNames("bearerAuth", "basicAuth")
            request {
                pathParameter<String>("USERNAME") { description = "Account username" }
                queryParameter<Int>("limit") {
                    description = "Maximum number of results to return (default: -20, negative means descending)"
                    required = false
                }
                queryParameter<Long>("offset") {
                    description = "Row ID offset for pagination"
                    required = false
                }
                queryParameter<Long>("timeout_ms") {
                    description = "Timeout for long polling in milliseconds (default: 0, no long polling)"
                    required = false
                }
            }
            response {
                code(HttpStatusCode.OK) { description = "Transaction history"; body<BankAccountTransactionsResponse>() }
                code(HttpStatusCode.NoContent) { description = "No transactions found" }
            }
        }) {
            val params = HistoryParams.extract(call.request.queryParameters)
            val bankAccount = call.bankInfo(db)
    
            val history: List<BankAccountTransactionInfo> =
                    db.transaction.pollHistory(params, bankAccount.bankAccountId)
            if (history.isEmpty()) {
                call.respond(HttpStatusCode.NoContent)
            } else {
                call.respond(BankAccountTransactionsResponse(history))
            }
        }
        get("/accounts/{USERNAME}/transactions/{T_ID}", {
            operationId = "getTransaction"
            description = "Get a specific transaction by ID"
            tags = listOf("Core Bank - Transactions")
            protected = true
            securitySchemeNames("bearerAuth", "basicAuth")
            request {
                pathParameter<String>("USERNAME") { description = "Account username" }
                pathParameter<Long>("T_ID") { description = "Transaction identifier" }
            }
            response {
                code(HttpStatusCode.OK) { description = "Transaction details"; body<BankAccountTransactionInfo>() }
                code(HttpStatusCode.NotFound) { description = "Transaction not found" }
            }
        }) {
            val tId = call.longPath("T_ID")
            val tx = db.transaction.get(tId, call.pathUsername) ?: throw notFound(
                "Bank transaction '$tId' not found",
                TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
            )
            call.respond(tx)
        }
    }
    auth(db, cfg.pwCrypto, TokenLogicalScope.readwrite, cfg.basicAuthCompat) {
        post("/accounts/{USERNAME}/transactions", {
            operationId = "createTransaction"
            description = "Create a new bank transaction"
            tags = listOf("Core Bank - Transactions")
            protected = true
            securitySchemeNames("bearerAuth", "basicAuth")
            request {
                pathParameter<String>("USERNAME") { description = "Account username" }
                body<TransactionCreateRequest>()
            }
            response {
                code(HttpStatusCode.OK) { description = "Transaction created"; body<TransactionCreateResponse>() }
                code(HttpStatusCode.Conflict) { description = "Transaction conflict" }
            }
        }) {
            val (req, challenge) = call.receiveChallenge<TransactionCreateRequest>(db, Operation.bank_transaction)

            val subject = req.payto_uri.message ?: throw badRequest("Wire transfer lacks subject")
            val amount = req.payto_uri.amount ?: req.amount ?: throw badRequest("Wire transfer lacks amount")

            cfg.checkRegionalCurrency(amount)

            val res = db.transaction.create(
                creditAccountPayto = req.payto_uri,
                debitAccountUsername = call.pathUsername,
                subject = subject,
                amount = amount,
                timestamp = Instant.now(),
                requestUid = req.request_uid,
                is2fa = challenge != null,
                wireTransferFees = cfg.wireTransferFees,
                minAmount = cfg.minAmount,
                maxAmount = cfg.maxAmount
            )
            when (res) {
                BankTransactionResult.UnknownDebtor -> throw unknownAccount(call.pathUsername)
                BankTransactionResult.TanRequired -> {
                    call.respondMfa(db, Operation.bank_transaction)
                }
                BankTransactionResult.BothPartySame -> throw conflict(
                    "Wire transfer attempted with credit and debit party being the same bank account",
                    TalerErrorCode.BANK_SAME_ACCOUNT 
                )
                BankTransactionResult.UnknownCreditor -> throw unknownCreditorAccount(req.payto_uri.canonical)
                BankTransactionResult.AdminCreditor -> throw conflict(
                    "Cannot transfer money to admin account",
                    TalerErrorCode.BANK_ADMIN_CREDITOR
                )
                BankTransactionResult.BalanceInsufficient -> throw conflict(
                    "Insufficient funds",
                    TalerErrorCode.BANK_UNALLOWED_DEBIT
                )
                BankTransactionResult.BadAmount -> throw conflict(
                    "Amount either to high or too low",
                    TalerErrorCode.BANK_UNALLOWED_DEBIT
                )
                BankTransactionResult.RequestUidReuse -> throw conflict(
                    "request_uid used already",
                    TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED
                )
                is BankTransactionResult.Success -> call.respond(TransactionCreateResponse(res.id))
            }
        }
    }
}

private fun Routing.coreBankWithdrawalApi(db: Database, cfg: BankConfig) {
    auth(db, cfg.pwCrypto, TokenLogicalScope.readwrite, cfg.basicAuthCompat) {
        post("/accounts/{USERNAME}/withdrawals", {
            operationId = "createWithdrawal"
            description = "Create a new withdrawal operation"
            tags = listOf("Core Bank - Withdrawals")
            protected = true
            securitySchemeNames("bearerAuth", "basicAuth")
            request {
                pathParameter<String>("USERNAME") { description = "Account username" }
                body<BankAccountCreateWithdrawalRequest>()
            }
            response {
                code(HttpStatusCode.OK) { description = "Withdrawal created"; body<BankAccountCreateWithdrawalResponse>() }
                code(HttpStatusCode.Conflict) { description = "Withdrawal conflict" }
            }
        }) {
            val req = call.receive<BankAccountCreateWithdrawalRequest>()
            req.amount?.run(cfg::checkRegionalCurrency)
            req.suggested_amount?.run(cfg::checkRegionalCurrency)
            val opId = UUID.randomUUID()
            when (db.withdrawal.create(
                call.pathUsername, 
                opId, 
                req.amount,
                req.suggested_amount,
                req.no_amount_to_wallet,
                Instant.now(),
                cfg.wireTransferFees,
                cfg.minAmount,
                cfg.maxAmount
            )) {
                WithdrawalCreationResult.UnknownAccount -> throw unknownAccount(call.pathUsername)
                WithdrawalCreationResult.AccountIsExchange -> throw conflict(
                    "Exchange account cannot perform withdrawal operation",
                    TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE
                )
                WithdrawalCreationResult.BalanceInsufficient -> throw conflict(
                    "Insufficient funds to withdraw with Taler",
                    TalerErrorCode.BANK_UNALLOWED_DEBIT
                )
                WithdrawalCreationResult.BadAmount -> throw conflict(
                    "Amount either to high or too low",
                    TalerErrorCode.BANK_UNALLOWED_DEBIT
                )
                WithdrawalCreationResult.Success -> {
                    call.respond(
                        BankAccountCreateWithdrawalResponse(
                            withdrawal_id = opId.toString(),
                            taler_withdraw_uri = cfg.talerWithdrawUri(opId)
                        )
                    )
                }
            }
        }
        post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/confirm", {
            operationId = "confirmWithdrawal"
            description = "Confirm a withdrawal operation"
            tags = listOf("Core Bank - Withdrawals")
            protected = true
            securitySchemeNames("bearerAuth", "basicAuth")
            request {
                pathParameter<String>("USERNAME") { description = "Account username" }
                pathParameter<String>("withdrawal_id") { description = "Withdrawal operation ID" }
                body<BankAccountConfirmWithdrawalRequest>()
            }
            response {
                code(HttpStatusCode.NoContent) { description = "Withdrawal confirmed" }
                code(HttpStatusCode.NotFound) { description = "Withdrawal operation not found" }
                code(HttpStatusCode.Conflict) { description = "Confirmation conflict" }
            }
        }) {
            val id = call.uuidPath("withdrawal_id")
            val (req, challenge) = call.receiveChallenge<BankAccountConfirmWithdrawalRequest>(db, Operation.withdrawal, BankAccountConfirmWithdrawalRequest())
            req.amount?.run(cfg::checkRegionalCurrency)
            when (db.withdrawal.confirm(
                call.pathUsername,
                id,
                Instant.now(),
                req.amount,
                challenge != null,
                cfg.wireTransferFees,
                cfg.minAmount,
                cfg.maxAmount
            )) {
                WithdrawalConfirmationResult.UnknownOperation -> throw notFound(
                    "Withdrawal operation $id not found",
                    TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
                )
                WithdrawalConfirmationResult.AlreadyAborted -> throw conflict(
                    "Cannot confirm an aborted withdrawal",
                    TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT
                )
                WithdrawalConfirmationResult.ReservePubReuse -> throw conflict(
                    "Reserve pub already used", 
                    TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT
                )
                WithdrawalConfirmationResult.NotSelected -> throw conflict(
                    "Cannot confirm an unselected withdrawal",
                    TalerErrorCode.BANK_CONFIRM_INCOMPLETE
                )
                WithdrawalConfirmationResult.BalanceInsufficient -> throw conflict(
                    "Insufficient funds",
                    TalerErrorCode.BANK_UNALLOWED_DEBIT
                )
                WithdrawalConfirmationResult.MissingAmount -> throw conflict(
                    "An amount is required",
                    TalerErrorCode.BANK_AMOUNT_REQUIRED
                )
                WithdrawalConfirmationResult.AmountDiffers -> throw conflict(
                    "Given amount is different from the current",
                    TalerErrorCode.BANK_AMOUNT_DIFFERS
                )
                WithdrawalConfirmationResult.BadAmount -> throw conflict(
                    "Amount either to high or too low",
                    TalerErrorCode.BANK_UNALLOWED_DEBIT
                )
                WithdrawalConfirmationResult.TanRequired -> {
                    call.respondMfa(db, Operation.withdrawal)
                }
                WithdrawalConfirmationResult.Success -> call.respond(HttpStatusCode.NoContent)
            }
        }
        post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/abort", {
            operationId = "abortWithdrawal"
            description = "Abort a withdrawal operation"
            tags = listOf("Core Bank - Withdrawals")
            protected = true
            securitySchemeNames("bearerAuth", "basicAuth")
            request {
                pathParameter<String>("USERNAME") { description = "Account username" }
                pathParameter<String>("withdrawal_id") { description = "Withdrawal operation ID" }
            }
            response {
                code(HttpStatusCode.NoContent) { description = "Withdrawal aborted" }
                code(HttpStatusCode.NotFound) { description = "Withdrawal operation not found" }
                code(HttpStatusCode.Conflict) { description = "Cannot abort confirmed withdrawal" }
            }
        }) {
            val opId = call.uuidPath("withdrawal_id")
            when (db.withdrawal.abort(opId)) {
                AbortResult.UnknownOperation -> throw notFound(
                    "Withdrawal operation $opId not found",
                    TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
                )
                AbortResult.AlreadyConfirmed -> throw conflict(
                    "Cannot abort confirmed withdrawal", 
                    TalerErrorCode.BANK_ABORT_CONFIRM_CONFLICT
                )
                AbortResult.Success -> call.respond(HttpStatusCode.NoContent)
            }
        }
    }
    get("/withdrawals/{withdrawal_id}", {
        operationId = "getWithdrawalStatus"
        description = "Get withdrawal operation status"
        tags = listOf("Core Bank - Withdrawals")
        request {
            pathParameter<String>("withdrawal_id") { description = "Withdrawal operation ID" }
            queryParameter<Long>("timeout_ms") {
                description = "Timeout for long polling in milliseconds (default: 0, no long polling)"
                required = false
            }
            queryParameter<String>("old_state") {
                description = "Previous state for long polling (pending, aborted, selected, confirmed). Default: pending"
                required = false
            }
        }
        response {
            code(HttpStatusCode.OK) { description = "Withdrawal status"; body<WithdrawalPublicInfo>() }
            code(HttpStatusCode.NotFound) { description = "Withdrawal operation not found" }
        }
    }) {
        val uuid = call.uuidPath("withdrawal_id")
        val params = StatusParams.extract(call.request.queryParameters)
        val op = db.withdrawal.pollInfo(uuid, params) ?: throw notFound(
            "Withdrawal operation '$uuid' not found", 
            TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
        )
        call.respond(op)
    }
}

private fun Routing.coreBankCashoutApi(db: Database, cfg: BankConfig) = conditional(cfg.allowConversion) {
    auth(db, cfg.pwCrypto, TokenLogicalScope.readwrite, cfg.basicAuthCompat) {
        post("/accounts/{USERNAME}/cashouts", {
            operationId = "createCashout"
            description = "Create a new cashout operation"
            tags = listOf("Core Bank - Cashouts")
            protected = true
            securitySchemeNames("bearerAuth", "basicAuth")
            request {
                pathParameter<String>("USERNAME") { description = "Account username" }
                body<CashoutRequest>()
            }
            response {
                code(HttpStatusCode.OK) { description = "Cashout created"; body<CashoutResponse>() }
                code(HttpStatusCode.Conflict) { description = "Cashout conflict" }
            }
        }) {
            val (req, challenge) = call.receiveChallenge<CashoutRequest>(db, Operation.cashout)

            cfg.checkRegionalCurrency(req.amount_debit)
            cfg.checkFiatCurrency(req.amount_credit)
        
            val res = db.cashout.create(
                username = call.pathUsername, 
                requestUid = req.request_uid,
                amountDebit = req.amount_debit, 
                amountCredit = req.amount_credit, 
                subject = req.subject ?: "", // TODO default subject
                timestamp = Instant.now(),
                is2fa = challenge != null
            )
            when (res) {
                CashoutCreationResult.AccountNotFound -> throw unknownAccount(call.pathUsername)
                CashoutCreationResult.BadConversion -> throw conflict(
                    "Wrong currency conversion",
                    TalerErrorCode.BANK_BAD_CONVERSION
                )
                CashoutCreationResult.UnderMin -> throw conflict(
                    "Amount of currency conversion it less than the minimum allowed",
                    TalerErrorCode.BANK_CONVERSION_AMOUNT_TO_SMALL
                )
                CashoutCreationResult.AccountIsExchange -> throw conflict(
                    "Exchange account cannot perform cashout operation",
                    TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE
                )
                CashoutCreationResult.BalanceInsufficient -> throw conflict(
                    "Insufficient funds to withdraw with Taler",
                    TalerErrorCode.BANK_UNALLOWED_DEBIT
                )
                CashoutCreationResult.RequestUidReuse -> throw conflict(
                    "request_uid used already",
                    TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED
                )
                CashoutCreationResult.NoCashoutPayto -> throw conflict(
                    "Missing cashout payto uri",
                    TalerErrorCode.BANK_CONFIRM_INCOMPLETE
                )
                CashoutCreationResult.TanRequired -> {
                    call.respondMfa(db, Operation.cashout)
                }
                is CashoutCreationResult.Success -> call.respond(CashoutResponse(res.id))
            }
        }
    }
    auth(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat, allowAdmin = true) {
        get("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}", {
            operationId = "getCashout"
            description = "Get a specific cashout operation"
            tags = listOf("Core Bank - Cashouts")
            protected = true
            securitySchemeNames("bearerAuth", "basicAuth")
            request {
                pathParameter<String>("USERNAME") { description = "Account username" }
                pathParameter<Long>("CASHOUT_ID") { description = "Cashout operation identifier" }
            }
            response {
                code(HttpStatusCode.OK) { description = "Cashout details"; body<CashoutStatusResponse>() }
                code(HttpStatusCode.NotFound) { description = "Cashout operation not found" }
            }
        }) {
            val id = call.longPath("CASHOUT_ID")
            val cashout = db.cashout.get(id, call.pathUsername) ?: throw notFound(
                "Cashout operation $id not found", 
                TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
            )
            call.respond(cashout)
        }
        get("/accounts/{USERNAME}/cashouts", {
            operationId = "listAccountCashouts"
            description = "List cashout operations for an account"
            tags = listOf("Core Bank - Cashouts")
            protected = true
            securitySchemeNames("bearerAuth", "basicAuth")
            request {
                pathParameter<String>("USERNAME") { description = "Account username" }
                queryParameter<Int>("limit") {
                    description = "Maximum number of results to return (default: -20, negative means descending)"
                    required = false
                }
                queryParameter<Long>("offset") {
                    description = "Row ID offset for pagination"
                    required = false
                }
            }
            response {
                code(HttpStatusCode.OK) { description = "List of cashouts for account"; body<Cashouts>() }
                code(HttpStatusCode.NoContent) { description = "No cashout operations found" }
            }
        }) {
            val params = PageParams.extract(call.request.queryParameters)
            val cashouts = db.cashout.pageForUser(params, call.pathUsername)
            if (cashouts.isEmpty()) {
                call.respond(HttpStatusCode.NoContent)
            } else {
                call.respond(Cashouts(cashouts))
            }
        }
    }
    authAdmin(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat) {
        get("/cashouts", {
            operationId = "listAllCashouts"
            description = "List all cashout operations (admin only)"
            tags = listOf("Core Bank - Cashouts")
            protected = true
            securitySchemeNames("bearerAuth", "basicAuth")
            request {
                queryParameter<Int>("limit") {
                    description = "Maximum number of results to return (default: -20, negative means descending)"
                    required = false
                }
                queryParameter<Long>("offset") {
                    description = "Row ID offset for pagination"
                    required = false
                }
            }
            response {
                code(HttpStatusCode.OK) { description = "List of all cashouts"; body<GlobalCashouts>() }
                code(HttpStatusCode.NoContent) { description = "No cashout operations found" }
            }
        }) {
            val params = PageParams.extract(call.request.queryParameters)
            val cashouts = db.cashout.pageAll(params)
            if (cashouts.isEmpty()) {
                call.respond(HttpStatusCode.NoContent)
            } else {
                call.respond(GlobalCashouts(cashouts))
            }
        }
    }
}

private fun Routing.coreBankTanApi(db: Database, cfg: BankConfig) {
    post("/accounts/{USERNAME}/challenge/{CHALLENGE_ID}", {
        operationId = "sendTanChallenge"
        description = "Send a TAN challenge"
        tags = listOf("Core Bank - TAN")
        request {
            pathParameter<String>("USERNAME") { description = "Account username" }
            pathParameter<String>("CHALLENGE_ID") { description = "Challenge identifier" }
        }
        response {
            code(HttpStatusCode.OK) { description = "Challenge response"; body<ChallengeRequestResponse>() }
            code(HttpStatusCode.NotFound) { description = "Challenge not found or expired" }
            code(HttpStatusCode.Gone) { description = "Challenge already solved" }
        }
    }) {
        val uuid = call.uuidPath("CHALLENGE_ID")
        val res = db.tan.send(
            uuid = uuid,
            timestamp = Instant.now(), 
            maxActive = MAX_ACTIVE_CHALLENGES
        )
        when (res) {
            TanSendResult.NotFound -> throw notFound(
                "Challenge $uuid not found",
                TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
            )
            TanSendResult.Expired -> throw notFound(
                "Challenge $uuid expired",
                TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED
            )
            TanSendResult.TooMany -> throw tooManyRequests(
                "Too many active challenges",
                TalerErrorCode.BANK_TAN_RATE_LIMITED
            )
            TanSendResult.Solved -> call.respond(HttpStatusCode.Gone)
            is TanSendResult.Send -> {
                val (tanScript, tanEnv) = cfg.tanChannels[res.tanChannel]
                    ?: throw unsupportedTanChannel(res.tanChannel)
                val msg = "T-${res.tanCode} is your ${cfg.name} verification code"
                val exitValue = withContext(Dispatchers.IO) {
                    val builder = ProcessBuilder(tanScript.toString(), res.tanInfo)
                    builder.redirectErrorStream(true)
                    for ((name, value) in tanEnv) {
                        builder.environment()[name] = value
                    }
                    val process = builder.start()
                    try {
                        process.outputWriter().use { it.write(msg) }
                        process.onExit().await()
                    } catch (e: Exception) {
                        process.destroy()
                    }
                    val exitValue = process.exitValue()
                    if (exitValue != 0) {
                        val out = runCatching {
                            process.inputStream.use {
                                res.tanCode.reader().readText()
                            }
                        }.getOrDefault("")
                        if (out.isNotEmpty()) {
                            logger.error("TAN ${res.tanChannel} - ${tanScript}: $out")
                        }
                    }
                    exitValue
                }
                if (exitValue != 0) {
                    throw apiError(
                        HttpStatusCode.BadGateway,
                        "Tan channel script failure with exit value $exitValue",
                        TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED
                    )
                }
                val retransmission = Instant.now() + TAN_RETRANSMISSION_PERIOD
                db.tan.markSent(uuid, retransmission)
                call.respond(ChallengeRequestResponse(
                    solve_expiration = res.expiration,
                    earliest_retransmission = TalerTimestamp(retransmission)
                ))
            }
            is TanSendResult.Success -> {
                call.respond(ChallengeRequestResponse(
                    solve_expiration = res.expiration,
                    earliest_retransmission = res.retransmission
                ))
            }
        }
    }
    post("/accounts/{USERNAME}/challenge/{CHALLENGE_ID}/confirm", {
        operationId = "confirmTanChallenge"
        description = "Confirm a TAN challenge"
        tags = listOf("Core Bank - TAN")
        request {
            pathParameter<String>("USERNAME") { description = "Account username" }
            pathParameter<String>("CHALLENGE_ID") { description = "Challenge identifier" }
            body<ChallengeSolve>()
        }
        response {
            code(HttpStatusCode.NoContent) { description = "Challenge confirmed successfully" }
            code(HttpStatusCode.NotFound) { description = "Challenge not found or expired" }
            code(HttpStatusCode.Conflict) { description = "Incorrect TAN code" }
        }
    }) {
        val uuid = call.uuidPath("CHALLENGE_ID")
        val req = call.receive<ChallengeSolve>()
        val code = req.tan.removePrefix("T-")
        val res = db.tan.solve(
            uuid = uuid,
            code = code,
            timestamp = Instant.now()
        )
        when (res) {
            TanSolveResult.NotFound -> throw notFound(
                "Challenge $uuid not found",
                TalerErrorCode.BANK_CHALLENGE_NOT_FOUND
            )
            TanSolveResult.BadCode -> throw conflict(
                "Incorrect TAN code",
                TalerErrorCode.BANK_TAN_CHALLENGE_FAILED
            )
            TanSolveResult.NoRetry -> throw tooManyRequests(
                "Too many failed confirmation attempt",
                TalerErrorCode.BANK_TAN_RATE_LIMITED
            )
            TanSolveResult.Expired -> throw notFound(
                "Challenge $uuid expired",
                TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED
            )
            is TanSolveResult.Success -> call.respond(HttpStatusCode.NoContent)
        }
    }
}

private fun Routing.coreBankConversionApi(db: Database, cfg: BankConfig) = conditional(cfg.allowConversion) {
    authAdmin(db, cfg.pwCrypto, TokenLogicalScope.readwrite, cfg.basicAuthCompat) {
        post("/conversion-rate-classes", {
            operationId = "createConversionRateClass"
            description = "Create a conversion rate class"
            tags = listOf("Core Bank - Conversion Rate Classes")
            protected = true
            securitySchemeNames("bearerAuth", "basicAuth")
            request {
                body<ConversionRateClassInput>()
            }
            response {
                code(HttpStatusCode.OK) { description = "Rate class created"; body<ConversionRateClassResponse>() }
                code(HttpStatusCode.Conflict) { description = "Name already in use" }
            }
        }) {
            val req = call.receive<ConversionRateClassInput>()
            cfg.checkCurrency(req)
            when (val res = db.conversion.createClass(req)) {
                is ClassCreateResult.Success -> call.respond(ConversionRateClassResponse(res.id))
                ClassCreateResult.NameReuse -> throw conflict(
                    "Conversion rate class name '${req.name}' already use",
                    TalerErrorCode.BANK_NAME_REUSE
                )
            }
            
        }
        patch("/conversion-rate-classes/{CLASS_ID}", {
            operationId = "updateConversionRateClass"
            description = "Update a conversion rate class"
            tags = listOf("Core Bank - Conversion Rate Classes")
            protected = true
            securitySchemeNames("bearerAuth", "basicAuth")
            request {
                pathParameter<Long>("CLASS_ID") { description = "Conversion rate class ID" }
                body<ConversionRateClassInput>()
            }
            response {
                code(HttpStatusCode.NoContent) { description = "Rate class updated" }
                code(HttpStatusCode.NotFound) { description = "Rate class not found" }
                code(HttpStatusCode.Conflict) { description = "Name already in use" }
            }
        }) {
            val id = call.longPath("CLASS_ID")
            val req = call.receive<ConversionRateClassInput>()
            cfg.checkCurrency(req)
            when (val res = db.conversion.patchClass(id, req)) {
                ClassPatchResult.Success -> call.respond(HttpStatusCode.NoContent)
                ClassPatchResult.Unknown -> throw notFound(
                    "Conversion rate class '$id' not found",
                    TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
                )
                ClassPatchResult.NameReuse -> throw conflict(
                    "Conversion rate class name '${req.name}' already use",
                    TalerErrorCode.BANK_NAME_REUSE
                )
            }
        }
        delete("/conversion-rate-classes/{CLASS_ID}", {
            operationId = "deleteConversionRateClass"
            description = "Delete a conversion rate class"
            tags = listOf("Core Bank - Conversion Rate Classes")
            protected = true
            securitySchemeNames("bearerAuth", "basicAuth")
            request {
                pathParameter<Long>("CLASS_ID") { description = "Conversion rate class ID" }
            }
            response {
                code(HttpStatusCode.NoContent) { description = "Rate class deleted" }
                code(HttpStatusCode.NotFound) { description = "Rate class not found" }
            }
        }) {
            val id = call.longPath("CLASS_ID")
            if (db.conversion.deleteClass(id)) {
                call.respond(HttpStatusCode.NoContent)
            } else {
                throw notFound(
                    "Conversion rate class '$id' not found",
                    TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
                )
            }
        }
    }
    authAdmin(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat) {
        get("/conversion-rate-classes/{CLASS_ID}", {
            operationId = "getConversionRateClass"
            description = "Get a conversion rate class"
            tags = listOf("Core Bank - Conversion Rate Classes")
            protected = true
            securitySchemeNames("bearerAuth", "basicAuth")
            request {
                pathParameter<Long>("CLASS_ID") { description = "Conversion rate class ID" }
            }
            response {
                code(HttpStatusCode.OK) { description = "Rate class details"; body<ConversionRateClass>() }
                code(HttpStatusCode.NotFound) { description = "Rate class not found" }
            }
        }) {
            val id = call.longPath("CLASS_ID")
            val cashout = db.conversion.getClass(id) ?: throw notFound(
                "Conversion rate class $id not found", 
                TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
            )
            call.respond(cashout)
        }
        get("/conversion-rate-classes", {
            operationId = "listConversionRateClasses"
            description = "List all conversion rate classes"
            tags = listOf("Core Bank - Conversion Rate Classes")
            protected = true
            securitySchemeNames("bearerAuth", "basicAuth")
            request {
                queryParameter<Int>("limit") {
                    description = "Maximum number of results to return (default: -20, negative means descending)"
                    required = false
                }
                queryParameter<Long>("offset") {
                    description = "Row ID offset for pagination"
                    required = false
                }
                queryParameter<String>("filter_name") {
                    description = "Filter rate classes by name substring"
                    required = false
                }
            }
            response {
                code(HttpStatusCode.OK) { description = "List of rate classes"; body<ConversionRateClasses>() }
                code(HttpStatusCode.NoContent) { description = "No rate classes found" }
            }
        }) {
            val params = ClassParams.extract(call.request.queryParameters)

            val page = db.conversion.pageClass(params)
            if (page.isEmpty()) {
                call.respond(HttpStatusCode.NoContent)
            } else {
                call.respond(ConversionRateClasses(page))
            }
        }
    }
}