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

import tech.libeufin.common.*
import tech.libeufin.nexus.*
import tech.libeufin.nexus.ebics.Dialect
import java.io.InputStream
import java.time.Instant
import java.time.ZoneOffset

sealed interface TxNotification {
    val executionTime: Instant
}

/** ISO20022 incoming payment */
data class IncomingPayment(
    /** ISO20022 UETR or TxID */
    val bankId: String? = null, // Null when TxID is wrong with Atruvia's implementation of instant transactions
    val amount: TalerAmount,
    val creditFee: TalerAmount? = null,
    val subject: String,
    override val executionTime: Instant,
    val debtorPayto: IbanPayto
): TxNotification {
    override fun toString(): String {
        val fee = if (creditFee == null) "" else "-$creditFee"
        return "IN ${executionTime.fmtDate()} $amount$fee $bankId debitor=$debtorPayto subject=\"$subject\""
    }
}

/** ISO20022 outgoing payment */
data class OutgoingPayment(
    /** ISO20022 EndToEndId or MessageId (retrocompatibility) */
    val endToEndId: String,
    /** ISO20022 MessageId */
    val msgId: String? = null,
    val amount: TalerAmount,
    val subject: String? = null, // Some implementation does not provide this for recovery
    override val executionTime: Instant,
    val creditorPayto: IbanPayto? = null // Some implementation does not provide this for recovery
): TxNotification {
    override fun toString(): String {
        val msgIdFmt = if (msgId == null) "" else "$msgId."
        return "OUT ${executionTime.fmtDate()} $amount $msgIdFmt$endToEndId creditor=$creditorPayto subject=\"$subject\""
    }
}

/** ISO20022 outgoing batch */
data class OutgoingBatch(
    /** ISO20022 MessageId */
    val msgId: String,
    override val executionTime: Instant,
): TxNotification {
    override fun toString(): String {
        return "BATCH ${executionTime.fmtDate()} $msgId"
    }
}

/** ISO20022 outgoing reversal */
data class OutgoingReversal(
    /** ISO20022 EndToEndId */
    val endToEndId: String,
    /** ISO20022 MessageId */
    val msgId: String? = null,
    val reason: String?,
    override val executionTime: Instant
): TxNotification {
    override fun toString(): String {
        val msgIdFmt = if (msgId == null) "" else "$msgId."
        return "BOUNCE ${executionTime.fmtDate()} $msgIdFmt$endToEndId: $reason"
    }
}

private class TxErr(val msg: String): Exception(msg)

private enum class Kind {
    CRDT,
    DBIT
}

/** Unique ID generated by libeufin-nexus */
data class OutgoingId(
    // Unique msg ID generated by libeufin-nexus
    val msgId: String?,
    // Unique end-to-end ID generated by libeufin-nexus
    val endToEndId: String?
) {
    fun ref(): String? = endToEndId ?: msgId
}

/** Parse a payto */
private fun XmlDestructor.payto(prefix: String): IbanPayto? {
    return opt("RltdPties") { 
        val iban = opt("${prefix}Acct")?.one("Id")?.one("IBAN")?.text()
        if (iban != null) {
            val name = opt(prefix) { opt("Nm")?.text() ?: opt("Pty")?.one("Nm")?.text() }
            // TODO more performant option
            ibanPayto(iban, name)
        } else {
            null
        }
    }
}

/** Check if an entry status is BOOK */
private fun XmlDestructor.isBooked(): Boolean {
    // We check at the Sts or Sts/Cd level for retrocompatibility
    return one("Sts") {
        val status = opt("Cd")?.text() ?: text()
        status == "BOOK"
    }
}

/** Parse the instruction execution date */
private fun XmlDestructor.executionDate(): Instant {
    // Value date if present else booking date
    val date = opt("ValDt") ?: one("BookgDt")
    val parsed = date.opt("Dt") {
        date().atStartOfDay()
    } ?: date.one("DtTm") {
        dateTime()
    }
    return parsed.toInstant(ZoneOffset.UTC)
}

/** Parse batch message ID and transaction end-to-end ID as generated by libeufin-nexus */
private fun XmlDestructor.outgoingId(): OutgoingId = one("Refs") {
    val endToEndId = opt("EndToEndId")?.text()
    val msgId = opt("MsgId")?.text()
    if (endToEndId == null) {
        // This is a batch representation
        OutgoingId(msgId, null)
    } else if (endToEndId == "NOTPROVIDED") {
        // If not set use MsgId as end-to-end ID for retrocompatibility
        OutgoingId(msgId, msgId)
    } else {
        OutgoingId(msgId, endToEndId)
    }
}

/** Parse and format transaction return reasons */
private fun XmlDestructor.returnReason(): String = opt("RtrInf") {
    val code = one("Rsn").one("Cd").enum<ExternalReturnReasonCode>()
    val info = map("AddtlInf") { text() }.joinToString("")
    buildString {
        append("${code.isoCode} '${code.description}'")
        if (info.isNotEmpty()) {
            append(" - '$info'")
        }
    }
} ?: opt("RmtInf") {
    map("Ustrd") { text() }.joinToString("")
} ?: ""

/** Parse amount */
private fun XmlDestructor.amount() = one("Amt") {
    val currency = attr("Ccy")
    val amount = text()
    val concat = if (amount.startsWith('.')) {
        "$currency:0$amount"
    } else {
        "$currency:$amount"
    }
    TalerAmount(concat)
}

/** Parse credit fees */
private fun XmlDestructor.creditFee(): TalerAmount? {
    var charges: TalerAmount? = null
    opt("Chrgs")?.each("Rcrd") {
        if (one("ChrgInclInd").bool() && one("CdtDbtInd").text() == "DBIT" ) {
            val amount = amount()
            charges = charges?.let { it + amount } ?: amount
        }
    }
    return charges
}

/** Parse amounts and compute fees */
private fun XmlDestructor.amountAndFee(): Pair<TalerAmount, TalerAmount?> {
    var amount = amount()
    val charges = creditFee()
    if (charges != null) {
        amount += charges
    }
    return Pair(amount, charges)
}

/** Parse bank transaction code */
private fun XmlDestructor.bankTransactionCode(): BankTransactionCode {
    return one("BkTxCd").one("Domn") {
        val domain = one("Cd").enum<ExternalBankTransactionDomainCode>()
        one("Fmly") {
            val family = one("Cd").enum<ExternalBankTransactionFamilyCode>()
            val subFamily = one("SubFmlyCd").enum<ExternalBankTransactionSubFamilyCode>()
            
            BankTransactionCode(domain, family, subFamily)
        }
    }
}

/** Parse optional bank transaction code */
private fun XmlDestructor.optBankTransactionCode(): BankTransactionCode? {
    return opt("BkTxCd")?.one("Domn") {
        val domain = one("Cd").enum<ExternalBankTransactionDomainCode>()
        one("Fmly") {
            val family = one("Cd").enum<ExternalBankTransactionFamilyCode>()
            val subFamily = one("SubFmlyCd").enum<ExternalBankTransactionSubFamilyCode>()
            
            BankTransactionCode(domain, family, subFamily)
        }
    }
}

/** Parse transaction wire transfer subject */
private fun XmlDestructor.wireTransferSubject(): String? {
    return opt("RmtInf")?.map("Ustrd") { text() }?.joinToString("")?.trim()
}

/** Parse account information */
private fun XmlDestructor.account(): Pair<String, String?> = one("Acct") {
    Pair(
        one("Id") {
            (opt("IBAN") ?: one("Othr").one("Id")).text()
        },
        opt("Ccy")?.text()
    )
}

data class AccountTransactions(
    val iban: String?,
    val currency: String?,
    val txs: List<TxNotification>
) {
    companion object {
        internal fun fromParts(iban: String?, currency: String?, txsInfos: List<TxInfo>): AccountTransactions {
            val txs = txsInfos.mapNotNull {
                try {
                    it.parse()
                } catch (e: TxErr) {
                    // TODO: add more info in doc or in log message?
                    logger.warn("skip incomplete tx: ${e.msg}")
                    null
                }    
            }
            return AccountTransactions(iban, currency, txs)
        }
    }
}

/** Parse camt.054 or camt.053 file */
fun parseTx(notifXml: InputStream, dialect: Dialect): List<AccountTransactions> {
    /*
        In ISO 20022 specifications, most fields are optional and the same information 
        can be written several times in different places. For libeufin, we're only 
        interested in a subset of the available values that can be found in both camt.052,
        camt.053 and camt.054. This function should not fail on legitimate files and should 
        simply warn when available information are insufficient.

        EBICS and ISO20022 do not provide a perfect transaction identifier. The best is the 
        UETR (unique end-to-end transaction reference), which is a universally unique 
        identifier (UUID). However, it is not supplied by all banks. TxId (TransactionIdentification) 
        is a unique identification as assigned by the first instructing agent. As its format 
        is ambiguous, its uniqueness is not guaranteed by the standard, and it is only 
        supposed to be unique for a “pre-agreed period”, whatever that means. These two 
        identifiers are optional in the standard, but have the advantage of being unique 
        and can be used to track a transaction between banks so we use them when available.

        It is also possible to use AccountServicerReference, which is a unique reference 
        assigned by the account servicing institution. They can be present at several levels
        (batch level, transaction level, etc.) and are often optional. They also have the 
        disadvantage of being known only by the account servicing institution. They should 
        therefore only be used as a last resort.
    */
    val accountTxs = mutableListOf<AccountTransactions>()
    XmlDestructor.fromStream(notifXml, "Document") { when (dialect) {
        Dialect.gls -> {
            /** Common parsing logic for camt.052 and camt.053 */
            fun XmlDestructor.parseGlsInner() {
                val (iban, currency) = account()
                val txsInfo = mutableListOf<TxInfo>()
                each("Ntry") {
                    if (!isBooked()) return@each
                    val code = bankTransactionCode()
                    if (!code.isPayment()) return@each
                    val entryRef = opt("AcctSvcrRef")?.text()
                    val bookDate = executionDate()
                    val kind = one("CdtDbtInd").enum<Kind>()
                    val amount = amount()
                    one("NtryDtls").one("TxDtls") { // TODO handle batches
                        val code = optBankTransactionCode() ?: code
                        val txRef = opt("Refs")?.opt("AcctSvcrRef")?.text()
                        if (code.isReversal()) {
                            val outgoingId = outgoingId()
                            if (kind == Kind.CRDT) {
                                val reason = returnReason()
                                txsInfo.add(TxInfo.CreditReversal(
                                    ref = outgoingId.ref() ?: txRef ?: entryRef,
                                    bookDate = bookDate,
                                    id = outgoingId,
                                    reason = reason,
                                    code = code
                                ))
                            }
                        } else {
                            val subject = wireTransferSubject()
                            when (kind) {
                                Kind.CRDT -> {
                                    val bankId = one("Refs").opt("TxId")?.text()
                                    val debtorPayto = payto("Dbtr")
                                    txsInfo.add(TxInfo.Credit(
                                        ref = bankId ?: txRef ?: entryRef,
                                        bookDate = bookDate,
                                        bankId = bankId,
                                        amount = amount,
                                        subject = subject,
                                        debtorPayto = debtorPayto,
                                        code = code,
                                        creditFee = null
                                    ))
                                }
                                Kind.DBIT -> {
                                    val outgoingId = outgoingId()
                                    val creditorPayto = payto("Cdtr")
                                    txsInfo.add(TxInfo.Debit(
                                        ref = outgoingId.ref() ?: txRef ?: entryRef,
                                        bookDate = bookDate,
                                        id = outgoingId,
                                        amount = amount,
                                        subject = subject,
                                        creditorPayto = creditorPayto,
                                        code = code
                                    ))
                                }
                            }
                        }
                    }
                }
                accountTxs.add(AccountTransactions.fromParts(iban, currency, txsInfo))
            }
            opt("BkToCstmrStmt")?.each("Stmt") { // Camt.053
                // All transactions appear here the day after they are booked
                parseGlsInner()
            }
            opt("BkToCstmrAcctRpt")?.each("Rpt") { // Camt.052
                // Transactions might appear here first before the end of the day
                parseGlsInner()
            }
            opt("BkToCstmrDbtCdtNtfctn")?.each("Ntfctn") { // Camt.054
                // Instant transactions appear here a few seconds after being booked
                val (iban, currency) = account()
                val txsInfo = mutableListOf<TxInfo>()
                each("Ntry") {
                    if (!isBooked()) return@each
                    val code = bankTransactionCode()
                    if (code.isReversal() || !code.isPayment()) return@each
                    val entryRef = opt("AcctSvcrRef")?.text()
                    val bookDate = executionDate()
                    val kind = one("CdtDbtInd").enum<Kind>()
                    val amount = amount()
                    one("NtryDtls").one("TxDtls") {
                        val code = optBankTransactionCode() ?: code
                        val txRef = opt("Refs")?.opt("AcctSvcrRef")?.text()
                        val subject = wireTransferSubject()
                        if (kind == Kind.CRDT) {
                            val bankId = one("Refs").opt("TxId")?.text()
                            val debtorPayto = payto("Dbtr")
                            txsInfo.add(TxInfo.Credit(
                                ref = txRef ?: entryRef,
                                bookDate = bookDate,
                                // TODO use the bank ID again when Atruvia's implementation is fixed
                                bankId = null,
                                amount = amount,
                                subject = subject,
                                debtorPayto = debtorPayto,
                                code = code,
                                creditFee = null
                            ))
                        }
                    }
                }
                accountTxs.add(AccountTransactions.fromParts(iban, currency, txsInfo))
            }
        }
        Dialect.postfinance -> {
            opt("BkToCstmrStmt")?.each("Stmt") { // Camt.053
                /*
                    All transactions appear here on the day following their booking. Alas, some 
                    necessary metadata is missing, which is only present in camt.054. However, 
                    this file contains the structured return reasons that are missing from the 
                    camt.054 files. That's why we only use this file for this purpose.
                */
                val (iban, currency) = account()
                val txsInfo = mutableListOf<TxInfo>()
                each("Ntry") {
                    if (!isBooked()) return@each
                    val code = bankTransactionCode()
                    // Non reversal transaction are handled in camt.054
                    if (!(code.isReversal() && code.isPayment())) return@each

                    val entryRef = opt("AcctSvcrRef")?.text()
                    val bookDate = executionDate()
                    one("NtryDtls").one("TxDtls") {
                        val kind = one("CdtDbtInd").enum<Kind>()
                        val code = optBankTransactionCode() ?: code
                        if (kind == Kind.CRDT) {
                            val txRef = opt("Refs")?.opt("AcctSvcrRef")?.text()
                            val outgoingId = outgoingId()
                            val reason = returnReason()
                            txsInfo.add(TxInfo.CreditReversal(
                                ref = outgoingId.ref() ?: txRef ?: entryRef,
                                bookDate = bookDate,
                                id = outgoingId,
                                reason = reason,
                                code = code
                            ))
                        }
                    }
                }
                accountTxs.add(AccountTransactions.fromParts(iban, currency, txsInfo))
            }
            opt("BkToCstmrDbtCdtNtfctn")?.each("Ntfctn") { // Camt.054
                // Instant transactions appear here a moment after being booked
                val (iban, currency) = account()
                val txsInfo = mutableListOf<TxInfo>()
                each("Ntry") {
                    if (!isBooked()) return@each
                    val code = bankTransactionCode()
                    // Reversal are handled from camt.053
                    if (code.isReversal() || !code.isPayment()) return@each

                    val entryRef = opt("AcctSvcrRef")?.text()
                    val bookDate = executionDate()
                    one("NtryDtls").each("TxDtls") {
                        val kind = one("CdtDbtInd").enum<Kind>()
                        val code = optBankTransactionCode() ?: code
                        val amount = amount()
                        val txRef = opt("Refs")?.opt("AcctSvcrRef")?.text()
                        val subject = wireTransferSubject()
                        when (kind) {
                            Kind.CRDT -> {
                                val bankId = opt("Refs")?.opt("UETR")?.text()
                                val debtorPayto = payto("Dbtr")
                                txsInfo.add(TxInfo.Credit(
                                    ref = bankId ?: txRef ?: entryRef,
                                    bookDate = bookDate,
                                    bankId = bankId,
                                    amount = amount,
                                    subject = subject,
                                    debtorPayto = debtorPayto,
                                    code = code,
                                    creditFee = null
                                ))
                            }
                            Kind.DBIT -> {
                                val outgoingId = outgoingId()
                                val creditorPayto = payto("Cdtr")
                                txsInfo.add(TxInfo.Debit(
                                    ref = outgoingId.ref() ?: txRef ?: entryRef,
                                    bookDate = bookDate,
                                    id = outgoingId,
                                    amount = amount,
                                    subject = subject,
                                    creditorPayto = creditorPayto,
                                    code = code
                                ))
                            }
                        }
                    }
                }
                accountTxs.add(AccountTransactions.fromParts(iban, currency, txsInfo))
            }
        }
        Dialect.maerki_baumann -> {
            opt("BkToCstmrStmt")?.each("Stmt") { // Camt.053
                val (iban, currency) = account()
                val txsInfo = mutableListOf<TxInfo>()
                each("Ntry") {
                    if (!isBooked()) return@each
                    val code = bankTransactionCode()
                    if (!code.isPayment()) return@each
                    val kind = one("CdtDbtInd").enum<Kind>()
                    val reversal = one("RvslInd").bool()
                    val entryRef = opt("AcctSvcrRef")?.text()
                    val bookDate = executionDate()
                    if (reversal) {
                        // Check reversal by fee over amount
                        require(kind == Kind.DBIT) { "reversal credit not yet supported" }
                        val fee = requireNotNull(creditFee()) { "Missing fee" }
                        val amount = amount()
                        one("NtryDtls").one("TxDtls") {
                            val txRef = opt("Refs")?.opt("AcctSvcrRef")?.text()
                            val bankId = opt("Refs")?.opt("UETR")?.text()
                            val subject = wireTransferSubject()
                            val debtorPayto = payto("Dbtr")
                            txsInfo.add(TxInfo.Credit(
                                ref = bankId ?: txRef ?: entryRef,
                                bookDate = bookDate,
                                bankId = bankId,
                                amount = amount,
                                subject = subject,
                                debtorPayto = debtorPayto,
                                code = code,
                                creditFee = fee
                            ))
                        }
                    } else {
                        one("NtryDtls").one("TxDtls") {
                            val txRef = opt("Refs")?.opt("AcctSvcrRef")?.text()
                            val kind = one("CdtDbtInd").enum<Kind>()
                            val code = optBankTransactionCode() ?: code
                            val (amount, fee) = amountAndFee()
                            val subject = wireTransferSubject()
                            if (!code.isReversal()) {
                                when (kind) {
                                    Kind.CRDT -> {
                                        val bankId = opt("Refs")?.opt("UETR")?.text()
                                        val debtorPayto = payto("Dbtr")
                                        txsInfo.add(TxInfo.Credit(
                                            ref = bankId ?: txRef ?: entryRef,
                                            bookDate = bookDate,
                                            bankId = bankId,
                                            amount = amount,
                                            subject = subject,
                                            debtorPayto = debtorPayto,
                                            code = code,
                                            creditFee = fee
                                        ))
                                    }
                                    Kind.DBIT -> {
                                        val outgoingId = outgoingId()
                                        val creditorPayto = payto("Cdtr")
                                        txsInfo.add(TxInfo.Debit(
                                            ref = outgoingId.ref() ?: txRef ?: entryRef,
                                            bookDate = bookDate,
                                            id = outgoingId,
                                            amount = amount,
                                            subject = subject,
                                            creditorPayto = creditorPayto,
                                            code = code
                                        ))
                                    }
                                }
                            }
                        }
                    }
                }
                accountTxs.add(AccountTransactions.fromParts(iban, currency, txsInfo))
            }
        }
    }}
    return accountTxs
}

sealed interface TxInfo {
    // Bank provider ref for debugging
    val ref: String?
    // When was this transaction booked
    val bookDate: Instant
    // ISO20022 bank transaction code
    val code: BankTransactionCode
    data class CreditReversal(
        override val ref: String?,
        override val bookDate: Instant,
        override val code: BankTransactionCode,
        // Unique ID generated by libeufin-nexus
        val id: OutgoingId,
        val reason: String
    ): TxInfo
    data class Credit(
        override val ref: String?,
        override val bookDate: Instant,
        override val code: BankTransactionCode,
        // Unique ID generated by payment provider
        val bankId: String?,
        val amount: TalerAmount,
        val creditFee: TalerAmount?,
        val subject: String?,
        val debtorPayto: IbanPayto?
    ): TxInfo
    data class Debit(
        override val ref: String?,
        override val bookDate: Instant,
        override val code: BankTransactionCode,
        // Unique ID generated by libeufin-nexus
        val id: OutgoingId,
        val amount: TalerAmount,
        val subject: String?,
        val creditorPayto: IbanPayto?
    ): TxInfo

    fun parse(): TxNotification {
        return when (this) {
            is TxInfo.CreditReversal -> {
                if (id.endToEndId == null) 
                    throw TxErr("missing end-to-end ID for Credit reversal $ref")
                OutgoingReversal(
                    endToEndId = id.endToEndId!!,
                    msgId = id.msgId,
                    reason = reason,
                    executionTime = bookDate
                )
            }
            is TxInfo.Credit -> {
                /*if (bankId == null) TODO use the bank ID again when Atruvia's implementation is fixed
                    throw TxErr("missing bank ID for Credit $ref")*/
                if (subject == null)
                    throw TxErr("missing subject for Credit $ref")
                if (debtorPayto == null)
                    throw TxErr("missing debtor info for Credit $ref")
                IncomingPayment(
                    amount = amount,
                    creditFee = creditFee,
                    bankId = bankId,
                    debtorPayto = debtorPayto,
                    executionTime = bookDate,
                    subject = subject,
                )
            }
            is TxInfo.Debit -> {
                if (id.endToEndId == null && id.msgId == null) {
                    throw TxErr("missing end-to-end ID for Debit $ref")
                } else if (id.endToEndId != null) {
                    OutgoingPayment(
                        amount = amount,
                        endToEndId = id.endToEndId,
                        msgId = id.msgId,
                        executionTime = bookDate,
                        creditorPayto = creditorPayto,
                        subject = subject
                    )
                } else {
                    OutgoingBatch(
                        msgId = id.msgId!!,
                        executionTime = bookDate,
                    )
                }
            }
        }
    }
}

data class BankTransactionCode(
    val domain: ExternalBankTransactionDomainCode,
    val family: ExternalBankTransactionFamilyCode,
    val subFamily: ExternalBankTransactionSubFamilyCode
) {
    fun isReversal(): Boolean = REVERSAL_CODE.contains(subFamily)
    fun isPayment(): Boolean = domain == ExternalBankTransactionDomainCode.PMNT || subFamily == ExternalBankTransactionSubFamilyCode.PSTE

    override fun toString(): String = 
        "${domain.name} ${family.name} ${subFamily.name} - '${domain.description}' '${family.description}' '${subFamily.description}'"

    companion object {
        private val REVERSAL_CODE = setOf(
            ExternalBankTransactionSubFamilyCode.RPCR,
            ExternalBankTransactionSubFamilyCode.RRTN,
            ExternalBankTransactionSubFamilyCode.PSTE,
        )
    }
}