/*
 * 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.nexus.cli

import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.Context
import com.github.ajalt.clikt.core.ProgramResult
import com.github.ajalt.clikt.parameters.arguments.*
import com.github.ajalt.clikt.parameters.groups.provideDelegate
import com.github.ajalt.clikt.parameters.options.*
import com.github.ajalt.clikt.parameters.types.enum
import kotlin.math.min
import kotlinx.coroutines.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.Contextual
import tech.libeufin.common.*
import tech.libeufin.nexus.*
import tech.libeufin.nexus.db.*
import tech.libeufin.nexus.db.PaymentDAO.*
import tech.libeufin.nexus.iso20022.*
import tech.libeufin.ebics.*
import java.io.IOException
import java.io.InputStream
import java.time.*
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.time.temporal.*

/** Register an outgoing [payment] into [db] */
suspend fun registerOutgoingPayment(
    db: Database,
    payment: OutgoingPayment
): OutgoingRegistrationResult {
    val metadata: Triple<ShortHashCode, BaseURL, String?>? = payment.subject?.let { 
        runCatching { parseOutgoingSubject(it) }.getOrNull()
    }
    val result = db.payment.registerOutgoing(payment, metadata?.first, metadata?.second, metadata?.third)
    if (result.new) {
        if (result.initiated)
            logger.info("$payment")
        else 
            logger.warn("$payment recovered")
    } else {
        logger.debug("{} already seen", payment)
    }
    return result
}

/** Register an outgoing [payment] into [db] */
suspend fun registerOutgoingBatch(
    db: Database,
    batch: OutgoingBatch
) {
    logger.info("BATCH ${batch.executionTime.fmtDate()} ${batch.msgId}")
    for (it in db.initiated.unsettledTxInBatch(batch.msgId, batch.executionTime)) {
        registerOutgoingPayment(db, it)
    }
}

/** 
 * Register an incoming [payment] into [db]
 * Stores the payment into valid talerable ones or bounces it
 */
suspend fun registerIncomingPayment(
    db: Database,
    cfg: NexusIngestConfig,
    payment: IncomingPayment,
) {
    fun logRes(res: InResult, kind: String = "", suffix: String = "") {
        val fmt = buildString {
            append(payment)
            if (kind != "") {
                append(" ")
                append(kind)
            }
            if (res.new) {
                if (res.bounceId != null) {
                    append(" bounced in ${res.bounceId}")
                }
            } else {
                if (res.completed) {
                    append(" completed")
                    if (res.bounceId != null) {
                        append(" bounced in ${res.bounceId}")
                    }
                } else {
                    if (res.bounceId != null) {
                        append(" already bounced in ${res.bounceId}")
                    } 
                }
            }
            if (suffix != "") {
                append(" ")
                append(suffix)
            }
        }
        if (res.completed || res.new) {
            logger.info(fmt)
        } else {
            logger.debug(fmt)
        }
    }
    suspend fun bounce(cause: String) {
        if (payment.id == null) {
            logger.debug("{} ignored: missing bank ID", payment)
            return
        }
        when (cfg.accountType) {
            AccountType.exchange -> {
                if (payment.executionTime < cfg.ignoreBouncesBefore) {
                    val res = db.payment.registerIncoming(payment)
                    logRes(res, suffix = "ignored bounce: $cause")
                } else {
                    var bounceAmount = payment.amount
                    if (payment.creditFee != null && cfg.bounceDeduceFee) {
                        if (payment.creditFee > bounceAmount) {
                            val res = db.payment.registerIncoming(payment)
                            logRes(res, suffix = "skip bounce (transfer fee higher than amount): $cause")
                            return
                        }
                        bounceAmount -= payment.creditFee
                    }
                    if (cfg.bounceFee > bounceAmount) {
                        val res = db.payment.registerIncoming(payment)
                        logRes(res, suffix = "skip bounce (bounce fee higher than amount): $cause")
                        return
                    }
                    bounceAmount -= cfg.bounceFee
                    val res = db.payment.registerMalformedIncoming(
                        payment,
                        bounceAmount, 
                        randEbicsId(),
                        Instant.now(),
                        cause
                    )
                    when (res) {
                        IncomingBounceRegistrationResult.Talerable ->
                            logger.warn("{} tried to bounce a talerable transaction", payment)
                        is IncomingBounceRegistrationResult.Success -> 
                            logRes(res, suffix=": $cause")
                    }
                }
            }
            AccountType.normal -> {
                val res = db.payment.registerIncoming(payment)
                logRes(res)
            }
        }
    }
    // Check we have enough info to handle this transaction
    if (payment.debtor == null || payment.debtor.receiverName == null) {
        val res = db.payment.registerIncoming(payment)
        logRes(res, kind = "incomplete")
        return
    }
    if (cfg.restrictionPaytoRegex != null) {
        if (!cfg.restrictionPaytoRegex.matches(payment.debtor.toString())) {
            bounce("restricted account")
            return
        }
    }
    // Else we try to parse the incoming subject
    if (payment.subject != null && subjectIsQrBill(payment.subject)) {
        when (val res = db.payment.registerQrBillIncoming(payment, payment.subject)) {
            IncomingRegistrationResult.ReservePubReuse -> bounce("reverse pub reuse")
            IncomingRegistrationResult.MappingReuse -> bounce("mapping reuse")
            IncomingRegistrationResult.UnknownMapping -> bounce("unknown mapping")
            is IncomingRegistrationResult.Success -> logRes(res)
        }
    } else {
        runCatching { parseIncomingSubject(payment.subject) }.fold(
            onSuccess = { metadata -> 
                if (metadata is IncomingSubject.AdminBalanceAdjust) {
                    val res = db.payment.registerIncoming(payment)
                    logRes(res, kind = "admin balance adjust")
                } else {
                    when (val res = db.payment.registerTalerableIncoming(payment, metadata)) {
                        IncomingRegistrationResult.ReservePubReuse -> bounce("reverse pub reuse")
                        IncomingRegistrationResult.MappingReuse -> bounce("mapping reuse")
                        IncomingRegistrationResult.UnknownMapping -> bounce("unknown mapping")
                        is IncomingRegistrationResult.Success -> logRes(res)
                    }
                }
            },
            onFailure = { e -> bounce(e.fmt())}
        )
    }
}

/** Register a [tx] notification into [db] */
suspend fun registerTransaction(
    db: Database,
    cfg: NexusIngestConfig,
    tx: TxNotification,
) {
    if (tx.executionTime < cfg.ignoreTransactionsBefore) {
        logger.debug("IGNORE {}", tx)
    } else {
        when (tx) {
            is IncomingPayment -> registerIncomingPayment(db, cfg, tx)
            is OutgoingPayment -> registerOutgoingPayment(db, tx)
            is OutgoingBatch -> registerOutgoingBatch(db, tx)
            is OutgoingReversal -> {
                logger.error("{}", tx)
                db.initiated.txStatusUpdate(tx.endToEndId, tx.msgId, StatusUpdate.permanent_failure, "Payment bounced: ${tx.reason}")
            }
        }
    }
}

/** Register a single EBICS [xml] txs [document] into [db] */
suspend fun registerTxs(
    db: Database,
    cfg: NexusConfig,
    xml: InputStream
): Int {
    var nbTx: Int = 0
    parseTx(xml).forEach { accountTx ->
        if (accountTx.iban == cfg.ebics.account.iban.toString()) {
            require(accountTx.currency == null || accountTx.currency == cfg.currency) { "Expected transactions of currency ${cfg.currency} got ${accountTx.currency}" } 
            accountTx.txs.forEach { tx ->
                when (tx) {
                    is IncomingPayment ->
                        require(tx.amount.currency == cfg.currency) { "Expected transactions of currency ${cfg.currency} got ${tx.amount.currency}" } 
                    is OutgoingPayment ->
                        require(tx.amount.currency == cfg.currency) { "Expected transactions of currency ${cfg.currency} got ${tx.amount.currency}" } 
                    is OutgoingBatch, is OutgoingReversal -> {}
                }
                registerTransaction(db, cfg.ingest, tx)
                nbTx += 1
            }
        } else {
            logger.debug("Skip transaction for unknown account ${accountTx.iban}")
        }
    }
    return nbTx
}

/** Register a single EBICS [xml] [document] into [db] */
suspend fun registerFile(
    db: Database,
    cfg: NexusConfig,
    xml: InputStream,
    doc: OrderDoc
) {
    when (doc) {
        OrderDoc.report, OrderDoc.statement, OrderDoc.notification -> {
            try {
                registerTxs(db, cfg, xml)
            } catch (e: Exception) {
                throw Exception("Ingesting transactions files failed", e)
            }
        }
        OrderDoc.acknowledgement -> {
            val acks = parseCustomerAck(xml)
            for (ack in acks) {
                when (ack.actionType) {
                    HacAction.ORDER_HAC_FINAL_POS -> {
                        logger.debug("{}", ack)
                        db.initiated.orderSuccess(ack.orderId!!)?.let { messageId ->
                            logger.info("Batch $messageId order ${ack.orderId} accepted at ${ack.timestamp.fmtDateTime()}")
                        }
                    }
                    HacAction.ORDER_HAC_FINAL_NEG -> {
                        logger.debug("{}", ack)
                        db.initiated.orderFailure(ack.orderId!!)?.let { (messageId, msg) ->
                            logger.error("Batch $messageId order ${ack.orderId} refused at ${ack.timestamp.fmtDateTime()}${if (msg != null) ": $msg" else ""}")
                        }
                    }
                    else -> {
                        logger.debug("{}", ack)
                        if (ack.orderId != null) {
                            db.initiated.orderStep(ack.orderId, ack.msg())
                        }
                    }
                }
            }
        }
        OrderDoc.status -> {
            val msgStatus = parseCustomerPaymentStatusReport(xml)
            logger.debug("{}", msgStatus)
            if (msgStatus.code != null) {
                val msg = msgStatus.msg()
                val state = when (msgStatus.code) {
                    ExternalPaymentGroupStatusCode.ACSC -> StatusUpdate.success
                    ExternalPaymentGroupStatusCode.RJCT -> {
                        logger.error("Batch ${msgStatus.id} failed: $msg")
                        StatusUpdate.permanent_failure
                    }
                    else -> StatusUpdate.pending
                }
                db.initiated.batchStatusUpdate(msgStatus.id, state, msg)
            }
            for (pmtStatus in msgStatus.payments) {
                if (pmtStatus.id != "NOTPROVIDED") {
                    logger.warn("Unexpected payment status for ${msgStatus.id}.${pmtStatus.id}")
                } else if (pmtStatus.code != null) {
                    val msg = pmtStatus.msg()
                    val state = when (pmtStatus.code) {
                        ExternalPaymentGroupStatusCode.ACSC -> StatusUpdate.success
                        ExternalPaymentGroupStatusCode.RJCT -> {
                            logger.error("Batch ${msgStatus.id} failed: $msg")
                            StatusUpdate.permanent_failure
                        }
                        else -> StatusUpdate.pending
                    }
                    db.initiated.batchStatusUpdate(msgStatus.id, state, msg)
                }
                for (txStatus in pmtStatus.transactions) {
                    val msg = txStatus.msg()
                    val state = when (txStatus.code) {
                        ExternalPaymentTransactionStatusCode.RJCT,
                        ExternalPaymentTransactionStatusCode.BLCK -> {
                            logger.error("Transaction ${txStatus.endToEndId} failed: $msg")
                            StatusUpdate.permanent_failure
                        }
                        else -> StatusUpdate.pending
                    }
                    db.initiated.txStatusUpdate(txStatus.endToEndId, null, state, msg)
                }
            }
        }
    }
}

/** Register an EBICS [payload] of [doc] into [db] */
private suspend fun registerPayload(
    db: Database,
    cfg: NexusConfig,
    payload: InputStream,
    doc: OrderDoc
) { 
    // Unzip payload if necessary
    when (doc) {
        OrderDoc.status,
        OrderDoc.report,
        OrderDoc.statement, 
        OrderDoc.notification -> {
            try {
                payload.unzipEach { fileName, xml ->
                    logger.trace("parse $fileName")
                    registerFile(db, cfg, xml, doc)
                }
            } catch (e: IOException) {
                throw Exception("Could not open any ZIP archive", e)
            }
        }
        OrderDoc.acknowledgement -> registerFile(db, cfg, payload, doc)
    }
}

/** 
 * Fetch and register banking records from [orders] using EBICS [client] starting from [pinnedStart]
 * 
 * If [pinnedStart] is null fetch new records.
 */
private suspend fun fetchEbicsDocuments(
    client: EbicsClient,
    db: Database,
    cfg: NexusConfig,
    orders: Collection<EbicsOrder>,
    pinnedStart: Instant?,
    peek: Boolean
): Boolean {
    val lastExecutionTime: Instant? = pinnedStart
    var success = true
    for ((doc, orders) in orders.groupBy { it.doc() }) {
        if (doc == null) {
            logger.debug("Skip unsupported orders {}", orders)
        } else {
            if (lastExecutionTime == null) {
                logger.info("Fetching new '${doc.fullDescription()}'")
            } else {
                logger.info("Fetching '${doc.fullDescription()}' from timestamp: $lastExecutionTime")
            }
            for (order in orders) {
                try {
                    client.download(
                        order,
                        lastExecutionTime,
                        null,
                        peek
                    ) { payload ->
                        registerPayload(db, cfg, payload, doc)
                    }
                } catch (e: EbicsError.Code) {
                    when (e.bankCode) {
                        EbicsReturnCode.EBICS_NO_DOWNLOAD_DATA_AVAILABLE -> continue
                        EbicsReturnCode.EBICS_AUTHORISATION_ORDER_IDENTIFIER_FAILED -> {
                            e.fmtLog(logger)
                            success = false
                            continue
                        }
                        else -> throw e
                    }
                }
            }
        }
    }
    return success
}

@Serializable
data class Checkpoint(
    @Contextual
    val last_successfull: Instant? = null,
    @Contextual
    val last_trial: Instant? = null
)

class EbicsFetch: EbicsCmd() {
    override fun help(context: Context) = "Downloads and parse EBICS files from the bank and register them into the database"

    private val documents: Set<OrderDoc> by argument(
        help = "Which documents should be fetched? If none are specified, all supported documents will be fetched",
        helpTags = OrderDoc.entries.associate { Pair(it.name, it.shortDescription()) },
    ).enum<OrderDoc>().multiple().unique()
    private val pinnedStart by option(
        help = "Only supported in --transient mode, this option lets specify the earliest timestamp of the downloaded documents",
        metavar = "YYYY-MM-DD"
    ).convert { dateToInstant(it) }
    private val peek by option("--peek",
        help = "Only supported in --transient mode, do not consume fetched documents"
    ).flag()
    private val transientCheckpoint by option("--checkpoint",
        help = "Only supported in --transient mode, run a checkpoint"
    ).flag()

    override fun run() = cliCmd(logger) {
        nexusConfig(config).withDb { db, cfg ->
            val (clientKeys, bankKeys) = expectFullKeys(cfg.ebics)
            val client = EbicsClient(
                cfg.ebics.host,
                httpClient(),
                db.ebics,
                EbicsLogger(ebicsLog),
                clientKeys,
                bankKeys
            )
            val docs = if (documents.isEmpty()) OrderDoc.entries else documents.toList()

            // EBICS order than should be fetched
            val selectedOrder = docs.flatMap { cfg.ebics.dialect.downloadDoc(it) }

            // Try to obtain real-time notification channel if not transient
            val wssNotification = if (transient) {
                logger.info("Transient mode: fetching once and returning")
                null
            } else {
                val tmp = listenForNotification(client)
                logger.info("Running with a frequency of ${cfg.fetch.frequencyRaw}")
                tmp
            }

            var lastFetch = Instant.EPOCH
            
            while (true) {
                val checkpoint = db.kv.get<TaskStatus>(CHECKPOINT_KEY) ?: TaskStatus()
                var nextFetch = lastFetch + cfg.fetch.frequency
                var nextCheckpoint = run {
                    // We never ran, we must checkpoint now
                    if (checkpoint.last_trial == null) {
                        Instant.EPOCH
                    } else {
                        // We run today at checkpointTime
                        val checkpointDate = OffsetDateTime.now().with(cfg.fetch.checkpointTime)
                        val checkpointToday = checkpointDate.toInstant()
                        // If we already ran today we ruAn tomorrow
                        if (checkpoint.last_trial > checkpointToday) {
                            checkpointDate.plusDays(1).toInstant()
                        } else {
                            checkpointToday
                        }
                    }
                }

                val now = Instant.now()
                var success: Boolean = true
                if (
                    // Run transient checkpoint at request
                    (transient && transientCheckpoint) ||
                    // Or run recurrent checkpoint
                    (!transient && now > nextCheckpoint)
                ) {
                    logger.info("Running checkpoint")
                    
                    val since = if (transient && pinnedStart != null && (checkpoint.last_successfull == null || pinnedStart!!.isBefore(checkpoint.last_successfull))) {
                        pinnedStart
                    } else {
                        checkpoint.last_successfull
                    }
                    success = try {
                        /// We fetch HKD to only fetch supported EBICS orders and get the document versions
                        val orders = client.download(EbicsOrder.V3.HKD) { stream ->
                            val hkd = EbicsAdministrative.parseHKD(stream)
                            val supportedOrder = hkd.partner.orders.map { it.order }
                            logger.debug {
                                val fmt = supportedOrder.map(EbicsOrder::description).joinToString(" ")
                                "HKD: ${fmt}"
                            }
                            selectedOrder select supportedOrder
                        }
                        fetchEbicsDocuments(client, db, cfg, orders, since, transient && peek)
                    } catch (e: Exception) {
                        e.fmtLog(logger)
                        false
                    }
                    db.kv.updateTaskStatus(CHECKPOINT_KEY, now, success)
                    lastFetch = now
                } else if (transient || now > nextFetch) {
                    if (!transient) logger.info("Running at frequency")
                    success = try {
                        /// We fetch HAA to only fetch pending & supported EBICS orders and get the document versions
                        val orders = client.download(EbicsOrder.V3.HAA) { stream ->
                            val haa = EbicsAdministrative.parseHAA(stream)
                            logger.debug {
                                val orders = haa.orders.map(EbicsOrder::description).joinToString(" ")
                                "HAA: ${orders}"
                            }
                            selectedOrder select haa.orders
                        }
                        fetchEbicsDocuments(client, db, cfg, orders, if (transient) pinnedStart else null, transient && peek)
                    } catch (e: Exception) {
                        e.fmtLog(logger)
                        false
                    }
                    lastFetch = now
                }
                db.kv.updateTaskStatus(SUBMIT_TASK_KEY, now, success)

                if (transient) throw ProgramResult(if (!success) 1 else 0)

                val delay = min(ChronoUnit.MILLIS.between(now, nextFetch), ChronoUnit.MILLIS.between(now, nextCheckpoint))
                if (wssNotification == null) {
                    delay(delay)
                } else {
                    val notifications = withTimeoutOrNull(delay) {
                        wssNotification.receive()
                    }
                    if (notifications != null) {
                        // Only fetch requested and supported orders
                        val orders = selectedOrder select notifications
                        if (orders.isNotEmpty()) {
                            logger.info("Running at real-time notifications reception")
                            fetchEbicsDocuments(client, db, cfg, notifications, null, false)
                        }
                    }
                }
            }
        }
    }
}
