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

import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.plugins.*
import kotlinx.serialization.json.Json
import tech.libeufin.bank.*
import tech.libeufin.bank.db.*
import tech.libeufin.bank.db.TanDAO.*
import tech.libeufin.common.*
import tech.libeufin.common.crypto.*
import tech.libeufin.common.api.*
import java.text.DecimalFormat
import java.time.Instant
import java.util.UUID

private suspend fun ApplicationCall.respondChallenges(
    db: Database,
    op: Operation,
    tans: Tans
): List<Challenge> {
    val (hbody, salt) = CryptoUtil.mfaBodyHashCreate(this.rawBody)
    val challenges = mutableListOf<Challenge>()

    for ((channel, info) in tans) {
        val code = Tan.genCode()
        val uuid = db.tan.new(
            hbody = hbody,
            salt = salt,
            username = pathUsername, 
            op = op,
            code = code,
            timestamp = Instant.now(), 
            retryCounter = TAN_RETRY_COUNTER,
            validityPeriod = TAN_VALIDITY_PERIOD,
            tanChannel = channel,
            tanInfo = info
        )
        val privateInfo = if (op == Operation.create_token) {
            "REDACTED"
        } else {
            info
        }
        challenges.add(Challenge(
            challenge_id = uuid.toString(),
            tan_channel = channel,
            tan_info = privateInfo
        ))
    }
    return challenges
}

/**
 * Generate a TAN challenge for an [op] request with [body] and 
 * respond to the HTTP request with a TAN challenge.
 * 
 * If [channel] and [info] are present, they will be used 
 * to send the TAN code, otherwise defaults will be used.
 */
suspend fun ApplicationCall.respondMfa(
    db: Database,
    op: Operation
) {
    val info = this.bankInfo(db)
    var challenges = respondChallenges(db, op, info.mfa)
    respond(
        status = HttpStatusCode.Accepted,
        message = ChallengeResponse(
            challenges = challenges,
            combi_and = false
        )
    )
}

suspend fun ApplicationCall.respondValidation(
    db: Database,
    op: Operation,
    tans: Tans
) {
    val challenges = respondChallenges(db, op, tans)
    respond(
        status = HttpStatusCode.Accepted,
        message = ChallengeResponse(
            challenges = challenges,
            combi_and = true
        )
    )
}

/**
 * Retrieve a confirmed challenge and its body for [op] from the database
 * if the challenge header is defined, otherwise extract the HTTP body.
 */
suspend inline fun <reified B> ApplicationCall.receiveChallenge(
    db: Database,
    op: Operation,
    default: B? = null
): Pair<B, Tans?> {
    // Parse body
    val contentLenght = request.headers[HttpHeaders.ContentLength]?.toIntOrNull()
    val body: B = if (contentLenght == 0 && default != null) {
        default
    } else {
        this.receive()
    }

    // Check if challenges are used
    val ids = request.headers[TALER_CHALLENGE_IDS]
    if (ids == null) return Pair(body, null) 

    // List validated challenges
    val uuids = ids.split(',').map { UUID.fromString(it.trim()) }
    val challenges = db.tan.challenge(uuids)

    val validated = challenges.mapNotNull { challenge ->
        if (challenge.op != op) {
            throw forbidden("Challenge is for a different operation body") 
        } else if (!CryptoUtil.mfaBodyHashCheck(this.rawBody, challenge.hbody, challenge.salt)) {
            throw forbidden("Challenge is for a different request body")
        } else if (challenge.confirmed) {
            Pair(challenge.channel, challenge.info)
        } else {
            null
        }
    }

    if (validated.isEmpty()) return Pair(body, null)  

    // CHeck if challenges are solved
    val bankInfo = this.bankInfo(db)

    // Account reconfig require mfa & new TAN validation
    if (op == Operation.account_reconfig) {
        val req = body as AccountReconfiguration

        val requiredValidation = req.requiredValidation(bankInfo)
        if (requiredValidation.all { validated.contains(it) }) {
            return Pair(body, emptyList())
        } else if (bankInfo.mfa.any { validated.contains(it) }) {
            return Pair(body, requiredValidation)
        } else {
            return Pair(body, null) 
        }
    } else {
        // Check mfa
        if (bankInfo.mfa.any { validated.contains(it) }) {
            return Pair(body, emptyList())
        } else {
            return Pair(body, null) 
        }
    }
}

object Tan {
    private val CODE_FORMAT = DecimalFormat("00000000")

    /** Generate a secure random TAN code */
    fun genCode(): String {
        val rand = SECURE_RNG.get().nextInt(100000000)
        val code = CODE_FORMAT.format(rand)
        return code
    }
}

