/*
 *   This file is a part of the SteamWar software.
 *
 *   Copyright (C) 2025 SteamWar.de-Serverteam
 *
 *   This program 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 of the License, or
 *   (at your option) any later version.
 *
 *   This program 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 this program.  If not, see <https://www.gnu.org/licenses/>.
 */

package de.steamwar.sql

import de.steamwar.sql.internal.useDb
import org.jetbrains.exposed.v1.core.JoinType
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.dao.IntEntity
import org.jetbrains.exposed.v1.dao.IntEntityClass
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.select
import java.security.SecureRandom
import java.sql.Timestamp
import java.util.*
import java.util.function.Consumer
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec

object SteamwarUserTable : IntIdTable("UserData", "id") {
    val uuid = varchar("UUID", 36).uniqueIndex()
    val username = varchar("UserName", 32).index()
    val team = reference("Team", TeamTable).index()
    val leader = bool("Leader")
    val locale = varchar("Locale", 16).nullable()
    val manualLocale = bool("ManualLocale")
    val bedrock = bool("Bedrock")
    val password = text("Password").nullable()
    val discordId = long("DiscordId").nullable().uniqueIndex()
}

class SteamwarUser(id: EntityID<Int>) : IntEntity(id) {
    companion object : IntEntityClass<SteamwarUser>(SteamwarUserTable) {
        private val random = SecureRandom()
        private val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA512")

        private val byId = mutableMapOf<Int, SteamwarUser>()
        private val byUUID = mutableMapOf<UUID, SteamwarUser>()
        private val byDiscordId = mutableMapOf<Long, SteamwarUser>()
        private val byUsername = mutableMapOf<String, SteamwarUser>()

        @JvmStatic
        fun clear() {
            byId.clear()
            byUUID.clear()
            byDiscordId.clear()
            byUsername.clear()
        }

        @JvmStatic
        fun invalidate(userId: Int) {
            val user = byId.remove(userId)
            if (user != null) {
                byUUID.remove(user.getUUID())
                byDiscordId.remove(user.discordId)
                byUsername.remove(user.userName)
            }
        }

        @JvmStatic
        fun byId(id: Int) =
            byId[id]
                ?: useDb { findById(id)?.also { cache(it) } }

        @JvmStatic
        fun get(uuid: UUID) =
            byUUID[uuid]
                ?: useDb { find { SteamwarUserTable.uuid eq uuid.toString() }.firstOrNull()?.also { cache(it) } }

        @JvmStatic
        fun get(discordId: Long) =
            byDiscordId[discordId]
                ?: useDb { find { SteamwarUserTable.discordId eq discordId }.firstOrNull()?.also { cache(it) } }

        @JvmStatic
        fun get(username: String) =
            byUsername[username]
                ?: useDb { find { SteamwarUserTable.username eq username }.firstOrNull()?.also { cache(it) } }

        private fun cache(user: SteamwarUser) {
            byId[user.getId()] = user
            byUUID[user.getUUID()] = user
            user.discordId?.let { byDiscordId[it] = user }
            byUsername[user.userName] = user
        }

        @JvmStatic
        fun getOrCreate(uuid: UUID, name: String, newPlayer: Consumer<UUID>): SteamwarUser {
            val user = get(uuid)

            return if (user != null) {
                if (user.userName != name) {
                    useDb {
                        user.userName = name
                    }
                }

                user
            } else {
                useDb {
                    SteamwarUserTable.insert {
                        it[SteamwarUserTable.uuid] = uuid.toString()
                        it[username] = name
                    }
                }

                newPlayer.accept(uuid)
                get(uuid)
                    ?: error("User $uuid not found after creation!")
            }
        }

        @JvmStatic
        fun getUsersWithPerm(perm: UserPerm) =
            useDb {
                UserPermTable.join(SteamwarUserTable, JoinType.INNER, UserPermTable.user, SteamwarUserTable.id)
                    .select(SteamwarUserTable.fields).where { UserPermTable.perm eq perm }.map { wrapRow(it) }
            }

        @JvmStatic
        fun getServerTeam() =
            useDb {
                listOf(
                    getUsersWithPerm(UserPerm.PREFIX_ADMIN),
                    getUsersWithPerm(UserPerm.PREFIX_DEVELOPER),
                    getUsersWithPerm(UserPerm.PREFIX_MODERATOR),
                    getUsersWithPerm(UserPerm.PREFIX_SUPPORTER),
                    getUsersWithPerm(UserPerm.PREFIX_BUILDER),
                ).flatten()
            }

        @JvmStatic
        fun getTeam(teamId: Int) =
            useDb {
                find { SteamwarUserTable.team eq teamId }.toList()
            }

        @JvmStatic
        fun batchCache(ids: MutableSet<Int>) {
            ids.removeIf(byId::containsKey)
            if (ids.isEmpty()) return

            useDb {
                find { SteamwarUserTable.id inList ids }.forEach { cache(it) }
            }
        }
    }

    val uuid: UUID by SteamwarUserTable.uuid.transform({ it.toString() }, { UUID.fromString(it) })
    var userName by SteamwarUserTable.username
    private var teamInternal by SteamwarUserTable.team
    var team: Int
        get() = teamInternal.value
        set(value) = useDb {
            teamInternal = EntityID(value, TeamTable)
            leaderInternal = false
        }

    private var leaderInternal by SteamwarUserTable.leader
    var leader: Boolean
        get() = leaderInternal
        set(value) = useDb {
            leaderInternal = value
        }

    fun isLeader() =
        leader

    var locale: Locale by SteamwarUserTable.locale
        .transform({ it.toLanguageTag() }, {
            it?.let { Locale.forLanguageTag(it) }
                ?: Locale.ENGLISH
        })
    var manualLocale by SteamwarUserTable.manualLocale
    var bedrock by SteamwarUserTable.bedrock
    private var passwordInternal by SteamwarUserTable.password
    var password: String?
        get() = passwordInternal
        set(value) {
            if (value == null) {
                useDb {
                    passwordInternal = null
                }
                return
            }

            val salt = ByteArray(16)
            random.nextBytes(salt)
            val saltString = Base64.getEncoder().encodeToString(salt)

            val hash = generateHash(value, salt)
            val hashString = Base64.getEncoder().encodeToString(hash)

            useDb {
                passwordInternal = "$hashString:$saltString"
            }
        }

    var discordId by SteamwarUserTable.discordId

    private val punishments by lazy { Punishment.getPunishmentsOfPlayer(id.value) }
    private val perms by lazy { UserPerm.getPerms(id.value) }
    private val prefix by lazy {
        perms.firstOrNull { UserPerm.prefixes.containsKey(it) }?.let { UserPerm.prefixes[it] }
            ?: UserPerm.emptyPrefix
    }

    fun getUUID(): UUID =
        uuid

    fun getId() =
        id.value

    fun getPunishment(punishment: Punishment.PunishmentType) =
        punishments[punishment]

    fun isPunished(punishment: Punishment.PunishmentType) =
        getPunishment(punishment)
            ?.let {
                if (!it.isCurrent()) {
                    if (punishment == Punishment.PunishmentType.Ban) {
                        BannedUserIPs.unbanIPs(id.value)
                    }
                    punishments.remove(punishment)
                    return@let false
                }

                return@let true
            }
            ?: false

    fun hasPerm(perm: UserPerm) =
        perms.contains(perm)

    fun perms() =
        perms

    fun prefix() =
        prefix

    fun getOnlinetime() =
        useDb {
            exec("SELECT SUM(UNIX_TIMESTAMP(EndTime) - UNIX_TIMESTAMP(StartTime)) as Playtime FROM Session WHERE UserID = ${this@SteamwarUser.id.value}") { rs ->
                return@exec if (rs.next()) {
                    rs.getBigDecimal("Playtime").toDouble()
                } else {
                    0.0
                }
            }
                ?: 0.0
        }

    fun getFirstjoin() =
        useDb {
            exec("SELECT MIN(StartTime) AS FirstJoin FROM Session WHERE UserID = ${this@SteamwarUser.id.value}") { rs ->
                return@exec if (rs.next()) {
                    rs.getTimestamp("FirstJoin")
                } else {
                    null
                }
            }
        }

    fun getLastOnline() =
        useDb {
            exec("SELECT MAX(EndTime) AS LastOnline FROM Session WHERE UserID = ${this@SteamwarUser.id.value}") { rs ->
                return@exec if (rs.next()) {
                    rs.getTimestamp("LastOnline")
                } else {
                    null
                }
            }
        }

    fun punish(punishment: Punishment.PunishmentType, time: Timestamp, reason: String, from: Int, perma: Boolean) =
        useDb {
            punishments.remove(punishment)
            punishments[punishment] = Punishment.createPunishment(this@SteamwarUser.id.value, from, punishment, reason, time, perma)
        }

    fun setLocale(locale: Locale?, manualeLocale: Boolean) {
        if (locale == null || (this.manualLocale && !manualLocale)) return

        useDb {
            this@SteamwarUser.locale = locale
            this@SteamwarUser.manualLocale = manualeLocale
        }
    }

    fun setDiscordId(discordId: Long) {
        byDiscordId.remove(this.discordId)
        useDb {
            this@SteamwarUser.discordId = discordId
        }
        byDiscordId[discordId] = this
    }

    fun verifyPassword(password: String): Boolean {
        if (!hasPassword()) return false

        val (hashString, saltString) = this.password!!.split(':')

        val hash = Base64.getDecoder().decode(hashString)
        val salt = Base64.getDecoder().decode(saltString)

        return hash.contentEquals(generateHash(password, salt))
    }

    fun hasPassword() =
        password != null

    fun generateHash(password: String, salt: ByteArray): ByteArray =
        PBEKeySpec(password.toCharArray(), salt, 65536, 512).let { factory.generateSecret(it).encoded }
}