/*
 * This file is part of LibEuFin.
 * Copyright (C) 2024-2025 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.ebics.XmlDestructor
import java.io.InputStream
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
import org.slf4j.Logger
import org.slf4j.LoggerFactory

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

sealed interface TxNotification {
    val executionTime: Instant
}

/** ID for incoming transactions */
data class IncomingId(
    /** ISO20022 UETR */
    val uetr: UUID? = null,
    /** ISO20022 TxID */
    val txId: String? = null,
    /** ISO20022 AcctSvcrRef */
    val acctSvcrRef: String? = null,
) {
    constructor(uetr: String, txId: String?, acctSvcrRef: String?) : this(UUID.fromString(uetr), txId, acctSvcrRef);

    fun ref(): String = uetr?.toString() ?: txId ?: acctSvcrRef!!

    override fun toString(): String = buildString {
        append('(')
        if (uetr != null) {
            append("uetr=")
            append(uetr.toString())
        }
        if (txId != null) {
            if (length != 1) append(" ")
            append("tx=")
            append(txId)
        }
        if (acctSvcrRef != null) {
            if (length != 1) append(" ")
            append("ref=")
            append(acctSvcrRef)
        }
        append(')')
    }
}

sealed interface OutId {}

/** ID for outgoing transactions */
data class OutgoingId(
    /** 
     * Unique msg ID generated by libeufin-nexus
     * ISO20022 MessageId
     **/
    val msgId: String? = null,
    /** 
     * Unique end-to-end ID generated by libeufin-nexus
     * ISO20022 EndToEndId or MessageId (retrocompatibility)
     **/
    val endToEndId: String? = null,
    /** 
     * Unique end-to-end ID generated by the bank
     * ISO20022 AcctSvcrRef
     **/
    val acctSvcrRef: String? = null,
): OutId {
    fun ref(): String = endToEndId ?: acctSvcrRef ?: msgId!!
    override fun toString(): String = buildString {
        append('(')
        if (msgId != null && msgId != endToEndId) {
            append("msg=")
            append(msgId.toString())
        }
        if (endToEndId != null) {
            if (length != 1) append(" ")
            append("e2e=")
            append(endToEndId)
        }
        if (acctSvcrRef != null) {
            if (length != 1) append(" ")
            append("ref=")
            append(acctSvcrRef)
        }
        append(')')
    }
}

/** ID for outgoing batches */
data class BatchId(
    /** 
     * Unique msg ID generated by libeufin-nexus
     * ISO20022 MessageId
     **/
    val msgId: String,
    /** 
     * Unique end-to-end ID generated by the bank
     * ISO20022 AcctSvcrRef
     **/
    val acctSvcrRef: String? = null,
): OutId {
    fun ref(): String = msgId
    override fun toString(): String = buildString {
        append("(msg=")
        append(msgId)
        if (acctSvcrRef != null) {
            if (length != 1) append(" ")
            append("ref=")
            append(acctSvcrRef)
        }
        append(')')
    }
}


/** ISO20022 incoming payment */
data class IncomingPayment(
    val id: IncomingId,
    val amount: TalerAmount,
    val creditFee: TalerAmount? = null,
    val subject: String?,
    override val executionTime: Instant,
    val debtor: IbanPayto?
): TxNotification {
    override fun toString(): String = buildString {
        append("IN ")
        append(executionTime.fmtDate())
        append(" ")
        append(amount)
        if (creditFee != null) {
            append("-")
            append(creditFee)
        }
        append(" ")
        append(id)
        if (debtor != null) {
            append(" debtor=")
            append(debtor.fmt())
        }
        if (subject != null) {
            append(" subject='")
            append(subject)
            append("'")
        }
    }
}

/** ISO20022 outgoing payment */
data class OutgoingPayment(
    val id: OutgoingId,
    val amount: TalerAmount,
    val debitFee: TalerAmount? = null,
    val subject: String?,
    override val executionTime: Instant,
    val creditor: IbanPayto?
): TxNotification {
    override fun toString(): String = buildString {
        append("OUT ")
        append(executionTime.fmtDate())
        append(" ")
        append(amount)
        if (debitFee != null) {
            append("-")
            append(debitFee)
        }
        append(" ")
        append(id)
        if (creditor != null) {
            append(" creditor=")
            append(creditor.fmt())
        }
        if (subject != null) {
            append(" subject='")
            append(subject)
            append("'")
        }
    }
}

/** 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 IncompleteTx(val msg: String): Exception(msg)

private enum class Kind {
    CRDT,
    DBIT
}

/** Parse a payto */
private fun XmlDestructor.payto(prefix: String): IbanPayto? {
    return opt("RltdPties") { 
        val iban = opt("${prefix}Acct")?.one("Id")?.opt("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(ref: String?): OutId = 
    opt("Refs") {
        val endToEndId = opt("EndToEndId")?.text()
        val msgId = opt("MsgId")?.text()
        val ref = if (ref != "NOTPROVIDED") ref else null
        if (msgId != null && endToEndId == null) {
            // This is a batch representation
            BatchId(msgId, ref)
        } else if (endToEndId == "NOTPROVIDED") {
            // If not set use MsgId as end-to-end ID for retrocompatibility
            OutgoingId(msgId, msgId, ref)
        } else {
            OutgoingId(msgId, endToEndId, ref)
        }
    } ?: OutgoingId(acctSvcrRef = ref)

/** Parse transaction ids as provided by bank*/
private fun XmlDestructor.incomingId(ref: String?): IncomingId =
    opt("Refs") {
        val uetr = opt("UETR")?.uuid()
        val txId = opt("TxId")?.text()
        IncomingId(uetr, txId, ref)
    } ?: IncomingId(acctSvcrRef = ref)


/** 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)
}

data class ComplexAmount(
    // Transaction amount
    val amount: TalerAmount,
    // The applied fee
    private val fee: TalerAmount,
) {
    /// The fees to register in database
    fun fee(): TalerAmount? = if (fee.isZero()) { null } else { fee }

    /// Check that entry and tx amount are compatible and return the result
    fun resolve(child: ComplexAmount): ComplexAmount {
        // Most time transaction will match
        if (this.amount == child.amount && this.fee == child.fee) {
            return this
        }
        
        // Or one of the level is missing the fee
        if (
            (child.amount > child.fee && child.amount - child.fee == this.amount) ||
            this.amount - this.fee == child.amount
        ) {
            if (child.fee.isZero()) {
                return this
            } else {
                return child
            }
        }
        
        // Or the conversion information are only present at the entry layer
        if (child.amount.currency != this.amount.currency) {
            return this
        }

        throw Error("Amount mismatch, got ${this} in the entry and ${child} in the tx")
    }
}

private fun XmlDestructor.complexAmount(charges: List<ChargeRecord>): ComplexAmount? {
    // Amount before charges
    var amount = opt("Amt") {
        val currency = attr("Ccy")
        // In case of fee overflow it's possible to have a negative amount here
        // We ignore this as it will be handled elsewhere correctly
        val amount = text().trimStart('-')
        TalerAmount("$currency:0$amount")
    } ?: return null

    var fee: TalerAmount = TalerAmount.zero(amount.currency)

    for (chr in charges) {
        if (chr.included && !chr.amount.isZero()) {
            fee += chr.amount
            if (chr.kind == Kind.DBIT) {
                if (chr.bearer == ChargeBearer.DEBT) {
                    if (chr.amount > amount) {
                        // This can happen when an incoming transaction fail because of debit fee
                        amount = chr.amount - amount
                    } else {
                        amount -= chr.amount
                    }
                } else if (chr.bearer == ChargeBearer.CRED) {
                    amount += chr.amount
                } else {
                    throw Error("Included charge ${chr.kind} with bearer ${chr.bearer}")
                }
            }
        }
    }
    
    return ComplexAmount(amount, fee)
}

/** 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? = 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()
    )
}

private data class ChargeRecord(
    val amount: TalerAmount,
    val kind: Kind,
    val included: Boolean,
    val bearer: ChargeBearer
)
private fun XmlDestructor.charges(): List<ChargeRecord> = opt("Chrgs")?.map("Rcrd") {
    val amount = amount()
    val kind = opt("CdtDbtInd")?.enum<Kind>() ?: Kind.CRDT
    val included = opt("ChrgInclInd")?.bool() ?: true // TODO not clear in spec
    val bearer = opt("Br")?.enum<ChargeBearer>() ?: ChargeBearer.SHAR
    ChargeRecord(amount, kind, included, bearer)
} ?: emptyList()

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: IncompleteTx) {
                    // 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): 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.
    */
    logger.trace("Parse transactions camt file")
    val accountTxs = mutableListOf<AccountTransactions>()

    /** Common parsing logic for camt.052, camt.053 and camt.054 */
    fun XmlDestructor.parseInner() {
        val (iban, currency) = account()
        val txInfos = mutableListOf<TxInfo>()
        val batches = each("Ntry") {
            if (!isBooked()) return@each
            val entryCode = bankTransactionCode()
            val reversal = opt("RvslInd")?.text() == "true"
            val entryKind = opt("CdtDbtInd")?.enum<Kind>();
            val entryRef = opt("AcctSvcrRef")?.text()
            val bookDate = executionDate()
            val entryCharges = charges()
            val entryAmount = complexAmount(entryCharges)!!

            // When an entry only contain a single transactions information will sometimes only be stored at the entry level
            val tmp = opt("NtryDtls")?.map("TxDtls") { this } ?: return@each 
            val unique = tmp.size == 1

            for (it in tmp) {it.run {
                // Check information are present and coherent
                val kind = requireNotNull(opt("CdtDbtInd")?.enum<Kind>() ?: entryKind) { "WTF" }

                // Sometimes the transaction level have a more precise bank transaction code
                val code = optBankTransactionCode() ?: entryCode

                // Amount
                val amount = if (unique) {
                    // When unique the charges can be only at the entry level
                    val txCharges = charges()
                    val txAmount = complexAmount(if (txCharges.isEmpty()) entryCharges else txCharges)
                    // Check coherence
                    if (txAmount != null) entryAmount.resolve(txAmount) else entryAmount
                } else {
                    // When many inner transaction the entry level is an aggregate of them
                    // We only use the transaction level information
                    requireNotNull(complexAmount(charges())) { "Missing tx amount" }
                }

                // We can only use the entry ref as the transaction ref if there is a single transaction in the batch
                val ref = opt("Refs")?.opt("AcctSvcrRef")?.text() ?: if (unique) entryRef else null
                
                if (code.isReversal() || reversal) {
                    val outgoingId = outgoingId(ref)
                    when (kind) {
                        Kind.CRDT -> {
                            val reason = returnReason()
                            txInfos.add(TxInfo.CreditReversal(
                                bookDate = bookDate,
                                id = outgoingId,
                                reason = reason,
                                code = code
                            ))
                        }
                        Kind.DBIT -> {
                            val id = incomingId(ref)
                            val subject = wireTransferSubject()
                            val debtor = payto("Dbtr")
                            val fee = amount.fee()
                            txInfos.add(TxInfo.Credit(
                                bookDate = bookDate,
                                id = id,
                                amount = amount.amount,
                                subject = subject,
                                debtor = debtor,
                                code = code,
                                creditFee = fee
                            ))
                        }
                    }
                } else {
                    val subject = wireTransferSubject()
                    when (kind) {
                        Kind.CRDT -> {
                            val id = incomingId(ref)
                            val debtor = payto("Dbtr")
                            txInfos.add(TxInfo.Credit(
                                bookDate = bookDate,
                                id = id,
                                amount = amount.amount,
                                subject = subject,
                                debtor = debtor,
                                code = code,
                                creditFee = amount.fee()
                            ))
                        }
                        Kind.DBIT -> {
                            val outgoingId = outgoingId(ref)
                            val creditor = payto("Cdtr")
                            txInfos.add(TxInfo.Debit(
                                bookDate = bookDate,
                                id = outgoingId,
                                amount = amount.amount,
                                subject = subject,
                                creditor = creditor,
                                code = code,
                                debitFee = amount.fee()
                            ))
                        }
                    }
                }
            }}
        }
        accountTxs.add(AccountTransactions.fromParts(iban, currency, txInfos))
    }
    XmlDestructor.parse(notifXml, "Document") {
        // Camt.053
        opt("BkToCstmrStmt")?.each("Stmt") { parseInner() }
        // Camt.052
        opt("BkToCstmrAcctRpt")?.each("Rpt") { parseInner() }
        // Camt.054
        opt("BkToCstmrDbtCdtNtfctn")?.each("Ntfctn") { parseInner() }
    }
    return accountTxs
}

sealed interface TxInfo {
    data class CreditReversal(
        val bookDate: Instant,
        val code: BankTransactionCode,
        val id: OutId,
        val reason: String
    ): TxInfo
    data class Credit(
        val bookDate: Instant,
        val code: BankTransactionCode,
        val id: IncomingId,
        val amount: TalerAmount,
        val creditFee: TalerAmount?,
        val subject: String?,
        val debtor: IbanPayto?
    ): TxInfo
    data class Debit(
        val bookDate: Instant,
        val code: BankTransactionCode,
        val id: OutId,
        val amount: TalerAmount,
        val debitFee: TalerAmount?,
        val subject: String?,
        val creditor: IbanPayto?
    ): TxInfo

    fun parse(): TxNotification {
        return when (this) {
            is TxInfo.CreditReversal -> {
                if (id !is OutgoingId || id.endToEndId == null) 
                    throw IncompleteTx("missing unique ID for Credit reversal $id")
                OutgoingReversal(
                    endToEndId = id.endToEndId!!,
                    msgId = id.msgId,
                    reason = reason,
                    executionTime = bookDate
                )
            }
            is TxInfo.Credit -> {
                if (id.uetr == null && id.txId == null && id.acctSvcrRef == null)
                    throw IncompleteTx("missing unique ID for Credit $id")
                IncomingPayment(
                    amount = amount,
                    creditFee = creditFee,
                    id = id,
                    debtor = debtor,
                    executionTime = bookDate,
                    subject = subject,
                )
            }
            is TxInfo.Debit -> {
                when (id) {
                    is OutgoingId -> {
                        if (id.endToEndId == null && id.msgId == null && id.acctSvcrRef == null) {
                            throw IncompleteTx("missing unique ID for Debit $id")
                        } else {
                            OutgoingPayment(
                                id = OutgoingId(
                                    endToEndId = id.endToEndId,
                                    acctSvcrRef = id.acctSvcrRef,
                                    msgId = id.msgId,
                                ),
                                amount = amount,
                                debitFee = debitFee,
                                executionTime = bookDate,
                                creditor = creditor,
                                subject = subject
                            )
                        }
                    }
                    is BatchId -> {
                        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,
        )
    }
}