/*
 * This file is part of LibEuFin.
 * Copyright (C) 2023-2024 Taler Systems S.A.

 * LibEuFin is free software; you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation; either version 3, or
 * (at your option) any later version.

 * LibEuFin is distributed in the hope that it will be useful, but
 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
 * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
 * Public License for more details.

 * You should have received a copy of the GNU Affero General Public
 * License along with LibEuFin; see the file COPYING.  If not, see
 * <http://www.gnu.org/licenses/>
 */

package tech.libeufin.nexus.cli

import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.Context
import com.github.ajalt.clikt.parameters.groups.provideDelegate
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option
import io.ktor.client.*
import tech.libeufin.common.*
import tech.libeufin.common.crypto.CryptoUtil
import tech.libeufin.nexus.*
import tech.libeufin.nexus.ebics.*
import tech.libeufin.nexus.ebics.EbicsKeyMng.Order.*
import java.nio.file.FileAlreadyExistsException
import java.nio.file.Path
import java.nio.file.StandardOpenOption
import java.time.Instant
import kotlin.io.path.Path
import kotlin.io.path.writeBytes

/** Load client private keys at [path] or create new ones if missing */
private fun loadOrGenerateClientKeys(path: Path): ClientPrivateKeysFile {
    // If exists load from disk
    val current = loadClientKeys(path)
    if (current != null) return current
    // Else create new keys
    val newKeys = generateNewKeys()
    persistClientKeys(newKeys, path)
    logger.info("New client private keys created at '$path'")
    return newKeys
}

/**
 * Asks the user to accept the bank public keys.
 *
 * @param bankKeys bank public keys, in format stored on disk.
 * @return true if the user accepted, false otherwise.
 */
private fun askUserToAcceptKeys(bankKeys: BankPublicKeysFile): Boolean {
    val encHash = CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_encryption_public_key).encodeUpHex()
    val authHash = CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_authentication_public_key).encodeUpHex()
    println("The bank has the following keys:")
    println("Encryption key: ${encHash.fmtChunkByTwo()}")
    println("Authentication key: ${authHash.fmtChunkByTwo()}")
    print("type 'yes, accept' to accept them: ")
    val userResponse: String? = readlnOrNull()
    return userResponse == "yes, accept"
}

/** Perform an EBICS public key management [order] using [client] and update on disk state */
private suspend fun submitClientKeys(
    cfg: NexusEbicsConfig,
    privs: ClientPrivateKeysFile,
    client: HttpClient,
    ebicsLogger: EbicsLogger,
    order: EbicsKeyMng.Order
) {
    require(order != HPB) { "Only INI & HIA are supported for client keys" }
    val resp = keyManagement(cfg, privs, client, ebicsLogger, order)
    if (resp.technicalCode == EbicsReturnCode.EBICS_INVALID_USER_OR_USER_STATE) {
        throw Exception("$order status code ${resp.technicalCode}: either your IDs are incorrect, or you already have keys registered with this bank")
    }
    val orderData = resp.okOrFail(order.name)
    when (order) {
        INI -> privs.submitted_ini = true
        HIA -> privs.submitted_hia = true
        HPB -> {}
    }
    try {
        persistClientKeys(privs, cfg.clientPrivateKeysPath)
    } catch (e: Exception) {
        throw Exception("Could not update the $order state on disk", e)
    }
}

/** Perform an EBICS private key management HPB using [client] */
private suspend fun fetchPrivateKeys(
    cfg: NexusEbicsConfig,
    privs: ClientPrivateKeysFile,
    client: HttpClient,
    ebicsLogger: EbicsLogger
): BankPublicKeysFile {
    val order = HPB
    val resp = keyManagement(cfg, privs, client, ebicsLogger, order)
    if (resp.technicalCode == EbicsReturnCode.EBICS_AUTHENTICATION_FAILED) {
        throw Exception("$order status code ${resp.technicalCode}: could not download bank keys, send client keys (and/or related PDF document with --generate-registration-pdf) to the bank")
    }
    val orderData = requireNotNull(resp.okOrFail(order.name)) {
        "$order: missing order data"
    }
    val (authPub, encPub) = EbicsKeyMng.parseHpbOrder(orderData)
    return BankPublicKeysFile(
        bank_authentication_public_key = authPub,
        bank_encryption_public_key = encPub,
        accepted = false
    )
}

/**
 * Mere collector of the PDF generation steps.  Fails the
 * process if a problem occurs.
 *
 * @param privs client private keys.
 * @param cfg configuration handle.
 */
private fun makePdf(privs: ClientPrivateKeysFile, cfg: NexusEbicsConfig) {
    val pdf = generateKeysPdf(privs, cfg)
    val path = Path("/tmp/libeufin-nexus-keys-${Instant.now().epochSecond}.pdf")
    try {
        path.writeBytes(pdf, StandardOpenOption.CREATE_NEW)
    } catch (e: Exception) {
        if (e is FileAlreadyExistsException) throw Exception("PDF file exists already at '$path', not overriding it")
        throw Exception("Could not write PDF to '$path'", e)
    }
    println("PDF file with keys created at '$path'")
}

/**
 * CLI class implementing the "ebics-setup" subcommand.
 */
class EbicsSetup: CliktCommand() {
    override fun help(context: Context) = "Set up the EBICS subscriber"

    private val common by CommonOption()
    private val forceKeysResubmission by option(
        help = "Resubmits all the keys to the bank"
    ).flag(default = false)
    private val autoAcceptKeys by option(
        help = "Accepts the bank keys without interactively asking the user"
    ).flag(default = false)
    private val generateRegistrationPdf by option(
        help = "Generates the PDF with the client public keys to send to the bank"
    ).flag(default = false)
    private val ebicsLog by ebicsLogOption()
    /**
     * This function collects the main steps of setting up an EBICS access.
     */
    override fun run() = cliCmd(logger, common.log) {
        val cfg = nexusConfig(common.config)

        val client = httpClient()
        val ebicsLogger = EbicsLogger(ebicsLog)

        val clientKeys = loadOrGenerateClientKeys(cfg.ebics.clientPrivateKeysPath)
        var bankKeys = loadBankKeys(cfg.ebics.bankPublicKeysPath)

        // Check EBICS 3 support
        val versions = HEV(client, cfg.ebics, ebicsLogger)
        logger.debug("HEV: {}", versions)
        if (!versions.contains(VersionNumber(3.0f, "H005")) && !versions.contains(VersionNumber(3.02f, "H005"))) {
            throw Exception("EBICS 3 is not supported by your bank")
        }

        // Privs exist.  Upload their pubs
        val keysNotSub = !clientKeys.submitted_ini
        if (!clientKeys.submitted_ini || forceKeysResubmission)
            submitClientKeys(cfg.ebics, clientKeys, client, ebicsLogger, INI)
        // Eject PDF if the keys were submitted for the first time, or the user asked.
        if (keysNotSub || generateRegistrationPdf) makePdf(clientKeys, cfg.ebics)
        if (!clientKeys.submitted_hia || forceKeysResubmission)
            submitClientKeys(cfg.ebics, clientKeys, client, ebicsLogger, HIA)
        
        val fetchedBankKeys = fetchPrivateKeys(cfg.ebics, clientKeys, client, ebicsLogger)
        if (bankKeys == null) {
            // Accept bank keys
            logger.info("Bank keys stored at ${cfg.ebics.bankPublicKeysPath}")
            try {
                persistBankKeys(fetchedBankKeys, cfg.ebics.bankPublicKeysPath)
            } catch (e: Exception) {
                throw Exception("Could not store bank keys on disk", e)
            }
            bankKeys = fetchedBankKeys
        } else {
            // Check current bank keys
            if (bankKeys.bank_encryption_public_key != fetchedBankKeys.bank_encryption_public_key) {
                throw Exception(buildString {
                    append("On disk bank encryption key stored at ")
                    append(cfg.ebics.bankPublicKeysPath)
                    append(" doesn't match server key\nDisk:   ")
                    append(CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_encryption_public_key).encodeUpHex().fmtChunkByTwo())
                    append("\nServer: ")
                    append(CryptoUtil.getEbicsPublicKeyHash(fetchedBankKeys.bank_encryption_public_key).encodeUpHex().fmtChunkByTwo())
                })
            } else if (bankKeys.bank_authentication_public_key != fetchedBankKeys.bank_authentication_public_key) {
                throw Exception(buildString {
                    append("On disk bank authentication key stored at ")
                    append(cfg.ebics.bankPublicKeysPath)
                    append(" doesn't match server key\nDisk:   ")
                    append(CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_authentication_public_key).encodeUpHex().fmtChunkByTwo())
                    append("\nServer: ")
                    append(CryptoUtil.getEbicsPublicKeyHash(fetchedBankKeys.bank_authentication_public_key).encodeUpHex().fmtChunkByTwo())
                })
            }
        }

        if (!bankKeys.accepted) {
            // Finishing the setup by accepting the bank keys.
            if (autoAcceptKeys) bankKeys.accepted = true
            else bankKeys.accepted = askUserToAcceptKeys(bankKeys)

            if (!bankKeys.accepted) {
                throw Exception("Cannot successfully finish the setup without accepting the bank keys")
            }
            try {
                persistBankKeys(bankKeys, cfg.ebics.bankPublicKeysPath)
            } catch (e: Exception) {
                throw Exception("Could not set bank keys as accepted on disk", e)
            }
        }

        // Check account information
        logger.info("Doing administrative request HKD")
        try {
            cfg.withDb { db, _ ->
                EbicsClient(
                    cfg,
                    client, 
                    db,
                    ebicsLogger,
                    clientKeys,
                    bankKeys
                ).download(EbicsOrder.V3.HKD, null, null, false) { stream ->
                    val (partner, users) = EbicsAdministrative.parseHKD(stream)
                    val user = users.find { it -> it.id == cfg.ebics.host.ebicsUserId }
                    // Debug logging
                    logger.debug {
                        buildString {
                            if (partner.name != null || partner.accounts.isNotEmpty()) {
                                append("Partner Info: ")
                                if (partner.name != null) {
                                    append("'")
                                    append(partner.name)
                                    append("' - ")
                                }
                                for ((iban, currency) in partner.accounts) {
                                    append(iban)
                                    append('_')
                                    append(currency)
                                }
                                append('\n')
                            }
                            append("Supported orders:\n")
                            for ((order, description) in partner.orders) {
                                append("- ")
                                append(order.description())
                                append(": ")
                                append(description)
                                append('\n')
                            }
                            if (user != null) {
                                append("Authorized orders:\n")
                                for ((order) in partner.orders) {
                                    append("- ")
                                    append(order.description())
                                    append('\n')
                                }
                            }
                        }
                    }

                    // Check partner info match config
                    if (partner.name != null && partner.name != cfg.ebics.account.name)
                        logger.warn("Expected NAME '${cfg.ebics.account.name}' from config got '${partner.name}' from bank")
                    val account = partner.accounts.find { it.iban == cfg.ebics.account.iban }
                    if (account != null) {
                        if (account.currency != null && account.currency != cfg.currency)
                            logger.error("Expected CURRENCY '${cfg.currency}' from config got '${account.currency}' from bank")
                    } else if (partner.accounts.isNotEmpty()) {
                        val ibans = partner.accounts.map { it.iban }.joinToString(" ")
                        logger.error("Expected IBAN ${cfg.ebics.account.iban} from config got $ibans from bank")
                    }

                    val requireOrders = cfg.ebics.dialect.orders()
                    
                    // Check partner support required orders
                    val unsupportedOrder = requireOrders subtract partner.orders.map { it.order }
                    if (unsupportedOrder.isNotEmpty()) {
                        logger.warn("Unsupported orders: {}", unsupportedOrder.map(EbicsOrder::description).joinToString(" "))
                    }

                    // Check user is authorized for required orders
                    if (user != null) {
                        val unauthorizedOrders = requireOrders subtract user.permissions subtract unsupportedOrder
                        if (unauthorizedOrders.isNotEmpty()) {
                            logger.warn("Unauthorized orders: {}", unauthorizedOrders.map(EbicsOrder::description).joinToString(" "))
                        }

                        logger.info("Subscriber status: {}", user.status.description)
                    }
                }
            }
        } catch (e: Exception) {
            // This can happen if HKD is not supported
            logger.warn("HKD failed: ${e.fmt()}")
        }
        
        println("setup ready")
    }
}