/*
 * 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.*
import tech.libeufin.common.*
import tech.libeufin.common.crypto.*
import tech.libeufin.common.db.*
import java.time.Instant

/** Data access logic for accounts */
class AccountDAO(private val db: Database) {
    /** Result status of account creation */
    sealed interface AccountCreationResult {
        data class Success(val payto: String): AccountCreationResult
        data object UsernameReuse: AccountCreationResult
        data object PayToReuse: AccountCreationResult
        data object BonusBalanceInsufficient: AccountCreationResult
    }

    /** Create new account */
    suspend fun create(
        username: String,
        password: String,
        name: String,
        email: String?,
        phone: String?,
        cashoutPayto: IbanPayto?,
        internalPayto: Payto,
        isPublic: Boolean,
        isTalerExchange: Boolean,
        maxDebt: TalerAmount,
        minCashout: TalerAmount?,
        bonus: TalerAmount,
        tanChannel: TanChannel?,
        // Whether to check [internalPaytoUri] for idempotency
        checkPaytoIdempotent: Boolean,
        pwCrypto: PwCrypto
    ): AccountCreationResult = db.serializableTransaction { conn ->
        val timestamp = Instant.now().micros()
        val idempotent = conn.withStatement("""
            SELECT password_hash, name=?
                AND email IS NOT DISTINCT FROM ?
                AND phone IS NOT DISTINCT FROM ?
                AND cashout_payto IS NOT DISTINCT FROM ?
                AND tan_channel IS NOT DISTINCT FROM ?::tan_enum
                AND (NOT ? OR internal_payto=?)
                AND is_public=?
                AND is_taler_exchange=?
                AND max_debt=(?,?)::taler_amount
                AND ${if (minCashout == null) "min_cashout IS NULL" else "min_cashout IS NOT DISTINCT FROM (?,?)::taler_amount"}
                ,internal_payto, name
            FROM customers 
                JOIN bank_accounts
                    ON customer_id=owning_customer_id
            WHERE username=?
        """) {
            setString(1, name)
            setString(2, email)
            setString(3, phone)
            setString(4, cashoutPayto?.simple())
            setString(5, tanChannel?.name)
            setBoolean(6, checkPaytoIdempotent)
            setString(7, internalPayto.canonical)
            setBoolean(8, isPublic)
            setBoolean(9, isTalerExchange)
            setLong(10, maxDebt.value)
            setInt(11, maxDebt.frac)
            if (minCashout != null) {
                setLong(12, minCashout.value)
                setInt(13, minCashout.frac)
                setString(14, username)
            } else {
                setString(12, username)
            }
            oneOrNull { 
                Pair(
                    pwCrypto.checkpw(password, it.getString(1)).match && it.getBoolean(2),
                    it.getBankPayto("internal_payto", "name", db.ctx)
                )
            } 
        }
        
        if (idempotent != null) {
            if (idempotent.first) {
                AccountCreationResult.Success(idempotent.second)
            } else {
                AccountCreationResult.UsernameReuse
            }
        } else {
            if (internalPayto is IbanPayto)
                conn.withStatement ("""
                    INSERT INTO iban_history(
                        iban
                        ,creation_time
                    ) VALUES (?, ?)
                """) {
                    setString(1, internalPayto.iban.value)
                    setLong(2, timestamp)
                    if (!executeUpdateViolation()) {
                        conn.rollback()
                        return@serializableTransaction AccountCreationResult.PayToReuse
                    }
                }

            val customerId = conn.withStatement("""
                INSERT INTO customers (
                    username
                    ,password_hash
                    ,name
                    ,email
                    ,phone
                    ,cashout_payto
                    ,tan_channel
                ) VALUES (?, ?, ?, ?, ?, ?, ?::tan_enum)
                    RETURNING customer_id
            """
            ) {
                setString(1, username)
                setString(2, pwCrypto.hashpw(password))
                setString(3, name)
                setString(4, email)
                setString(5, phone)
                setString(6, cashoutPayto?.simple())
                setString(7, tanChannel?.name)
                one { it.getLong("customer_id") }
            }
        
            conn.withStatement("""
                INSERT INTO bank_accounts(
                    internal_payto
                    ,owning_customer_id
                    ,is_public
                    ,is_taler_exchange
                    ,max_debt
                    ,min_cashout
                ) VALUES (?, ?, ?, ?, (?, ?)::taler_amount, ${if (minCashout == null) "NULL" else "(?, ?)::taler_amount"})
            """) {
                setString(1, internalPayto.canonical)
                setLong(2, customerId)
                setBoolean(3, isPublic)
                setBoolean(4, isTalerExchange)
                setLong(5, maxDebt.value)
                setInt(6, maxDebt.frac)
                if (minCashout != null) {
                    setLong(7, minCashout.value)
                    setInt(8, minCashout.frac)
                }
                if (!executeUpdateViolation()) {
                    conn.rollback()
                    return@serializableTransaction AccountCreationResult.PayToReuse
                }
            }
            
            if (bonus.value != 0L || bonus.frac != 0) {
                conn.withStatement("""
                    SELECT out_balance_insufficient
                    FROM bank_transaction(?,'admin','bonus',(?,?)::taler_amount,?,true,NULL,NULL,NULL,NULL)
                """) {
                    setString(1, internalPayto.canonical)
                    setLong(2, bonus.value)
                    setInt(3, bonus.frac)
                    setLong(4, timestamp)
                    one {
                        when {
                            it.getBoolean("out_balance_insufficient") -> {
                                conn.rollback()
                                AccountCreationResult.BonusBalanceInsufficient
                            }
                            else -> AccountCreationResult.Success(internalPayto.bank(name, db.ctx))
                        }
                    }
                }
            } else {
                AccountCreationResult.Success(internalPayto.bank(name, db.ctx))
            }
        }
    }

    /** Result status of account deletion */
    enum class AccountDeletionResult {
        Success,
        UnknownAccount,
        BalanceNotZero,
        TanRequired
    }

    /** Delete account [username] */
    suspend fun delete(
        username: String, 
        is2fa: Boolean
    ): AccountDeletionResult = db.serializable(
        """
        SELECT
            out_not_found,
            out_balance_not_zero,
            out_tan_required
        FROM account_delete(?,?,?)
        """
    ) {
        setString(1, username)
        setLong(2, Instant.now().micros())
        setBoolean(3, is2fa)
        one {
            when {
                it.getBoolean("out_not_found") -> AccountDeletionResult.UnknownAccount
                it.getBoolean("out_balance_not_zero") -> AccountDeletionResult.BalanceNotZero
                it.getBoolean("out_tan_required") -> AccountDeletionResult.TanRequired
                else -> AccountDeletionResult.Success
            }
        }
    }

    /** Result status of customer account patch */
    sealed interface AccountPatchResult {
        data object UnknownAccount: AccountPatchResult
        data object NonAdminName: AccountPatchResult
        data object NonAdminCashout: AccountPatchResult
        data object NonAdminDebtLimit: AccountPatchResult
        data object NonAdminMinCashout: AccountPatchResult
        data object MissingTanInfo: AccountPatchResult
        data class TanRequired(val channel: TanChannel?, val info: String?): AccountPatchResult
        data object Success: AccountPatchResult
    }

    /** Change account [username] information */
    suspend fun reconfig(
        username: String,
        name: String?,
        cashoutPayto: Option<IbanPayto?>,
        phone: Option<String?>,
        email: Option<String?>,
        tan_channel: Option<TanChannel?>,
        isPublic: Boolean?,
        debtLimit: TalerAmount?,
        minCashout: Option<TalerAmount?>,
        isAdmin: Boolean,
        is2fa: Boolean,
        faChannel: TanChannel?,
        faInfo: String?,
        allowEditName: Boolean,
        allowEditCashout: Boolean,
    ): AccountPatchResult = db.serializableTransaction { conn ->
        val checkName = !isAdmin && !allowEditName && name != null
        val checkCashout = !isAdmin && !allowEditCashout && cashoutPayto.isSome()
        val checkDebtLimit = !isAdmin && debtLimit != null
        val checkMinCashout = !isAdmin && minCashout.isSome()

        data class CurrentAccount(
            val id: Long,
            val channel: TanChannel?,
            val email: String?,
            val phone: String?,
            val name: String,
            val cashoutPayTo: String?,
            val debtLimit: TalerAmount,
            val minCashout: TalerAmount?
        )

        // Get user ID and current data
        val curr = conn.withStatement("""
            SELECT 
                customer_id, tan_channel, phone, email, name, cashout_payto
                ,(max_debt).val AS max_debt_val
                ,(max_debt).frac AS max_debt_frac
                ,(min_cashout).val AS min_cashout_val
                ,(min_cashout).frac AS min_cashout_frac
            FROM customers
                JOIN bank_accounts 
                ON customer_id=owning_customer_id
            WHERE username=? AND deleted_at IS NULL
        """) {
            setString(1, username)
            oneOrNull {
                CurrentAccount(
                    id = it.getLong("customer_id"),
                    channel = it.getOptEnum<TanChannel>("tan_channel"),
                    phone = it.getString("phone"),
                    email = it.getString("email"),
                    name = it.getString("name"),
                    cashoutPayTo = it.getOptIbanPayto("cashout_payto")?.simple(),
                    debtLimit = it.getAmount("max_debt", db.bankCurrency),
                    minCashout = it.getOptAmount("min_cashout", db.bankCurrency),
                )
            } ?: return@serializableTransaction AccountPatchResult.UnknownAccount
        }

        // Patched TAN channel
        val patchChannel = tan_channel.get()
        // TAN channel after the PATCH
        val newChannel = patchChannel ?: curr.channel
        // Patched TAN info
        val patchInfo = when (newChannel) {
            TanChannel.sms -> phone.get()
            TanChannel.email -> email.get()
            null -> null
        }
        // TAN info after the PATCH
        val newInfo = patchInfo ?: when (newChannel) {
            TanChannel.sms -> curr.phone
            TanChannel.email -> curr.email
            null -> null
        }
        // Cashout payto without a receiver-name
        val simpleCashoutPayto = cashoutPayto.get()?.simple()

        // Check reconfig rights
        if (checkName && name != curr.name) 
            return@serializableTransaction AccountPatchResult.NonAdminName
        if (checkCashout && simpleCashoutPayto != curr.cashoutPayTo) 
            return@serializableTransaction AccountPatchResult.NonAdminCashout
        if (checkDebtLimit && debtLimit != curr.debtLimit)
            return@serializableTransaction AccountPatchResult.NonAdminDebtLimit
        if (checkMinCashout && minCashout.get() != curr.minCashout)
            return@serializableTransaction AccountPatchResult.NonAdminMinCashout
        if (patchChannel != null && newInfo == null)
            return@serializableTransaction AccountPatchResult.MissingTanInfo

        // Tan channel verification
        if (!isAdmin) {
            // Check performed 2fa check
            if (curr.channel != null && !is2fa) {
                // Perform challenge with current settings
                return@serializableTransaction AccountPatchResult.TanRequired(channel = null, info = null)
            }
            // If channel or info changed and the 2fa challenge is performed with old settings perform a new challenge with new settings
            if ((patchChannel != null && patchChannel != faChannel) || (patchInfo != null && patchInfo != faInfo)) {
                return@serializableTransaction AccountPatchResult.TanRequired(channel = newChannel, info = newInfo)
            }
        }

        // Invalidate current challenges
        if (patchChannel != null || patchInfo != null) {
            conn.withStatement("UPDATE tan_challenges SET expiration_date=0 WHERE customer=?") {
                setLong(1, curr.id)
                execute()
            }
        }

        // Update bank info
        conn.dynamicUpdate(
            "bank_accounts",
            sequence {
                if (isPublic != null) yield("is_public=?")
                if (debtLimit != null) yield("max_debt=(?, ?)::taler_amount")
                minCashout.some { 
                    if (it != null) {
                        yield("min_cashout=(?, ?)::taler_amount")
                    } else {
                        yield("min_cashout=null")
                    }
                }
            },
            "WHERE owning_customer_id = ?",
            sequence {
                isPublic?.let { yield(it) }
                debtLimit?.let { yield(it.value); yield(it.frac) }
                minCashout.some { 
                    if (it != null) {
                        yield(it.value)
                        yield(it.frac)
                    }
                }
                yield(curr.id)
            }
        )

        // Update customer info
        conn.dynamicUpdate(
            "customers",
            sequence {
                cashoutPayto.some { yield("cashout_payto=?") }
                phone.some { yield("phone=?") }
                email.some { yield("email=?") }
                tan_channel.some { yield("tan_channel=?::tan_enum") }
                name?.let { yield("name=?") }
            },
            "WHERE customer_id = ?",
            sequence {
                cashoutPayto.some { yield(simpleCashoutPayto) }
                phone.some { yield(it) }
                email.some { yield(it) }
                tan_channel.some { yield(it?.name) }
                name?.let { yield(it) }
                yield(curr.id)
            }
        )

        AccountPatchResult.Success
    }


    /** Result status of customer account auth patch */
    enum class AccountPatchAuthResult {
        UnknownAccount,
        OldPasswordMismatch,
        TanRequired,
        Success
    }

    /** Change account [username] password to [newPw] if current match [oldPw] */
    suspend fun reconfigPassword(
        username: String, 
        newPw: Password, 
        oldPw: String?,
        is2fa: Boolean,
        pwCrypto: PwCrypto
    ): AccountPatchAuthResult = db.serializableTransaction { conn ->
        val (customerId, currentPwh, tanRequired) = conn.withStatement("""
            SELECT customer_id, password_hash, (NOT ? AND tan_channel IS NOT NULL) 
            FROM customers WHERE username=? AND deleted_at IS NULL
        """) {
            setBoolean(1, is2fa)
            setString(2, username)
            oneOrNull { 
                Triple(it.getLong(1), it.getString(2), it.getBoolean(3))
            } ?: return@serializableTransaction AccountPatchAuthResult.UnknownAccount
        }
        if (tanRequired) {
            AccountPatchAuthResult.TanRequired
        } else if (oldPw != null && !pwCrypto.checkpw(oldPw, currentPwh).match) {
            AccountPatchAuthResult.OldPasswordMismatch
        } else {
            val newPwh = pwCrypto.hashpw(newPw.pw)
            conn.withStatement("UPDATE customers SET password_hash=?, token_creation_counter=0 WHERE customer_id=?") {
                setString(1, newPwh)
                setLong(2, customerId)
                executeUpdate()
            }
          
            AccountPatchAuthResult.Success
        }
    }

    /** Result status of customer account password check */
    enum class CheckPasswordResult {
        UnknownAccount,
        PasswordMismatch,
        Locked,
        Success
    }

    /** Check password of account [username] against [pw], rehashing it if outdated  */
    suspend fun checkPassword(username: String, pw: String, pwCrypto: PwCrypto): CheckPasswordResult {
        // Get user current password hash
        val info = db.serializable(
            "SELECT customer_id, password_hash, token_creation_counter FROM customers WHERE username=? AND deleted_at IS NULL"
        ) { 
            setString(1, username)
            oneOrNull { 
                Triple(it.getLong(1), it.getString(2), it.getInt(3))
            }
        }
        if (info == null) return CheckPasswordResult.UnknownAccount
        val (customerId, currentPwh, tokenCreationCounter) = info

        // Check locked
        if (tokenCreationCounter >= MAX_TOKEN_CREATION_ATTEMPTS) return CheckPasswordResult.Locked

        // Check password
        val check = pwCrypto.checkpw(pw, currentPwh)
        if (!check.match) return CheckPasswordResult.PasswordMismatch

        // Reshah if outdated
        if (check.outdated) {
            val newPwh = pwCrypto.hashpw(pw)
            db.serializable(
                "UPDATE customers SET password_hash=? where customer_id=? AND password_hash=?"
            ) { 
                setString(1, newPwh)
                setLong(2, customerId)
                setString(3, currentPwh)
                executeUpdate()
            }
        }

        return CheckPasswordResult.Success
    }

    /** Get bank info of account [username] */
    suspend fun bankInfo(username: String): BankInfo? = db.serializable(
        """
        SELECT
            bank_account_id
            ,internal_payto
            ,name
            ,is_taler_exchange
        FROM bank_accounts
            JOIN customers ON customer_id=owning_customer_id
        WHERE username=?
        """
    ) {
        setString(1, username)
        oneOrNull {
            BankInfo(
                payto = it.getBankPayto("internal_payto", "name", db.ctx),
                isTalerExchange = it.getBoolean("is_taler_exchange"),
                bankAccountId = it.getLong("bank_account_id")
            )
        }
    }

    /** Check bank info of account [payto] */
    suspend fun checkInfo(payto: IbanPayto): AccountInfo? = db.serializable(
        """
        SELECT FROM bank_accounts WHERE internal_payto=?
        """
    ) {
        setString(1, payto.canonical)
        oneOrNull {
            AccountInfo()
        }
    }

    /** Get data of account [username] */
    suspend fun get(username: String): AccountData? = db.serializable(
        """
        SELECT
            name
            ,email
            ,phone
            ,tan_channel
            ,cashout_payto
            ,internal_payto
            ,(balance).val AS balance_val
            ,(balance).frac AS balance_frac
            ,has_debt
            ,(max_debt).val AS max_debt_val
            ,(max_debt).frac AS max_debt_frac
            ,(min_cashout).val AS min_cashout_val
            ,(min_cashout).frac AS min_cashout_frac
            ,is_public
            ,is_taler_exchange
            ,CASE 
                WHEN deleted_at IS NOT NULL THEN 'deleted'
                WHEN token_creation_counter > ? THEN 'locked'
                ELSE 'active'
            END as status
        FROM customers 
            JOIN bank_accounts
                ON customer_id=owning_customer_id
        WHERE username=?
        """
    ) {
        setInt(1, MAX_TOKEN_CREATION_ATTEMPTS)
        setString(2, username)
        oneOrNull {            
            val name = it.getString("name")
            val status: AccountStatus = it.getEnum("status")
            AccountData(
                name = name,
                contact_data = ChallengeContactData(
                    email = Option.Some(it.getString("email")),
                    phone = Option.Some(it.getString("phone"))
                ),
                tan_channel = it.getOptEnum<TanChannel>("tan_channel"),
                cashout_payto_uri = it.getOptIbanPayto("cashout_payto")?.full(name),
                payto_uri = it.getBankPayto("internal_payto", "name", db.ctx),
                balance = Balance(
                    amount = it.getAmount("balance", db.bankCurrency),
                    credit_debit_indicator =
                        if (it.getBoolean("has_debt")) {
                            CreditDebitInfo.debit
                        } else {
                            CreditDebitInfo.credit
                        }
                ),
                debit_threshold = it.getAmount("max_debt", db.bankCurrency),
                min_cashout = it.getOptAmount("min_cashout", db.bankCurrency),
                is_public = it.getBoolean("is_public"),
                is_taler_exchange = it.getBoolean("is_taler_exchange"),
                status = status,
                is_locked = status == AccountStatus.locked
            )
        }
    }

    /** Get a page of all public accounts */
    suspend fun pagePublic(params: AccountParams): List<PublicAccount> 
        = db.page(
            params.page,
            "bank_account_id",
            """
            SELECT
              (balance).val AS balance_val,
              (balance).frac AS balance_frac,
              has_debt,
              internal_payto,
              username,
              is_taler_exchange,
              name,
              bank_account_id
              FROM bank_accounts JOIN customers
                ON owning_customer_id = customer_id 
              WHERE is_public=true AND 
                ${if (params.usernameFilter != null) "name LIKE ? AND" else ""} 
                deleted_at IS NULL AND
            """,
            {
                if (params.usernameFilter != null) {
                    setString(1, params.usernameFilter)
                    1
                } else {
                    0
                }
            }
        ) {
            PublicAccount(
                username = it.getString("username"),
                row_id = it.getLong("bank_account_id"),
                payto_uri = it.getBankPayto("internal_payto", "name", db.ctx),
                balance = Balance(
                    amount = it.getAmount("balance", db.bankCurrency),
                    credit_debit_indicator = if (it.getBoolean("has_debt")) {
                        CreditDebitInfo.debit 
                    } else {
                        CreditDebitInfo.credit
                    }
                ),
                is_taler_exchange = it.getBoolean("is_taler_exchange")
            )
        }

    /** Get a page of accounts */
    suspend fun pageAdmin(params: AccountParams): List<AccountMinimalData>
        = db.page(
            params.page,
            "bank_account_id",
            """
            SELECT
            username,
            name,
            (balance).val AS balance_val,
            (balance).frac AS balance_frac,
            has_debt AS balance_has_debt,
            (max_debt).val as max_debt_val,
            (max_debt).frac as max_debt_frac
            ,(min_cashout).val AS min_cashout_val
            ,(min_cashout).frac AS min_cashout_frac
            ,is_public
            ,is_taler_exchange
            ,internal_payto
            ,bank_account_id
            ,CASE 
                WHEN deleted_at IS NOT NULL THEN 'deleted'
                WHEN token_creation_counter > ? THEN 'locked'
                ELSE 'active'
            END as status
            FROM bank_accounts JOIN customers
              ON owning_customer_id = customer_id 
            WHERE ${if (params.usernameFilter != null) "name LIKE ? AND" else ""} 
            """,
            {
                setInt(1, MAX_TOKEN_CREATION_ATTEMPTS)
                if (params.usernameFilter != null) {
                    setString(2, params.usernameFilter)
                    2
                } else {
                    1
                }
            }
        ) {
            val status: AccountStatus = it.getEnum("status")
            AccountMinimalData(
                username = it.getString("username"),
                row_id = it.getLong("bank_account_id"),
                name = it.getString("name"),
                balance = Balance(
                    amount = it.getAmount("balance", db.bankCurrency),
                    credit_debit_indicator = if (it.getBoolean("balance_has_debt")) {
                        CreditDebitInfo.debit
                    } else {
                        CreditDebitInfo.credit
                    }
                ),
                debit_threshold = it.getAmount("max_debt", db.bankCurrency),
                min_cashout = it.getOptAmount("min_cashout", db.bankCurrency),
                is_public = it.getBoolean("is_public"),
                is_taler_exchange = it.getBoolean("is_taler_exchange"),
                payto_uri = it.getBankPayto("internal_payto", "name", db.ctx),
                status = status,
                is_locked = status == AccountStatus.locked
            )
        }
}