import Foundation
import Fluent
import Vapor
import SwiftSMTP
import Clairvoyant

typealias PasswordHash = String
typealias SessionToken = String

private struct MailConfig {

    /// The url to the root of the server
    let serverDomain: String

    /// The number of minutes until a password reset token is no longer valid
    let tokenExpiryDuration: Int

    let smtp: SMTP

    let mailSender: Mail.User
}

private extension Configuration.EMail {

    var mailConfig: MailConfig {
        let smtp = SMTP(
                hostname: emailHostname,
                email: email,
                password: password)
        let mailSender = Mail.User(name: "Schafkopf Server", email: email)

        return .init(
            serverDomain: serverDomain,
            tokenExpiryDuration: tokenExpiryDuration,
            smtp: smtp,
            mailSender: mailSender)
    }
}

final class SQLiteDatabase {

    /// A mapping between player name and generated access tokens for a session
    private var sessionTokenForPlayer = [PlayerName: SessionToken]()

    /// A reverse mapping between generated access tokens and player name
    private var playerNameForToken = [SessionToken: PlayerName]()

    private let tables: TableManagement

    private let mail: MailConfig?

    private let registeredPlayerCountMetric: Metric<Int>

    init(database: Database, mail: Configuration.EMail?) async throws {
        self.registeredPlayerCountMetric = try await Metric(
            "schafkopf.players",
            name: "Number of users",
            description: "The total number of user accounts")
        self.tables = try await TableManagement(database: database)
        self.mail = mail?.mailConfig

        await updateRegisteredPlayerCount(from: database)
    }

    func registerPlayer(named name: PlayerName, hash: PasswordHash, email: String?, in database: Database) async throws -> SessionToken {
        if let email {
            let user = try await User
                .query(on: database)
                .filter(\.$recoveryEmail == email)
                .first()
            if user != nil {
                throw Abort(.conflict)
            }
        }
        if try await User.query(on: database).filter(\.$name == name).first() != nil {
            throw Abort(.conflict)
        }
        let user = User(name: name, hash: hash, email: email)
        try await user.create(on: database)

        // Create a new token and store it for the user
        let token = SessionToken.newToken()
        self.sessionTokenForPlayer[name] = token
        self.playerNameForToken[token] = name

        await updateRegisteredPlayerCount(from: database)
        return token
    }

    func updateRegisteredPlayerCount(from database: Database) async {
        do {
            let count = try await User.query(on: database).count()
            _ = try? await registeredPlayerCountMetric.update(count)
        } catch {
            log("Failed to update player count metric: \(error)")
        }
    }

    /**
     Send a password reset email.

     Possible errors:
     - `417`: Player name or email not found.
     */
    func sendPasswordResetEmailIfPossible(name: PlayerName, request: Request) async throws {
        guard let user = try await User.query(on: request.db).filter(\.$name == name).first() else {
            throw Abort(.expectationFailed)
        }
        guard let email = user.recoveryEmail else {
            throw Abort(.expectationFailed)
        }
        try await user.$resetRequest.load(on: request.db)
        if let reset = user.resetRequest {
            reset.renew()
            try await reset.save(on: request.db)
            self.sendEmail(name: name, email: email, token: reset.resetToken)
        } else {
            let reset = PasswordReset()
            try await user.$resetRequest.create(reset, on: request.db)
            self.sendEmail(name: name, email: email, token: reset.resetToken)
        }
    }

    private func sendEmail(name: PlayerName, email: String, token: String) {
        guard let mailData = mail else {
            log("Recovery email not set up")
            return
        }
        let recipient = Mail.User(name: name, email: email)
        //let mailConfiguration = mailData.mailConfiguration
        let url = "\(mailData.serverDomain)/recovery.html?token=\(token)"
        let mail = Mail(
            from: mailData.mailSender,
            to: [recipient],
            subject: "Schafkopf Server Password Reset",
            text:
                    """
                    Hello \(name),

                    a reset of your account password has been requested for the Schafkopf Server at \(mailData.serverDomain).
                    To choose a new password, click the following link:

                    \(url)

                    The link will expire in \(mailData.tokenExpiryDuration) minutes. If you didn't request the password reset, you don't have to do anything.

                    Regards,

                    The Schafkopf Server Team
                    """
        )

        mailData.smtp.send(mail) { (error) in
            if let error = error {
                log("Failed to send recovery email to \(email): \(error)")
            }
        }
    }

    /**
     Change the password of a user with a recovery token

     Possible errors:
     - `417`: Reset token not found or expired
     */
    func updatePassword(password: String, forResetToken token: String, in database: Database) async throws {
        // 1. Find and validate the reset request
        let reset: PasswordReset? = try await PasswordReset.query(on: database)
            .filter(\.$resetToken == token)
            .first()
        guard let reset else {
            throw Abort(.expectationFailed)
        }
        guard reset.expiryDate.timeIntervalSinceNow > 0 else {
            throw Abort(.expectationFailed)
        }

        // 2. Update the user password
        let user = try await reset.$user.get(on: database)
        user.passwordHash = password
        try await user.save(on: database)

        // 3. Delete the reset request
        try await PasswordReset
            .query(on: database)
            .filter(\.$resetToken == token)
            .delete()
    }

    func passwordHashForExistingPlayer(named name: PlayerName, in database: Database) async throws -> PasswordHash {
        try await User
            .query(on: database)
            .filter(\.$name == name)
            .first()
            .unwrap(or: Abort(.unauthorized))
            .passwordHash
    }

    func deletePlayer(named name: PlayerName, in database: Database) async throws {
        let user = try await user(named: name, in: database)
        try await tables.leaveTable(player: user, in: database)
        try await User.query(on: database).filter(\.$name == name).delete()
        await updateRegisteredPlayerCount(from: database)
    }

    func isValid(sessionToken token: SessionToken) -> Bool {
        playerNameForToken[token] != nil
    }

    func startSession(socket: WebSocket, sessionToken token: SessionToken) -> Bool {
        guard let player = playerNameForToken[token] else {
            return false
        }
        return tables.connect(player: player, using: socket)
    }

    func endSession(forSessionToken sessionToken: SessionToken) {
        guard let player = endExistingSession(forSessionToken: sessionToken) else {
            return
        }
        tables.disconnect(player: player)
    }

    private func endExistingSession(forSessionToken token: SessionToken) -> PlayerName? {
        guard let player = playerNameForToken.removeValue(forKey: token) else {
            return nil
        }
        sessionTokenForPlayer.removeValue(forKey: player)
        return player
    }


    /**
     Start a new session for an existing user.
     - Parameter name: The user name
     - Returns: The generated access token for the session
     */
    func startNewSessionForRegisteredPlayer(named name: PlayerName) -> SessionToken {
        let token = SessionToken.newToken()
        self.sessionTokenForPlayer[name] = token
        self.playerNameForToken[token] = name
        return token
    }

    func registeredPlayerExists(withSessionToken token: SessionToken) -> PlayerName? {
        playerNameForToken[token]
    }

    func currentTableOfPlayer(named player: PlayerName) -> TableInfo? {
        tables.tableInfo(player: player)
    }

    private func points(for player: PlayerName, in database: Database) async throws -> Int {
        try await User.query(on: database)
            .filter(\.$name == player)
            .first()
            .unwrap(or: Abort(.notFound))
            .points
    }

    private func user(named name: PlayerName, in database: Database) async throws -> User {
        try await User
            .query(on: database)
            .filter(\.$name == name)
            .first()
            .unwrap(or: Abort(.notFound))
    }

    private func user(withToken token: SessionToken, in database: Database) async throws -> User {
        let name = try playerNameForToken[token].unwrap(or: Abort(.unauthorized))
        return try await user(named: name, in: database)
    }

    // MARK: Tables

    /**
     Create a new table with optional players.
     - Parameter name: The name of the table
     - Parameter players: The player creating the table
     - Parameter isPublic: Indicates that this is a game joinable by everyone
     - Returns: The table id
     */
    func createTable(named name: TableName, player: PlayerName, isPublic: Bool, in database: Database) async throws -> TableInfo {
        let user = try await user(named: player, in: database)
        return try await tables.createTable(named: name, player: user, isPublic: isPublic, in: database)
    }

    func getPublicTableInfos() -> [PublicTableInfo] {
        tables.publicTableList
    }

    func join(tableId: UUID, playerToken: SessionToken, in database: Database) async throws -> TableInfo {
        let user = try await user(withToken: playerToken, in: database)
        return try await tables.join(tableId: tableId, player: user, in: database)
    }

    func leaveTable(playerToken: SessionToken, in database: Database) async throws {
        let user = try await user(withToken: playerToken, in: database)
        try await tables.leaveTable(player: user, in: database)
    }

    func performAction(playerToken: SessionToken, action: PlayerAction) -> PlayerActionResult {
        guard let player = playerNameForToken[playerToken] else {
            return .invalidToken
        }
        return tables.performAction(player: player, action: action)
    }

    func select(game: GameType, playerToken: SessionToken) -> PlayerActionResult {
        guard let player = playerNameForToken[playerToken] else {
            return .invalidToken
        }
        return tables.select(game: game, player: player)
    }

    func play(card: Card, playerToken: SessionToken, in database: Database) async throws -> PlayerActionResult {
        guard let player = playerNameForToken[playerToken] else {
            return .invalidToken
        }
        return try await tables.play(card: card, player: player, in: database)
    }
    
    func disconnectAllSockets() {
        tables.disconnectAllSockets()
    }
}
