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

import org.junit.Test
import tech.libeufin.common.*
import tech.libeufin.common.db.*
import tech.libeufin.nexus.AccountType
import tech.libeufin.nexus.NexusIngestConfig
import tech.libeufin.nexus.iso20022.*
import tech.libeufin.nexus.ebics.*
import tech.libeufin.nexus.cli.*
import tech.libeufin.nexus.db.*
import tech.libeufin.nexus.db.PaymentDAO.OutgoingRegistrationResult
import tech.libeufin.nexus.db.InitiatedDAO.PaymentInitiationResult
import java.time.Instant
import kotlin.test.*

suspend fun Database.checkInCount(nbIncoming: Int, nbBounce: Int, nbTalerable: Int) = serializable(
    """
        SELECT (SELECT count(*) FROM incoming_transactions) AS incoming,
               (SELECT count(*) FROM bounced_transactions) AS bounce,
               (SELECT count(*) FROM talerable_incoming_transactions) AS talerable;
    """
) {
    one {
        assertEquals(
            Triple(nbIncoming, nbBounce, nbTalerable),
            Triple(it.getInt("incoming"), it.getInt("bounce"), it.getInt("talerable"))
        )
    }
}

suspend fun Database.checkOutCount(nbIncoming: Int, nbTalerable: Int) = serializable(
    """
        SELECT (SELECT count(*) FROM outgoing_transactions) AS incoming,
               (SELECT count(*) FROM talerable_outgoing_transactions) AS talerable;
    """
) {
    one {
        assertEquals(
            Pair(nbIncoming, nbTalerable),
            Pair(it.getInt("incoming"), it.getInt("talerable"))
        )
    }
}

suspend fun Database.inTxExists(id: String): Boolean = serializable(
    "SELECT EXISTS(SELECT FROM incoming_transactions WHERE bank_id = ?)"
) {
    setString(1, id)
    one {
        it.getBoolean(1)
    }
}

class OutgoingPaymentsTest {
    @Test
    fun registerTx() = setup { db, _ -> 
        // Register initiated transaction
        for (subject in sequenceOf(
            "initiated by nexus",
            "${ShortHashCode.rand()} https://exchange.com"
        )) {
            val pay = genOutPay(subject)
            assertIs<PaymentInitiationResult.Success>(
                db.initiated.create(genInitPay(pay.endToEndId, subject))
            )
            val first = registerOutgoingPayment(db, pay)
            assertEquals(OutgoingRegistrationResult(id = first.id, initiated = true, new = true), first)
            assertEquals(
                OutgoingRegistrationResult(id = first.id, initiated = true, new = false),
                registerOutgoingPayment(db, pay)
            )
        }
        db.checkOutCount(nbIncoming = 2, nbTalerable = 1)

        // Register unknown
        for (subject in sequenceOf(
            "not initiated by nexus",
            "${ShortHashCode.rand()} https://exchange.com"
        )) {
            val pay = genOutPay(subject)
            val first = registerOutgoingPayment(db, pay)
            assertEquals(OutgoingRegistrationResult(id = first.id, initiated = false, new = true), first)
            assertEquals(
                OutgoingRegistrationResult(id = first.id, initiated = false, new = false),
                registerOutgoingPayment(db, pay)
            )
        }
        db.checkOutCount(nbIncoming = 4, nbTalerable = 2)

        // Register wtid reuse
        val wtid = ShortHashCode.rand()
        for (subject in sequenceOf(
            "$wtid https://exchange.com",
            "$wtid https://exchange.com"
        )) {
            val pay = genOutPay(subject)
            val first = registerOutgoingPayment(db, pay)
            assertEquals(OutgoingRegistrationResult(id = first.id, initiated = false, new = true), first)
            assertEquals(
                OutgoingRegistrationResult(id = first.id, initiated = false, new = false),
                db.payment.registerOutgoing(pay, null, null)
            )
        }
        db.checkOutCount(nbIncoming = 6, nbTalerable = 3)
    }

    @Test
    fun registerBatch() = setup { db, _ ->
        // Init batch
        val wtid = ShortHashCode.rand()
        for (subject in sequenceOf(
            "initiated by nexus",
            "${ShortHashCode.rand()} https://exchange.com",
            "$wtid https://exchange.com",
            "$wtid https://exchange.com"
        )) {
            assertIs<PaymentInitiationResult.Success>(
                db.initiated.create(genInitPay(randEbicsId(), subject=subject))
            )
        }
        db.initiated.batch(Instant.now(), "BATCH")

        // Register batch
        registerOutgoingBatch(db, OutgoingBatch("BATCH", Instant.now()));
        db.checkOutCount(nbIncoming = 4, nbTalerable = 2)
    }
}

class IncomingPaymentsTest {
    // Tests creating and bouncing incoming payments in one DB transaction
    @Test
    fun bounce() = setup { db, _ -> 
        // creating and bouncing one incoming transaction.
        val payment = genInPay("incoming and bounced")
        val id = randEbicsId()
        db.payment.registerMalformedIncoming(
            payment,
            TalerAmount("KUDOS:2.53"),
            id,
            Instant.now()
        ).run {
            assertTrue(new)
            assertEquals(id, bounceId)
        }
        db.payment.registerMalformedIncoming(
            payment,
            TalerAmount("KUDOS:2.53"),
            randEbicsId(),
            Instant.now()
        ).run {
            assertFalse(new)
            assertEquals(id, bounceId)
        }
        db.conn {
            // Checking one incoming got created
            val checkIncoming = it.prepareStatement("""
                SELECT (amount).val as amount_value, (amount).frac as amount_frac 
                FROM incoming_transactions WHERE incoming_transaction_id = 1
            """).executeQuery()
            assertTrue(checkIncoming.next())
            assertEquals(payment.amount.value, checkIncoming.getLong("amount_value"))
            assertEquals(payment.amount.frac, checkIncoming.getInt("amount_frac"))
            // Checking the bounced table got its row.
            val checkBounced = it.prepareStatement("""
                SELECT 1 FROM bounced_transactions 
                WHERE incoming_transaction_id = 1 AND initiated_outgoing_transaction_id = 1
            """).executeQuery()
            assertTrue(checkBounced.next())
            // check the related initiated payment exists.
            val checkInitiated = it.prepareStatement("""
                SELECT
                    (amount).val as amount_value
                    ,(amount).frac as amount_frac
                FROM initiated_outgoing_transactions
                WHERE initiated_outgoing_transaction_id = 1
            """).executeQuery()
            assertTrue(checkInitiated.next())
            assertEquals(
                53000000,
                checkInitiated.getInt("amount_frac")
            )
            assertEquals(
                2,
                checkInitiated.getInt("amount_value")
            )
        }
    }

    // Test creating an incoming reserve taler transaction without and ID and reconcile it later again
    @Test
    fun reconcileMissingId() = setup { db, _ ->
        val cfg = NexusIngestConfig.default(AccountType.exchange)
        val subject = "test with ${EddsaPublicKey.randEdsaKey()} reserve pub"  
        
        // Register with missing ID
        val incoming = genInPay(subject)
        val incomingMissingId = incoming.copy(bankId = null)
        registerIncomingPayment(db, cfg, incomingMissingId)
        db.checkInCount(1, 0, 1)
        assertFalse(db.inTxExists(incoming.bankId!!))

        // Idempotent
        registerIncomingPayment(db, cfg, incomingMissingId)
        db.checkInCount(1, 0, 1)

        // Different metadata is bounced
        registerIncomingPayment(db, cfg, genInPay(subject, "KUDOS:9"))
        registerIncomingPayment(db, cfg, genInPay("another $subject"))
        db.checkInCount(3, 2, 1)

        // Different medata with missing id is ignored
        registerIncomingPayment(db, cfg, incomingMissingId.copy(amount = TalerAmount("KUDOS:9")))
        registerIncomingPayment(db, cfg, incomingMissingId.copy(subject = "another $subject"))
        db.checkInCount(3, 2, 1)

        // Recover bank ID when metadata match
        registerIncomingPayment(db, cfg, incoming)
        assertTrue(db.inTxExists(incoming.bankId!!))

        // Idempotent
        registerIncomingPayment(db, cfg, incoming)
        db.checkInCount(3, 2, 1)
        
        // Missing ID is ignored
        registerIncomingPayment(db, cfg, incomingMissingId)
        db.checkInCount(3, 2, 1)

        // Other ID is bounced known that we know the id
        registerIncomingPayment(db, cfg, incomingMissingId.copy(bankId = "NEW"))
        db.checkInCount(4, 3, 1)
    }

    // Test creating an incoming kyc taler transaction without and ID and reconcile it later again
    @Test
    fun reconcileMissingIdKyc() = setup { db, _ ->
        val cfg = NexusIngestConfig.default(AccountType.exchange)
        val subject = "test with KYC:${EddsaPublicKey.randEdsaKey()} account pub"
        
        // Register with missing ID
        val incoming = genInPay(subject)
        val incomingMissingId = incoming.copy(bankId = null)
        registerIncomingPayment(db, cfg, incomingMissingId)
        db.checkInCount(1, 0, 1)
        assertFalse(db.inTxExists(incoming.bankId!!))

        // Idempotent
        registerIncomingPayment(db, cfg, incomingMissingId)
        db.checkInCount(1, 0, 1)

        // Different metadata is accepted
        registerIncomingPayment(db, cfg, genInPay(subject, "KUDOS:9"))
        registerIncomingPayment(db, cfg, genInPay("another $subject"))
        db.checkInCount(3, 0, 3)

        // Different medata with missing id are accepted
        registerIncomingPayment(db, cfg, incomingMissingId.copy(amount = TalerAmount("KUDOS:9.5")))
        registerIncomingPayment(db, cfg, incomingMissingId.copy(subject = "again another $subject"))
        db.checkInCount(5, 0, 5)
        
        // Recover bank ID when metadata match
        registerIncomingPayment(db, cfg, incoming)
        assertTrue(db.inTxExists(incoming.bankId!!))

        // Idempotent
        registerIncomingPayment(db, cfg, incoming)
        db.checkInCount(5, 0, 5)
        
        // Missing ID is ignored
        registerIncomingPayment(db, cfg, incomingMissingId)
        db.checkInCount(5, 0, 5)

        // Other ID is accepted
        registerIncomingPayment(db, cfg, incomingMissingId.copy(bankId = "NEW"))
        db.checkInCount(6, 0, 6)
    }
}

class PaymentInitiationsTest {

    // Test skipping transaction based on config
    @Test
    fun skipping() = setup("skip.conf") { db, cfg ->
        suspend fun checkCount(nbTxs: Int, nbBounce: Int) {
            db.serializable(
                """
                    SELECT (SELECT count(*) FROM incoming_transactions) + (SELECT count(*) FROM outgoing_transactions) AS transactions,
                        (SELECT count(*) FROM bounced_transactions) AS bounce
                """
            ) {
                one {
                    assertEquals(
                        Pair(nbTxs, nbBounce),
                        Pair(it.getInt("transactions"), it.getInt("bounce"))
                    )
                }
            }
        }

        suspend fun ingest(executionTime: Instant) {
            for (tx in sequenceOf(
                genInPay("test at $executionTime", executionTime = executionTime),
                genOutPay("test at $executionTime", executionTime = executionTime)
            )) {
                registerTransaction(db, cfg.ingest, tx)
            }
        }

        assertEquals(cfg.fetch.ignoreTransactionsBefore, dateToInstant("2024-04-04"))
        assertEquals(cfg.fetch.ignoreBouncesBefore, dateToInstant("2024-06-12"))

        // No transaction at the beginning
        checkCount(0, 0)

        // Skipped transactions
        ingest(cfg.fetch.ignoreTransactionsBefore.minusMillis(10))
        checkCount(0, 0)

        // Skipped bounces
        ingest(cfg.fetch.ignoreTransactionsBefore)
        ingest(cfg.fetch.ignoreTransactionsBefore.plusMillis(10))
        ingest(cfg.fetch.ignoreBouncesBefore.minusMillis(10))
        checkCount(6, 0)

        // Bounces
        ingest(cfg.fetch.ignoreBouncesBefore)
        ingest(cfg.fetch.ignoreBouncesBefore.plusMillis(10))
        checkCount(10, 2)
    }

    @Test
    fun status() = setup { db, _ ->
        suspend fun checkPart(
            batchId: Long, 
            batchStatus: SubmissionState, 
            batchMsg: String?, 
            txStatus: SubmissionState, 
            txMsg: String?,
            settledStatus: SubmissionState, 
            settledMsg: String?,
        ) {
            // Check batch status
            val msgId = db.serializable(
                """
                SELECT message_id, status, status_msg FROM initiated_outgoing_batches WHERE initiated_outgoing_batch_id=?
                """
            ) {
                setLong(1, batchId)
                one {
                    val msgId = it.getString("message_id")
                    assertEquals(
                        batchStatus to batchMsg,
                        it.getEnum<SubmissionState>("status") to it.getString("status_msg"),
                        msgId
                    )
                    msgId
                }
            }
            // Check tx status
            db.serializable(
                """
                SELECT end_to_end_id, status, status_msg FROM initiated_outgoing_transactions WHERE initiated_outgoing_batch_id=?
                """
            ) {
                setLong(1, batchId)
                all { 
                    val endToEndId = it.getString("end_to_end_id")
                    val expected = when (endToEndId) {
                        "TX" -> Pair(txStatus, txMsg)
                        "TX_SETTLED" -> Pair(settledStatus, settledMsg)
                        else -> throw Exception("Unexpected tx $endToEndId")
                    }
                    assertEquals(
                        expected,
                        it.getEnum<SubmissionState>("status") to it.getString("status_msg"),
                        "$msgId.$endToEndId"
                    )
                }
            }
        }
        suspend fun checkBatch(batchId: Long, status: SubmissionState, msg: String?, txStatus: SubmissionState? = null) {
            val txStatus = txStatus ?: status
            checkPart(batchId, status, msg, txStatus, msg, txStatus, msg)
        }
        suspend fun checkOrder(orderId: String, status: SubmissionState, msg: String?, txStatus: SubmissionState? = null) {
            val batchId = db.serializable(
                "SELECT initiated_outgoing_batch_id FROM initiated_outgoing_batches WHERE order_id=?"
            ) {
                setString(1, orderId)
                one { 
                    it.getLong("initiated_outgoing_batch_id")
                }
            }
            checkBatch(batchId, status, msg, txStatus)
        }

        suspend fun test(lambda: suspend (Long) -> Unit) {
            // Reset DB
            db.conn { conn -> 
                conn.execSQLUpdate("DELETE FROM initiated_outgoing_transactions");
                conn.execSQLUpdate("DELETE FROM initiated_outgoing_batches");
            }
            // Create a test batch with three transactions
            for (id in sequenceOf("TX", "TX_SETTLED")) {
                assertIs<PaymentInitiationResult.Success>(
                    db.initiated.create(genInitPay(id))
                )
            }
            db.initiated.batch(Instant.now(), "BATCH")
            // Create witness transactions and batch
            for (id in sequenceOf("WITNESS_1", "WITNESS_2")) {
                assertIs<PaymentInitiationResult.Success>(
                    db.initiated.create(genInitPay(id))
                )
            }
            db.initiated.batch(Instant.now(), "BATCH_WITNESS")
            for (id in sequenceOf("WITNESS_3", "WITNESS_4")) {
                assertIs<PaymentInitiationResult.Success>(
                    db.initiated.create(genInitPay(id))
                )
            }
            // Check everything is unsubmitted
            db.serializable(
                """
                SELECT (SELECT bool_and(status = 'unsubmitted') FROM initiated_outgoing_batches)
                   AND (SELECT bool_and(status = 'unsubmitted') FROM initiated_outgoing_transactions)
                """
            ) {
                one { assertTrue(it.getBoolean(1)) }
            }
            // Run test
            lambda(db.initiated.submittable("").find { it.messageId == "BATCH" }!!.id)
            // Check witness status is unaltered
            db.serializable(
                """
                SELECT (SELECT bool_and(status = 'unsubmitted') FROM initiated_outgoing_batches WHERE message_id != 'BATCH')
                   AND (SELECT bool_and(initiated_outgoing_transactions.status = 'unsubmitted') 
                            FROM initiated_outgoing_transactions JOIN initiated_outgoing_batches USING (initiated_outgoing_batch_id)
                            WHERE message_id != 'BATCH')
                """
            ) {
                one { assertTrue(it.getBoolean(1)) }
            }
        }

        // Submission retry status
        test { batchId ->
            db.initiated.batchSubmissionFailure(batchId, Instant.now(), "First failure")
            checkBatch(batchId, SubmissionState.transient_failure, "First failure")
            db.initiated.batchSubmissionFailure(batchId, Instant.now(), "Second failure")
            checkBatch(batchId, SubmissionState.transient_failure, "Second failure")
            db.initiated.batchSubmissionSuccess(batchId, Instant.now(), "ORDER")
            checkOrder("ORDER", SubmissionState.pending, null)
            db.initiated.batchSubmissionSuccess(batchId, Instant.now(), "ORDER")
            checkOrder("ORDER", SubmissionState.pending, null)
            db.initiated.orderStep("ORDER", "step msg")
            checkOrder("ORDER", SubmissionState.pending, "step msg")
            db.initiated.orderStep("ORDER", "success msg")
            checkOrder("ORDER", SubmissionState.pending, "success msg")
            db.initiated.orderSuccess("ORDER")
            checkOrder("ORDER", SubmissionState.success, "success msg", SubmissionState.pending)
            db.initiated.orderStep("ORDER", "late msg")
            checkOrder("ORDER", SubmissionState.success, "success msg", SubmissionState.pending)
        }

        // Order step message on failure
        test { batchId ->
            db.initiated.batchSubmissionSuccess(batchId, Instant.now(), "ORDER")
            checkOrder("ORDER", SubmissionState.pending, null)
            db.initiated.orderStep("ORDER", "step msg")
            checkOrder("ORDER", SubmissionState.pending, "step msg")
            db.initiated.orderStep("ORDER", "failure msg")
            checkOrder("ORDER", SubmissionState.pending, "failure msg")
            assertEquals("failure msg", db.initiated.orderFailure("ORDER")!!.second)
            checkOrder("ORDER", SubmissionState.permanent_failure, "failure msg")
            db.initiated.orderStep("ORDER", "late msg")
            checkOrder("ORDER", SubmissionState.permanent_failure, "failure msg")
        }

        // Payment & batch status
        test { batchId ->
            checkBatch(batchId, SubmissionState.unsubmitted, null)
            db.initiated.batchStatusUpdate("BATCH", StatusUpdate.pending, "progress")
            checkBatch(batchId, SubmissionState.pending, "progress")
            db.initiated.txStatusUpdate("TX_SETTLED", null, StatusUpdate.success, "success")
            checkPart(batchId, SubmissionState.pending, "progress", SubmissionState.pending, "progress", SubmissionState.success, "success")
            db.initiated.batchStatusUpdate("BATCH", StatusUpdate.transient_failure, "waiting")
            checkPart(batchId, SubmissionState.transient_failure, "waiting", SubmissionState.transient_failure, "waiting", SubmissionState.success, "success")
            db.initiated.txStatusUpdate("TX", "BATCH", StatusUpdate.permanent_failure, "failure")
            checkPart(batchId, SubmissionState.success, null, SubmissionState.permanent_failure, "failure", SubmissionState.success, "success")
            db.initiated.txStatusUpdate("TX_SETTLED", "BATCH", StatusUpdate.permanent_failure, "late")
            checkPart(batchId, SubmissionState.success, null, SubmissionState.permanent_failure, "failure", SubmissionState.late_failure, "late")
        }

        // Registration
        test { batchId ->
            checkBatch(batchId, SubmissionState.unsubmitted, null)
            registerOutgoingPayment(db, genOutPay("", endToEndId = "TX_SETTLED"))
            checkPart(batchId, SubmissionState.unsubmitted, null, SubmissionState.unsubmitted, null, SubmissionState.success, null)
            registerOutgoingPayment(db, genOutPay("", endToEndId = "TX", msgId = "BATCH"))
            checkPart(batchId, SubmissionState.success, null, SubmissionState.success, null, SubmissionState.success, null)
        }
       
        // Unknown order and batch
        db.initiated.batchSubmissionSuccess(42, Instant.now(), "ORDER_X")
        db.initiated.batchSubmissionFailure(42, Instant.now(), null)
        db.initiated.orderStep("ORDER_X", "msg")
        db.initiated.batchStatusUpdate("BATCH_X", StatusUpdate.success, null)
        db.initiated.txStatusUpdate("TX_X", "BATCH_X", StatusUpdate.success, "msg")
        assertNull(db.initiated.orderSuccess("ORDER_X"))
        assertNull(db.initiated.orderFailure("ORDER_X"))
    }

    @Test
    fun submittable() = setup { db, _ -> 
        repeat(6) {
            assertIs<PaymentInitiationResult.Success>(
                db.initiated.create(genInitPay("PAY$it"))
            )
            db.initiated.batch(Instant.now(), "BATCH$it")
        }
        suspend fun checkIds(vararg ids: String) {
            assertEquals(
                listOf(*ids),
                db.initiated.submittable("KUDOS").flatMap { it.payments.map { it.endToEndId } }
            )
        }
        checkIds("PAY0", "PAY1", "PAY2", "PAY3", "PAY4", "PAY5")

        // Check submitted not submitable
        db.initiated.batchSubmissionSuccess(1, Instant.now(), "ORDER1")
        checkIds("PAY1", "PAY2", "PAY3", "PAY4", "PAY5")

        // Check transient failure submitable last
        db.initiated.batchSubmissionFailure(2, Instant.now(), "Failure")
        checkIds("PAY2", "PAY3", "PAY4", "PAY5", "PAY1")

        // Check persistent failure not submitable
        db.initiated.batchSubmissionSuccess(4, Instant.now(), "ORDER3")
        db.initiated.orderFailure("ORDER3")
        checkIds("PAY2", "PAY4", "PAY5", "PAY1")
        db.initiated.batchSubmissionSuccess(5, Instant.now(), "ORDER4")
        db.initiated.orderFailure("ORDER4")
        checkIds("PAY2", "PAY5", "PAY1")

        // Check rotation
        db.initiated.batchSubmissionFailure(3, Instant.now(), "Failure")
        checkIds("PAY5", "PAY1", "PAY2")
        db.initiated.batchSubmissionFailure(6, Instant.now(), "Failure")
        checkIds("PAY1", "PAY2", "PAY5")
        db.initiated.batchSubmissionFailure(2, Instant.now(), "Failure")
        checkIds("PAY2", "PAY5", "PAY1")
    }

    // TODO test for unsettledTxInBatch
}

class EbicsTxTest {
    // Test pending transaction's id
    @Test
    fun pending() = setup { db, _ ->
        val ids = setOf("first", "second", "third")
        for (id in ids) {
            db.ebics.register(id)
        }

        repeat(ids.size) {
            val id = db.ebics.first()
            assert(ids.contains(id))
            db.ebics.remove(id!!)
        }

        assertNull(db.ebics.first())
    }
}