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

import tech.libeufin.common.IbanPayto
import tech.libeufin.common.TalerAmount
import tech.libeufin.nexus.ebics.Dialect
import java.io.InputStream
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter

/**
 * Gets the amount number, also converting it from the
 * Taler-friendly 8 fractional digits to the more bank
 * friendly with 2.
 *
 * @param amount the Taler amount where to extract the number
 * @return [String] of the amount number without the currency.
 */
fun getAmountNoCurrency(amount: TalerAmount): String {
    if (amount.frac == 0) {
        return amount.value.toString()
    } else {
        val fractionFormat = amount.frac.toString().padStart(8, '0').dropLastWhile { it == '0' }
        if (fractionFormat.length > 2) throw Exception("Sub-cent amounts not supported")
        return "${amount.value}.${fractionFormat}"
    }
}

/** Create a pain.001 XML document valid for [dialect] */
fun createPain001(
    requestUid: String,
    initiationTimestamp: Instant,
    debitAccount: IbanAccountMetadata,
    amount: TalerAmount,
    wireTransferSubject: String,
    creditAccount: IbanAccountMetadata,
    dialect: Dialect
): ByteArray {
    val version = "09"
    val zonedTimestamp = ZonedDateTime.ofInstant(initiationTimestamp, ZoneId.of("UTC"))
    val amountWithoutCurrency: String = getAmountNoCurrency(amount)
    return XmlBuilder.toBytes("Document") {
        attr("xmlns", "urn:iso:std:iso:20022:tech:xsd:pain.001.001.$version")
        attr("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance")
        attr("xsi:schemaLocation", "urn:iso:std:iso:20022:tech:xsd:pain.001.001.$version pain.001.001.$version.xsd")
        el("CstmrCdtTrfInitn") {
            el("GrpHdr") {
                // Use for idempotency as banks will refuse to process EBICS request with the same MsgId for a pre- agreed period
                // This is especially important for bounces
                el("MsgId", requestUid) 
                el("CreDtTm", DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(zonedTimestamp))
                el("NbOfTxs", "1")
                el("CtrlSum", amountWithoutCurrency)
                el("InitgPty/Nm", debitAccount.name)
            }
            el("PmtInf") {
                el("PmtInfId", "NOTPROVIDED")
                el("PmtMtd", "TRF")
                el("BtchBookg", "false")
                el("NbOfTxs", "1")
                el("CtrlSum", amountWithoutCurrency)
                el("PmtTpInf/SvcLvl/Cd", 
                    when (dialect) {
                        Dialect.postfinance -> "SDVA"
                        Dialect.gls -> "SEPA"
                    }
                )
                el("ReqdExctnDt/Dt", DateTimeFormatter.ISO_DATE.format(zonedTimestamp))
                el("Dbtr/Nm", debitAccount.name)
                el("DbtrAcct/Id/IBAN", debitAccount.iban)
                el("DbtrAgt/FinInstnId") {
                    if (debitAccount.bic != null) {
                        el("BICFI", debitAccount.bic)
                    } else {
                        el("Othr/Id", "NOTPROVIDED")
                    }
                }
                el("ChrgBr", "SLEV")
                el("CdtTrfTxInf") {
                    el("PmtId") {
                        el("InstrId", "NOTPROVIDED")
                        // Used to identify this transaction in CAMT files when MsgId is not present
                        el("EndToEndId", requestUid)
                    }
                    el("Amt/InstdAmt") {
                        attr("Ccy", amount.currency)
                        text(amountWithoutCurrency)
                    }
                    if (creditAccount.bic != null) el("CdtrAgt/FinInstnId/BICFI", creditAccount.bic)
                    el("Cdtr") {
                        el("Nm", creditAccount.name)
                        // Addr might become a requirement in the future
                        /*el("PstlAdr") {
                            el("TwnNm", "Bochum")
                            el("Ctry", "DE")
                        }*/
                    }
                    el("CdtrAcct/Id/IBAN", creditAccount.iban)
                    el("RmtInf/Ustrd", wireTransferSubject)
                }
            }
        }
    }
}

data class CustomerAck(
    val actionType: HacAction,
    val orderId: String?,
    val code: ExternalStatusReasonCode?,
    val info: String,
    val timestamp: Instant
) {
    fun msg(): String = buildString {
        append("$actionType")
        if (code != null) append(" ${code.isoCode}")
        append(" - '${actionType.description}'")
        if (code != null) append(" '${code.description}'")
        if (info != "") append(" - '$info'")
    }

    override fun toString(): String = buildString {
        append(timestamp.fmtDateTime())
        if (orderId != null) append(" $orderId")
        append(" ${msg()}")
    }
}

/** Parse HAC pain.002 XML file */
fun parseCustomerAck(xml: InputStream): List<CustomerAck> {
    return XmlDestructor.fromStream(xml, "Document") {
        one("CstmrPmtStsRpt").map("OrgnlPmtInfAndSts") {
            val actionType = one("OrgnlPmtInfId").enum<HacAction>()
            one("StsRsnInf") {
                var timestamp: Instant? = null
                var orderId: String? = null
                one("Orgtr").one("Id").one("OrgId").each("Othr") {
                    val value = one("Id")
                    val key = one("SchmeNm").one("Prtry").text()
                    when (key) {
                        "TimeStamp" -> {
                            timestamp = value.dateTime().toInstant(ZoneOffset.UTC)
                        }
                        "OrderID" -> orderId = value.text()
                        // TODO extract ids ?
                    }
                }
                val code = opt("Rsn")?.one("Cd")?.enum<ExternalStatusReasonCode>()
                val info = map("AddtlInf") { text() }.joinToString("")
                CustomerAck(actionType, orderId, code, info, timestamp!!)
            }
        }
    }
}

data class PaymentStatus(
    val msgId: String,
    val paymentId: String?,
    val txId: String?,
    val paymentCode: ExternalPaymentGroupStatusCode,
    val txCode: ExternalPaymentTransactionStatusCode?,
    val reasons: List<Reason>
) { 
    fun id(): String {
        var str = msgId
        if (paymentId != null) str += ".$paymentId"
        if (txId != null) str += ".$txId"
        return str
    }

    fun code(): String = txCode?.isoCode ?: paymentCode.isoCode

    fun description(): String = txCode?.description ?: paymentCode.description

    fun msg(): String {
        return if (reasons.isEmpty()) {
            "${code()} '${description()}'"
        } else if (reasons.size == 1) {
            "${code()} ${reasons[0].code.isoCode} - '${description()}' '${reasons[0].code.description}'"
        } else {
            buildString {
                append("${code()} '${description()}' - ")
                for (reason in reasons) {
                    append("${reason.code.isoCode} '${reason.code.description}' ")
                }
            }
        }
    }

    override fun toString(): String = "${id()} ${msg()}"
}

data class Reason (
    val code: ExternalStatusReasonCode,
    val information: String
)

/** Parse pain.002 XML file */
fun parseCustomerPaymentStatusReport(xml: InputStream): PaymentStatus {
    fun XmlDestructor.reasons(): List<Reason> {
        return map("StsRsnInf") {
            val code = one("Rsn").one("Cd").enum<ExternalStatusReasonCode>()
            // TODO parse information
            Reason(code, "")
        }
    }
    return XmlDestructor.fromStream(xml, "Document") {
        // TODO handle batch status
        one("CstmrPmtStsRpt") {
            val (msgId, msgCode, msgReasons) = one("OrgnlGrpInfAndSts") {
                val id = one("OrgnlMsgId").text()
                val code = opt("GrpSts")?.enum<ExternalPaymentGroupStatusCode>()
                val reasons = reasons()
                Triple(id, code, reasons)
            }
            opt("OrgnlPmtInfAndSts") {
                val payId = one("OrgnlPmtInfId").text()
                val payCode = one("PmtInfSts").enum<ExternalPaymentGroupStatusCode>()
                val payReasons = reasons()
                opt("TxInfAndSts") {
                    val txId = one("OrgnlInstrId").text()
                    val txCode = one("TxSts").enum<ExternalPaymentTransactionStatusCode>()
                    val txReasons = reasons()
                    PaymentStatus(msgId, payId, txId, payCode, txCode, txReasons)
                } ?: PaymentStatus(msgId, payId, null, payCode, null, payReasons)
            } ?: PaymentStatus(msgId, null, null, msgCode!!, null, msgReasons)
        }
    }
}

sealed interface TxNotification {
    val executionTime: Instant
    data class Reversal(
        val msgId: String,
        val reason: String?,
        override val executionTime: Instant
    ): TxNotification
}

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

/** ISO20022 outgoing payment */
data class OutgoingPayment(
    /** ISO20022 MessageIdentification & EndToEndId */
    val messageId: String,
    val amount: TalerAmount,
    val wireTransferSubject: String? = null, // Some implementation does not provide this for recovery
    override val executionTime: Instant,
    val creditPaytoUri: String? = null // Some implementation does not provide this for recovery
): TxNotification {
    override fun toString(): String {
        return "OUT ${executionTime.fmtDate()} $amount '$messageId' creditor=$creditPaytoUri subject=\"$wireTransferSubject\""
    }
}

private fun XmlDestructor.payto(prefix: String): String? {
    val iban = opt("${prefix}Acct")?.one("Id")?.one("IBAN")?.text()
    return if (iban != null) {
        val name = opt(prefix) { opt("Nm")?.text() ?: opt("Pty")?.one("Nm")?.text() }
        // Parse bic ?
        IbanPayto.build(iban, null, name)
    } else {
        null
    }
}

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

private enum class Kind {
    CRDT,
    DBIT
}

/** Parse camt.054 or camt.053 file */
fun parseTx(
    notifXml: InputStream,
    acceptedCurrency: String,
    dialect: Dialect
): List<TxNotification> {
    /*
        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.
    */

    /** Check if a transaction status is BOOK */
    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 */
    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 original transaction ID generated by libeufin-nexus */
    fun XmlDestructor.nexusId(): String? =
        // We check at the EndToEndId or MsgId level for retrocompatibility
        opt("Refs") { opt("EndToEndId")?.textProvided() ?: opt("MsgId")?.text() }
    
    /** Parse and format transaction return reasons */
    fun XmlDestructor.returnReason(): String = one("RtrInf") {
        val code = one("Rsn").one("Cd").enum<ExternalReturnReasonCode>()
        val info = opt("AddtlInf")?.text()
        buildString {
            append("${code.isoCode} '${code.description}'")
            if (info != null) {
                append(" - '$info'")
            }
        }
    }
    
    fun XmlDestructor.amount(acceptedCurrency: String) = one("Amt") {
        val currency = attr("Ccy")
        /** FIXME: test by sending non-CHF to PoFi and see which currency gets here. */
        if (currency != acceptedCurrency) throw Exception("Currency $currency not supported")
        TalerAmount("$currency:${text()}")
    }

    /** Check if transaction code is reversal */
    fun XmlDestructor.isReversalCode(): Boolean {
        return one("BkTxCd").one("Domn") {
            // TODO automate enum generation for all those code
            val domainCode = one("Cd").text()
            one("Fmly") {
                val familyCode = one("Cd").text()
                val subFamilyCode = one("SubFmlyCd").text()
                
                subFamilyCode == "RRTN" || subFamilyCode == "RPCR"
            }
        }
    }

    val txsInfo = mutableListOf<TxInfo>()

    XmlDestructor.fromStream(notifXml, "Document") { when (dialect) {
        Dialect.gls -> {
            /** Common parsing logic for camt.052 and camt.053 */
            fun XmlDestructor.parseGlsInner() {
                opt("Acct") {
                    // Sanity check on currency and IBAN ?
                }
                each("Ntry") {
                    if (!isBooked()) return@each
                    val entryRef = opt("AcctSvcrRef")?.text()
                    val bookDate = executionDate()
                    val kind = one("CdtDbtInd").enum<Kind>()
                    val amount = amount(acceptedCurrency)
                    one("NtryDtls").one("TxDtls") { // TODO handle batches
                        val txRef = opt("Refs")?.opt("AcctSvcrRef")?.text()
                        val reversal = isReversalCode()
                        val nexusId = nexusId()
                        if (reversal) {
                            if (kind == Kind.CRDT) {
                                val reason = returnReason()
                                txsInfo.add(TxInfo.CreditReversal(
                                    ref = nexusId ?: txRef ?: entryRef,
                                    bookDate = bookDate,
                                    nexusId = nexusId,
                                    reason = reason
                                ))
                            }
                        } else {
                            val subject = opt("RmtInf")?.map("Ustrd") { text() }?.joinToString("")
                            when (kind) {
                                Kind.CRDT -> {
                                    val bankId = one("Refs").opt("TxId")?.text()
                                    val debtorPayto = opt("RltdPties") { payto("Dbtr") }
                                    txsInfo.add(TxInfo.Credit(
                                        ref = bankId ?: txRef ?: entryRef,
                                        bookDate = bookDate,
                                        bankId = bankId,
                                        amount = amount,
                                        subject = subject,
                                        debtorPayto = debtorPayto
                                    ))
                                }
                                Kind.DBIT -> {
                                    val creditorPayto = opt("RltdPties") { payto("Cdtr") }
                                    txsInfo.add(TxInfo.Debit(
                                        ref = nexusId ?: txRef ?: entryRef,
                                        bookDate = bookDate,
                                        nexusId = nexusId,
                                        amount = amount,
                                        subject = subject,
                                        creditorPayto = creditorPayto
                                    ))
                                }
                            }
                        }
                    }
                }
            }
            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
                opt("Acct") {
                    // Sanity check on currency and IBAN ?
                }
                each("Ntry") {
                    if (!isBooked()) return@each
                    if (isReversalCode()) return@each
                    val entryRef = opt("AcctSvcrRef")?.text()
                    val bookDate = executionDate()
                    val kind = one("CdtDbtInd").enum<Kind>()
                    val amount = amount(acceptedCurrency)
                    one("NtryDtls").one("TxDtls") {
                        val txRef = one("Refs").opt("AcctSvcrRef")?.text()
                        val subject = opt("RmtInf")?.map("Ustrd") { text() }?.joinToString("")
                        if (kind == Kind.CRDT) {
                            val bankId = one("Refs").opt("TxId")?.text()
                            val debtorPayto = opt("RltdPties") { 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
                            ))
                        }
                    }
                }
            }
        }
        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.
                */
                opt("Acct") {
                    // Sanity check on currency and IBAN ?
                }
                each("Ntry") {
                    if (!isBooked()) return@each
                    // Non reversal transaction are handled in camt.054
                    if (!isReversalCode()) return@each

                    val entryRef = opt("AcctSvcrRef")?.text()
                    val bookDate = executionDate()
                    one("NtryDtls").one("TxDtls") {
                        val kind = one("CdtDbtInd").enum<Kind>()
                        if (kind == Kind.CRDT) {
                            val txRef = opt("Refs")?.opt("AcctSvcrRef")?.text()
                            val nexusId = nexusId()
                            val reason = returnReason()
                            txsInfo.add(TxInfo.CreditReversal(
                                ref = nexusId ?: txRef ?: entryRef,
                                bookDate = bookDate,
                                nexusId = nexusId,
                                reason = reason
                            ))
                        }
                    }
                }
            }
            opt("BkToCstmrDbtCdtNtfctn")?.each("Ntfctn") { // Camt.054
                // Instant transactions appear here a moment after being booked
                opt("Acct") {
                    // Sanity check on currency and IBAN ?
                }
                each("Ntry") {
                    if (!isBooked()) return@each
                    // Reversal are handled from camt.053
                    if (isReversalCode()) return@each

                    val entryRef = opt("AcctSvcrRef")?.text()
                    val bookDate = executionDate()
                    one("NtryDtls").each("TxDtls") {
                        val kind = one("CdtDbtInd").enum<Kind>()
                        val amount = amount(acceptedCurrency)
                        val txRef = one("Refs").opt("AcctSvcrRef")?.text()
                        val subject = opt("RmtInf")?.map("Ustrd") { text() }?.joinToString("")
                        when (kind) {
                            Kind.CRDT -> {
                                val bankId = one("Refs").opt("UETR")?.text()
                                val debtorPayto = opt("RltdPties") { payto("Dbtr") }
                                txsInfo.add(TxInfo.Credit(
                                    ref = bankId ?: txRef ?: entryRef,
                                    bookDate = bookDate,
                                    bankId = bankId,
                                    amount = amount,
                                    subject = subject,
                                    debtorPayto = debtorPayto
                                ))
                            }
                            Kind.DBIT -> {
                                val nexusId = nexusId()
                                val creditorPayto = opt("RltdPties") { payto("Cdtr") }
                                txsInfo.add(TxInfo.Debit(
                                    ref = nexusId ?: txRef ?: entryRef,
                                    bookDate = bookDate,
                                    nexusId = nexusId,
                                    amount = amount,
                                    subject = subject,
                                    creditorPayto = creditorPayto
                                ))
                            }
                        }
                    }
                }
            }
        }
    }}
    
    return txsInfo.mapNotNull {
        try {
            parseTxLogic(it)
        } catch (e: TxErr) {
            // TODO: add more info in doc or in log message?
            logger.warn("skip incomplete tx: ${e.msg}")
            null
        }    
    }
}

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

private fun parseTxLogic(info: TxInfo): TxNotification {
    return when (info) {
        is TxInfo.CreditReversal -> {
            if (info.nexusId == null) 
                throw TxErr("missing nexus ID for Credit reversal ${info.ref}")
            TxNotification.Reversal(
                msgId = info.nexusId,
                reason = info.reason,
                executionTime = info.bookDate
            )
        }
        is TxInfo.Credit -> {
            /*if (info.bankId == null) TODO use the bank ID again when Atruvia's implementation is fixed
                throw TxErr("missing bank ID for Credit ${info.ref}")*/
            if (info.subject == null)
                throw TxErr("missing subject for Credit ${info.ref}")
            if (info.debtorPayto == null)
                throw TxErr("missing debtor info for Credit ${info.ref}")
            IncomingPayment(
                amount = info.amount,
                bankId = info.bankId,
                debitPaytoUri = info.debtorPayto,
                executionTime = info.bookDate,
                wireTransferSubject = info.subject
            )
        }
        is TxInfo.Debit -> {
            if (info.nexusId == null)
                throw TxErr("missing nexus ID for Debit ${info.ref}")
            OutgoingPayment(
                amount = info.amount,
                messageId = info.nexusId,
                executionTime = info.bookDate,
                creditPaytoUri = info.creditorPayto,
                wireTransferSubject = info.subject
            )
        }
    }
}