/*
 * This file is part of LibEuFin.
 * Copyright (C) 2024-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 org.slf4j.Logger
import org.slf4j.LoggerFactory
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.util.*
import io.ktor.util.pipeline.*
import tech.libeufin.bank.*
import tech.libeufin.bank.db.AccountDAO.CheckPasswordResult
import tech.libeufin.bank.db.Database
import tech.libeufin.common.*
import tech.libeufin.common.api.intercept
import tech.libeufin.common.crypto.PwCrypto
import java.time.Instant

private val logger: Logger = LoggerFactory.getLogger("libeufin-bank-auth")

/** Used to store if the current request is authenticated */
private val AUTH = AttributeKey<Boolean>("auth")

/** Used to store if the currently authenticated user is admin */
private val AUTH_IS_ADMIN = AttributeKey<Boolean>("is_admin")

/** Used to store used auth token */
private val AUTH_TOKEN = AttributeKey<ByteArray>("auth_token")

/** Used to store auth account info */
val AUTH_INFO = AttributeKey<BankInfo>("auth_info")


const val TOKEN_PREFIX = "secret-token:"

/** Get username of the request account */
val ApplicationCall.pathUsername: String get() = parameters.expect("USERNAME")

/** Check if current request is authenticated */
val ApplicationCall.isAuthenticated: Boolean get() = attributes.getOrNull(AUTH) == true

/** Check if current auth account is admin */
val ApplicationCall.isAdmin: Boolean get() = attributes.getOrNull(AUTH_IS_ADMIN) == true

/** Check auth token used for authentication */
val ApplicationCall.authToken: ByteArray? get() = attributes.getOrNull(AUTH_TOKEN)


/** 
 * Create an admin authenticated route for [scope].
 * 
 * If [enforce], only admin can access this route.
 * 
 * You can check is the currently authenticated user is admin using [isAdmin].
 **/
fun Route.authAdmin(
    db: Database,
    pwCrypto: PwCrypto,
    scope: TokenLogicalScope,
    compatPw: Boolean,
    enforce: Boolean = true,
    callback: Route.() -> Unit
): Route = intercept("AuthAdmin", callback) {
    if (enforce) {
        val info = this.authenticateBankRequest(db, pwCrypto, scope, false, compatPw)
        if (info.username != "admin") {
            throw forbidden("Only administrator allowed")
        }
    } else {
        try {
            this.authenticateBankRequest(db, pwCrypto, scope, false, compatPw)
        } catch (e: Exception) {
            null
        }
    }
}


/** 
 * Create an authenticated route for [scope].
 * 
 * If [allowAdmin], admin is allowed to auth for any user.
 * If [requireAdmin], only admin can access this route.
 * 
 * You can check is the currently authenticated user is admin using [isAdmin].
 **/
fun Route.auth(
    db: Database,
    pwCrypto: PwCrypto,
    scope: TokenLogicalScope,
    compatPw: Boolean,
    allowAdmin: Boolean = false,
    requireAdmin: Boolean = false,
    allowPw: Boolean = false,
    callback: Route.() -> Unit
): Route = intercept("Auth", callback) {
    val info = this.authenticateBankRequest(db, pwCrypto, scope, allowPw, compatPw)
    if (requireAdmin && info.username != "admin") {
        throw forbidden("Only administrator allowed")
    } else {
        val hasRight = info.username == pathUsername || (allowAdmin && info.username == "admin")
        if (!hasRight) {
            throw forbidden("Customer ${info.username} have no right on $pathUsername account")
        }
    }
}

/** 
 * Create an optionally authenticated route for [scope].
 * 
 * You can check is the currently authenticated user is admin using [isAdmin].
 **/
fun Route.optAuth(
    db: Database,
    pwCrypto: PwCrypto,
    scope: TokenLogicalScope,
    compatPw: Boolean,
    allowAdmin: Boolean = false,
    callback: Route.() -> Unit
): Route = intercept("Auth", callback) {
    val header = request.headers[HttpHeaders.Authorization]
    if (header != null) {
        val info = this.authenticateBankRequest(db, pwCrypto, scope, false, compatPw)
        val hasRight = info.username == pathUsername || (allowAdmin && info.username == "admin")
        if (!hasRight) {
            throw forbidden("Customer ${info.username} have no right on $pathUsername account")
        }
    }
}

fun missingAuth(): ApiException = unauthorized(
    "Authorization header not found",
    TalerErrorCode.GENERIC_PARAMETER_MISSING
)

/**
 * Authenticate an HTTP request for [requiredScope] according to the scheme that is mentioned 
 * in the Authorization header.
 * The allowed schemes are either 'Basic' or 'Bearer'.
 *
 * Returns the authenticated customer username.
 */
private suspend fun ApplicationCall.authenticateBankRequest(
    db: Database,
    pwCrypto: PwCrypto,
    requiredScope: TokenLogicalScope,
    allowPw: Boolean,
    compatPw: Boolean
): BankInfo {
    val header = request.headers[HttpHeaders.Authorization]
    
    // Basic auth challenge
    if (header == null) {
        if (allowPw || compatPw) {
            response.header(HttpHeaders.WWWAuthenticate, "Basic realm=\"LibEuFin Bank\", charset=\"UTF-8\"")
        }
        throw missingAuth()
    }

    // Parse header
    val (scheme, content) = header.splitOnce(" ") ?: throw badRequest(
        "Authorization is invalid",
        TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED
    )
    val info = when (scheme) {
        "Basic" -> doBasicAuth(db, content, pwCrypto, allowPw, compatPw)
        "Bearer" -> doTokenAuth(db, content, requiredScope)
        else -> throw unauthorized("Authorization method '$scheme' wrong or not supported")
    }

    this.attributes.put(AUTH, true)
    this.attributes.put(AUTH_IS_ADMIN, info.username == "admin")
    this.attributes.put(AUTH_INFO, info)
    return info
}

/**
 * Performs the HTTP Basic Authentication.
 * 
 * Returns the authenticated customer username
 */
private suspend fun doBasicAuth(
    db: Database,
    encoded: String,
    pwCrypto: PwCrypto,
    allowPw: Boolean,
    compatPw: Boolean
): BankInfo {
    val decoded = String(encoded.decodeBase64(), Charsets.UTF_8)
    val (username, plainPassword) = decoded.splitOnce(":") ?: throw badRequest(
        "Malformed Basic auth credentials found in the Authorization header",
        TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED
    )
    if (!allowPw) {
        logger.warn("User '$username' used deprecated password auth")
        if (!compatPw) {
            throw unauthorized("Authorization method 'Basic' not supported")
        }
    }
    when (val res = db.account.checkPassword(username, plainPassword, pwCrypto)) {
        CheckPasswordResult.UnknownAccount -> throw unauthorized("Unknown account")
        CheckPasswordResult.PasswordMismatch -> throw unauthorized("Bad password")
        CheckPasswordResult.Locked -> throw forbidden("Account is locked", TalerErrorCode.BANK_ACCOUNT_LOCKED)
        is CheckPasswordResult.Success -> return res.info
    }
}

fun validScope(required: TokenLogicalScope, scope: TokenScope): Boolean = when (required) {
    TokenLogicalScope.readonly -> scope in setOf(TokenScope.readonly, TokenScope.readwrite)
    TokenLogicalScope.readwrite -> scope in setOf(TokenScope.readwrite)
    TokenLogicalScope.revenue -> scope in setOf(TokenScope.readonly, TokenScope.readwrite, TokenScope.revenue)
    TokenLogicalScope.readonly_wiregateway -> scope in setOf(TokenScope.wiregateway, TokenScope.readonly, TokenScope.readwrite)
    TokenLogicalScope.readwrite_wiregateway -> scope in setOf(TokenScope.wiregateway, TokenScope.readwrite)
    TokenLogicalScope.observability -> scope in setOf(TokenScope.readonly, TokenScope.readwrite, TokenScope.observability)
    TokenLogicalScope.refreshable -> true
}

/**
 * Performs the secret-token HTTP Bearer Authentication.
 * 
 * Returns the authenticated customer username
 */
private suspend fun ApplicationCall.doTokenAuth(
    db: Database,
    bearer: String,
    requiredScope: TokenLogicalScope,
): BankInfo {
    if (!bearer.startsWith(TOKEN_PREFIX)) throw badRequest(
        "Bearer token malformed",
        TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED
    )
    val decoded = try {
        Base32Crockford.decode(bearer.slice(13..<bearer.length))
    } catch (e: Exception) {
        throw badRequest(
            e.message, TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED
        )
    }
    val (token, info) = db.token.accessInfo(decoded, Instant.now()) ?: throw unauthorized("Unknown token", TalerErrorCode.GENERIC_TOKEN_UNKNOWN)
    when {
        token.expirationTime.isBefore(Instant.now()) 
            -> throw unauthorized("Expired auth token", TalerErrorCode.GENERIC_TOKEN_EXPIRED)

        !validScope(requiredScope, token.scope)
            -> throw forbidden("Auth token has insufficient scope", TalerErrorCode.GENERIC_TOKEN_PERMISSION_INSUFFICIENT)

        !token.isRefreshable && requiredScope == TokenLogicalScope.refreshable 
            -> throw forbidden("Unrefreshable token", TalerErrorCode.GENERIC_TOKEN_PERMISSION_INSUFFICIENT)
    }

    this.attributes.put(AUTH_TOKEN, decoded)
    this.attributes.put(AUTH_INFO, info)

    return info
}