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

import tech.libeufin.bank.Operation
import tech.libeufin.bank.TanChannel
import tech.libeufin.common.db.*
import tech.libeufin.common.*
import tech.libeufin.bank.*
import tech.libeufin.bank.auth.*
import java.time.Duration
import java.time.Instant
import java.util.UUID
import java.util.concurrent.TimeUnit

/** Data access logic for tan challenged */
class TanDAO(private val db: Database) {
    /** Create a new challenge */
    suspend fun new(
        username: String, 
        op: Operation,
        hbody: Base32Crockford64B,
        salt: Base32Crockford16B,
        code: String,
        timestamp: Instant,
        retryCounter: Int,
        validityPeriod: Duration,
        tanChannel: TanChannel,
        tanInfo: String
    ): UUID = db.serializable(
        """
        INSERT INTO tan_challenges (
            hbody,
            salt,
            op,
            code,
            creation_date,
            expiration_date,
            retry_counter,
            customer,
            tan_channel,
            tan_info,
            uuid
        ) VALUES ( 
            ?,
            ?,
            ?::op_enum,
            ?,
            ?,
            ?,
            ?,
            (SELECT customer_id FROM customers WHERE username = ? AND deleted_at IS NULL),
            ?::tan_enum,
            ?,
            gen_random_uuid()
        ) RETURNING uuid
        """
    ) {
        bind(hbody)
        bind(salt)
        bind(op)
        bind(code)
        bind(timestamp)
        bind(timestamp.micros() + TimeUnit.MICROSECONDS.convert(validityPeriod))
        bind(retryCounter)
        bind(username)
        bind(tanChannel)
        bind(tanInfo)
        one {
            it.getObject(1) as UUID
        }
    }

    /** Result of TAN challenge transmission */
    sealed interface TanSendResult {
        data class Send(
            val tanInfo: String,
            val tanChannel: TanChannel,
            val tanCode: String,
            val expiration: TalerTimestamp
        )
        data class Success(
            val expiration: TalerTimestamp,
            val retransmission: TalerTimestamp
        ): TanSendResult
        data object Expired: TanSendResult
        data object Solved: TanSendResult
        data object NotFound: TanSendResult
        data object TooMany: TanSendResult
    }

    /** Request TAN challenge transmission */
    suspend fun send(
        uuid: UUID,
        timestamp: Instant,
        maxActive: Int
    ) = db.serializable(
        """
        SELECT
           (confirmation_date IS NOT NULL) as solved
          ,retransmission_date
          ,expiration_date
          ,code
          ,tan_channel
          ,tan_info
          -- If this is the first time we submit this challenge check there is not too many active challenges
          ,(retransmission_date = 0 AND (
            SELECT count(*) >= ?
            FROM tan_challenges as o
            WHERE c.customer = o.customer
              AND retransmission_date != 0
              AND confirmation_date IS NULL
              AND expiration_date >= ?
          )) AS too_many
        FROM tan_challenges as c
        WHERE uuid = ?
        """
    ) {
        bind(maxActive)
        bind(timestamp)
        bind(uuid)
        oneOrNull {
            when {
                it.getBoolean("solved") -> TanSendResult.Solved
                it.getBoolean("too_many") -> TanSendResult.TooMany
                else -> {
                    val retransmission = it.getTalerTimestamp("retransmission_date")
                    val expiration = it.getTalerTimestamp("expiration_date")
                    if (expiration.instant.isBefore(timestamp)) {
                        TanSendResult.Expired
                    } else if (retransmission.instant.isBefore(timestamp)) {
                        TanSendResult.Send(
                            tanInfo = it.getString("tan_info"),
                            tanChannel = it.getEnum("tan_channel"),
                            tanCode = it.getString("code"),
                            expiration = expiration
                        )
                    } else {
                        TanSendResult.Success(
                            expiration = expiration,
                            retransmission = retransmission
                        )
                    }
                }
            }
        } ?: TanSendResult.NotFound
    }

    /** Mark TAN challenge transmission */
    suspend fun markSent(
        uuid: UUID,
        retransmission: Instant
    ) = db.serializable(
        "UPDATE tan_challenges SET retransmission_date = ? WHERE uuid = ?"
    ) {
        bind(retransmission)
        bind(uuid)
        executeUpdate()
    }

    /** Result of TAN challenge solution */
    sealed interface TanSolveResult {
        data class Success(val op: Operation, val channel: TanChannel?, val info: String?): TanSolveResult
        data object NotFound: TanSolveResult
        data object NoRetry: TanSolveResult
        data object Expired: TanSolveResult
        data object BadCode: TanSolveResult
    }

    /** Solve TAN challenge */
    suspend fun solve(
        uuid: UUID,
        code: String,
        timestamp: Instant
    ) = db.serializable(
        """
        SELECT 
            out_ok, out_no_op, out_no_retry, out_expired,
            out_op, out_channel, out_info
        FROM tan_challenge_try(?,?,?)
        """
    ) {
        bind(uuid)
        bind(code)
        bind(timestamp.micros())
        one {
            when {
                it.getBoolean("out_ok") -> TanSolveResult.Success(
                    op = it.getEnum("out_op"),
                    channel = it.getOptEnum<TanChannel>("out_channel"),
                    info = it.getString("out_info")
                )
                it.getBoolean("out_no_op") -> TanSolveResult.NotFound
                it.getBoolean("out_no_retry") -> TanSolveResult.NoRetry
                it.getBoolean("out_expired") -> TanSolveResult.Expired
                else -> TanSolveResult.BadCode
            }
        }
    }

    data class SolvedChallenge(
        val salt: Base32Crockford16B,
        val hbody: Base32Crockford64B,
        val channel: TanChannel,
        val info: String,
        val confirmed: Boolean,
        val op: Operation
    )

    /** Get a TAN challenge [uuid] */
    suspend fun challenge(uuids: List<UUID>): List<SolvedChallenge> = db.serializable(
        """
        SELECT salt, hbody, tan_channel, tan_info, op, (confirmation_date IS NOT NULL) as confirmed
        FROM tan_challenges
        WHERE uuid = ANY(?::uuid[])
        """
    ) {
        bind(uuids.toTypedArray())
        all { 
            SolvedChallenge(
                salt = Base32Crockford16B(it.getBytes("salt")),
                hbody = Base32Crockford64B(it.getBytes("hbody")),
                op = it.getEnum<Operation>("op"),
                channel = it.getEnum<TanChannel>("tan_channel"),
                info = it.getString("tan_info"),
                confirmed = it.getBoolean("confirmed")
            )
        }
    }
}