/*
 * This file is part of LibEuFin.
 * Copyright (C) 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.ebics

import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.utils.io.jvm.javaio.*
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import org.w3c.dom.Document
import org.xml.sax.SAXException
import tech.libeufin.common.*
import tech.libeufin.common.crypto.CryptoUtil
import java.io.InputStream
import java.io.SequenceInputStream
import java.security.interfaces.RSAPrivateCrtKey
import java.time.Instant
import java.util.*

internal val logger: Logger = LoggerFactory.getLogger("libeufin-ebics")

/** Supported documents that can be downloaded via EBICS  */
enum class SupportedDocument {
    PAIN_002,
    PAIN_002_LOGS,
    CAMT_053,
    CAMT_052,
    CAMT_054
}

/** EBICS related errors */
sealed class EbicsError(msg: String, cause: Throwable? = null): Exception(msg, cause) {
    /** Network errors */
    class Network(msg: String, cause: Throwable): EbicsError(msg, cause)
    /** Http errors */
    class HTTP(msg: String, val status: HttpStatusCode): EbicsError(msg)
    /** EBICS protocol & XML format error */
    class Protocol(msg: String, cause: Throwable? = null): EbicsError(msg, cause)
    /** EBICS protocol & XML format error */
    class Code(msg: String, val technicalCode: EbicsReturnCode, val bankCode: EbicsReturnCode): EbicsError(msg)
}

/** POST an EBICS request [msg] to [bankUrl] returning a parsed XML response */
suspend fun HttpClient.postToBank(
    bankUrl: String, 
    msg: ByteArray,
    phase: String,
    stepLogger: StepLogger
): Document {
    stepLogger.logRequest(msg)
    val res = try {
        post(urlString = bankUrl) {
            contentType(ContentType.Text.Xml)
            setBody(msg)
        }
    } catch (e: Exception) {
        throw EbicsError.Network("$phase: failed to contact bank", e)
    }
    
    if (res.status != HttpStatusCode.OK) {
        stepLogger.logFailure(res)
        throw EbicsError.HTTP("$phase: bank HTTP error: ${res.status}", res.status)
    }
    try {
        val bodyStream = res.bodyAsChannel().toInputStream()
        val loggedStream = stepLogger.logResponse(bodyStream)
        return XMLUtil.parseIntoDom(loggedStream)
    } catch (e: SAXException) {
        throw EbicsError.Protocol("$phase: invalid XML bank response", e)
    } catch (e: Exception) {
        throw EbicsError.Network("$phase: failed read bank response", e)
    }
}

/** POST an EBICS BTS request [xmlReq] using [client] returning a validated and parsed XML response */
suspend fun EbicsBTS.postBTS(
    client: HttpClient,
    xmlReq: ByteArray,
    phase: String,
    stepLogger: StepLogger
): BTSResponse {
    val doc = client.postToBank(cfg.baseUrl, xmlReq, phase, stepLogger)
    try {
        XMLUtil.verifyEbicsDocument(
            doc,
            bankKeys.bank_authentication_public_key
        )
    } catch (e: Exception) {
        throw EbicsError.Protocol("$phase ${order.description()}: invalid signature", e)
    }
    val response = try {
        EbicsBTS.parseResponse(doc)
    } catch (e: Exception) {
        throw EbicsError.Protocol("$phase ${order.description()}: invalid ebics response", e)
    }
    logger.debug {
        buildString {
            append(phase)
            response.content.transactionID?.let {
                append(" for ")
                append(it)
            }
            append(": ")
            append(response.technicalCode)
            append(" & ")
            append(response.bankCode)
        }
    }
    return response.okOrFail(phase)
}

/** High level EBICS client */
class EbicsClient(
    val cfg: EbicsHostConfig,
    val client: HttpClient,
    val dao: EbicsDAO,
    val ebicsLogger: EbicsLogger,
    val clientKeys: ClientPrivateKeysFile,
    val bankKeys: BankPublicKeysFile
) {
    /** 
     * Performs an EBICS download transaction of [order] between [startDate] and [endDate].
     * Download content is passed to [processing]
     * 
     * It conducts init -> transfer -> processing -> receipt phases.
     * 
     * Cancellations and failures are handled.
     */
    suspend fun <T> download(
        order: EbicsOrder,
        startDate: Instant? = null,
        endDate: Instant? = null,
        peek: Boolean = false,
        processing: suspend (InputStream) -> T,
    ): T {
        val description = order.description()
        logger.debug {
            buildString {
                append("Download order ")
                append(description)
                if (startDate != null) {
                    append(" from ")
                    append(startDate)
                    if (endDate != null) {
                        append(" to ")
                        append(endDate)
                    }
                }
            }
        }
        val impl = EbicsBTS(cfg, bankKeys, clientKeys, order)

        // Close interrupted
        val interruptedLog = ebicsLogger.tx("INTD")
        while (true) {
            val tId = dao.first()
            if (tId == null) break
            val xml = impl.downloadReceipt(tId, false)
            try {
                impl.postBTS(client, xml, "Closing interrupted transaction ${tId}", interruptedLog.step(tId))
            } catch (e: Exception) {
                when (e) {
                    // Transaction already closed or expired - EBICS protocol error
                    is EbicsError.Code if e.technicalCode == EbicsReturnCode.EBICS_TX_UNKNOWN_TXID -> {}
                    // Transaction already closed or expired - HTTP protocol error for non compliant banks
                    is EbicsError.HTTP if e.status == HttpStatusCode.BadRequest -> {}
                    // Unexpected error
                    else -> throw e
                }
                logger.debug("${e.fmt()}")
            }
            dao.remove(tId)
        }

        val txLog = ebicsLogger.tx(order)

        // We need to run the logic in a non-cancelable context because we need to send 
        // a receipt for each open download transaction, otherwise we'll be stuck in an 
        // error loop until the pending transaction timeout.
        val (tId, initContent) = withContext(NonCancellable) {
            // Init phase
            val initReq = impl.downloadInitialization(startDate, endDate)
            val initContent = impl.postBTS(client, initReq, "Download init $description", txLog.step("init"))
            val tId = requireNotNull(initContent.transactionID) {
                "Download init $description: missing transaction ID"
            }
            dao.register(tId)
            Pair(tId, initContent)
        }
        val howManySegments = requireNotNull(initContent.numSegments) {
            "Download init $description: missing num segments"
        }
        val firstSegment = requireNotNull(initContent.segment) {
            "Download init $description: missing OrderData"
        }
        val dataEncryptionInfo = requireNotNull(initContent.dataEncryptionInfo) {
            "Download init $description: missing EncryptionInfo"
        }

        // Transfer phase
        val segments = mutableListOf(firstSegment)
        for (x in 2 .. howManySegments) {
            val transReq = impl.downloadTransfer(x, howManySegments, tId)
            val transResp = impl.postBTS(client, transReq, "Download transfer $description", txLog.step("transfer$x"))
            val segment = requireNotNull(transResp.segment) {
                "Download transfer: missing encrypted segment"
            }
            segments.add(segment)
        }


        // Decompress encrypted chunks
        val payloadStream = try {
            decryptAndDecompressPayload(
                clientKeys.encryption_private_key,
                dataEncryptionInfo,
                segments
            )
        } catch (e: Exception) {
            throw EbicsError.Protocol("invalid chunks", e)
        }

        val container = when (order) {
            is EbicsOrder.V2_5 -> "rax" // TODO infer ?
            is EbicsOrder.V3 -> order.container ?: "xml"
        }
        val loggedStream = txLog.payload(payloadStream, container)

        // Run business logic
        val res = runCatching {
            processing(loggedStream)
        }

        // First send a proper EBICS transaction receipt
        val xml = impl.downloadReceipt(tId, res.isSuccess && !peek)
        impl.postBTS(client, xml, "Download receipt $description", txLog.step("receipt"))
        runCatching { dao.remove(tId) }
        // Then throw business logic exception if any
        return res.getOrThrow()
    }

    /** 
     * Performs an EBICS upload transaction of [order] using [payload].
     * 
     * It conducts init -> upload phases.
     * 
     * Returns upload orderID
     */
    suspend fun upload(
        order: EbicsOrder,
        payload: ByteArray,
    ): String {
        val description = order.description();
        logger.debug { "Upload order $description" }
        val txLog = ebicsLogger.tx(order)
        val impl = EbicsBTS(cfg, bankKeys, clientKeys, order)
        val preparedPayload = prepareUploadPayload(cfg, clientKeys, bankKeys, payload)
        
        txLog.payload(payload, "xml")

        // Init phase
        val initXml = impl.uploadInitialization(preparedPayload)
        val initResp = impl.postBTS(client, initXml, "Upload init $description", txLog.step("init"))
        val tId = requireNotNull(initResp.transactionID) {
            "Upload init $description: missing transaction ID"
        }
        val orderId = requireNotNull(initResp.orderID) {
            "Upload init $description: missing order ID"
        }

        // Transfer phase
        for (i in 1..preparedPayload.segments.size) {
            val transferXml = impl.uploadTransfer(tId, preparedPayload, i)
            impl.postBTS(client, transferXml, "Upload transfer $description", txLog.step("transfer$i"))
        }
        return orderId
    } 
}

suspend fun HEV(
    client: HttpClient,
    cfg: EbicsHostConfig,
    ebicsLogger: EbicsLogger
): List<VersionNumber> {
    logger.info("Doing administrative request HEV")
    val txLog = ebicsLogger.tx("HEV")
    val req = EbicsAdministrative.HEV(cfg)
    val xml = client.postToBank(cfg.baseUrl, req, "HEV", txLog.step())
    return EbicsAdministrative.parseHEV(xml).okOrFail("HEV")
}

suspend fun keyManagement(
    cfg: EbicsHostConfig,
    privs: ClientPrivateKeysFile,
    client: HttpClient,
    ebicsLogger: EbicsLogger,
    order: EbicsKeyMng.Order,
    ebics3: Boolean
): EbicsResponse<InputStream?> {
    logger.info("Doing key request $order")
    val txLog = ebicsLogger.tx(order.name)
    // TODO is this still necessary ?
    val req = EbicsKeyMng(cfg, privs, ebics3).request(order)
    val xml = client.postToBank(cfg.baseUrl, req, order.name, txLog.step())
    return EbicsKeyMng.parseResponse(xml, privs.encryption_private_key)
}

class PreparedUploadData(
    val transactionKey: ByteArray,
    val userSignatureDataEncrypted: String,
    val dataDigest: ByteArray,
    val segments: List<String>
)

/** Signs, encrypts and format EBICS BTS payload */
fun prepareUploadPayload(
    cfg: EbicsHostConfig,
    clientKeys: ClientPrivateKeysFile,
    bankKeys: BankPublicKeysFile,
    payload: ByteArray,
): PreparedUploadData {
    val payloadDigest = CryptoUtil.digestEbicsOrderA006(payload)
    val innerSignedEbicsXml = XmlBuilder.toBytes("UserSignatureData") {
        attr("xmlns", "http://www.ebics.org/S002")
        el("OrderSignatureData") {
            el("SignatureVersion", "A006")
            el("SignatureValue", CryptoUtil.signEbicsA006(
                payloadDigest,
                clientKeys.signature_private_key,
            ).encodeBase64())
            el("PartnerID", cfg.partnerId)
            el("UserID", cfg.userId)
        }
    }
    // Generate ephemeral transaction key
    val (transactionKey, encryptedTransactionKey) = CryptoUtil.genEbicsE002Key(bankKeys.bank_encryption_public_key)
    // Compress and encrypt order signature
    val orderSignature = CryptoUtil.encryptEbicsE002(
        transactionKey,
        innerSignedEbicsXml.inputStream().deflate()
    ).encodeBase64()
    // Compress and encrypt payload
    val encrypted = CryptoUtil.encryptEbicsE002(
        transactionKey,
        payload.inputStream().deflate()
    )
    // Chunks of 1MB and encode segments
    val segments = encrypted.encodeBase64().chunked(1000000)

    return PreparedUploadData(
        encryptedTransactionKey,
        orderSignature,
        payloadDigest,
        segments
    )
}

/** Decrypts and decompresses EBICS BTS payload */
fun decryptAndDecompressPayload(
    clientEncryptionKey: RSAPrivateCrtKey,
    encryptionInfo: DataEncryptionInfo,
    segments: List<ByteArray>
): InputStream {
    val transactionKey = CryptoUtil.decryptEbicsE002Key(clientEncryptionKey, encryptionInfo.transactionKey)
    return SequenceInputStream(Collections.enumeration(segments.map { it.inputStream() })) // Aggregate
        .run {
            CryptoUtil.decryptEbicsE002(
                transactionKey,
                this
            )
        }.inflate()
}

/** Generate a secure random nonce of [size] bytes */
fun getNonce(size: Int): ByteArray {
    return ByteArray(size / 8).secureRand()
}

private val EBICS_ID_ALPHABET = ('A'..'Z') + ('0'..'9')

fun randEbicsId(): String {
    return List(34) { EBICS_ID_ALPHABET.random() }.joinToString("")
}

class DataEncryptionInfo(
    val transactionKey: ByteArray,
    val bankPubDigest: ByteArray
)

class EbicsResponse<T>(
    val technicalCode: EbicsReturnCode,
    val bankCode: EbicsReturnCode,
    internal val content: T
) {
    /** Checks that return codes are both EBICS_OK */
    fun ok(): T? {
        return if (technicalCode.kind() != EbicsReturnCode.Kind.Error &&
            bankCode.kind() != EbicsReturnCode.Kind.Error) {
                content
        } else {
            null
        }
    }

    /** Checks that return codes are both EBICS_OK or throw an exception */
    fun okOrFail(phase: String): T {
        if (technicalCode.kind() == EbicsReturnCode.Kind.Error) {
            throw EbicsError.Code("$phase has technical error: $technicalCode", technicalCode, bankCode)
        } else if (bankCode.kind() == EbicsReturnCode.Kind.Error) {
            throw EbicsError.Code("$phase has bank error: $bankCode", technicalCode, bankCode)
        } else {
            return content
        }
    }
}