package de.epiceric.shopchest.sql;

import de.epiceric.shopchest.ShopChest;
import de.epiceric.shopchest.config.Config;
import de.epiceric.shopchest.event.ShopBuySellEvent;
import de.epiceric.shopchest.event.ShopBuySellEvent.Type;
import de.epiceric.shopchest.shop.Shop;
import de.epiceric.shopchest.shop.ShopProduct;
import de.epiceric.shopchest.shop.Shop.ShopType;
import de.epiceric.shopchest.utils.Callback;
import de.epiceric.shopchest.utils.Utils;
import org.bukkit.Bukkit;
import org.bukkit.Chunk;
import org.bukkit.Location;
import org.bukkit.OfflinePlayer;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.scheduler.BukkitRunnable;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;

import com.zaxxer.hikari.HikariDataSource;

public abstract class Database {
    private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    private boolean initialized;

    String tableShops;
    String tableLogs;
    String tableLogouts;
    String tableFields;

    ShopChest plugin;
    HikariDataSource dataSource;

    protected Database(ShopChest plugin) {
        this.plugin = plugin;
    }

    abstract HikariDataSource getDataSource();

    abstract String getQueryCreateTableShops();

    abstract String getQueryCreateTableLog();

    abstract String getQueryCreateTableLogout();

    abstract String getQueryCreateTableFields();

    abstract String getQueryGetTable();

    private int getDatabaseVersion() throws SQLException {
        try (Connection con = dataSource.getConnection()) {
            try (Statement s = con.createStatement()) {
                ResultSet rs = s.executeQuery("SELECT value FROM " + tableFields + " WHERE field='version'");
                if (rs.next()) {
                    return rs.getInt("value");
                }
            }
        }
        return 0;
    }

    private void setDatabaseVersion(int version) throws SQLException {
        String queryUpdateVersion = "REPLACE INTO " + tableFields + " VALUES ('version', ?)";
        try (Connection con = dataSource.getConnection()) {
            try (PreparedStatement ps = con.prepareStatement(queryUpdateVersion)) {
                ps.setInt(1, version);
                ps.executeUpdate();
            }
        }
    }

    private boolean update() throws SQLException {
        String queryGetTable = getQueryGetTable();

        try (Connection con = dataSource.getConnection()) {
            boolean needsUpdate1 = false; // update "shop_log" to "economy_logs" and update "shops" with prefixes
            boolean needsUpdate2 = false; // create field table and set database version

            try (PreparedStatement ps = con.prepareStatement(queryGetTable)) {
                ps.setString(1, "shop_log");
                ResultSet rs = ps.executeQuery();
                if (rs.next()) {
                    needsUpdate1 = true;
                }
            }

            try (PreparedStatement ps = con.prepareStatement(queryGetTable)) {
                ps.setString(1, tableFields);
                ResultSet rs = ps.executeQuery();
                if (!rs.next()) {
                    needsUpdate2 = true;
                }
            }

            if (needsUpdate1) {
                String queryRenameTableLogouts = "ALTER TABLE player_logout RENAME TO " + tableLogouts;
                String queryRenameTableLogs = "ALTER TABLE shop_log RENAME TO backup_shop_log"; // for backup
                String queryRenameTableShops = "ALTER TABLE shops RENAME TO backup_shops"; // for backup

                plugin.getLogger().info("Updating database... (#1)");

                // Rename logout table
                try (Statement s = con.createStatement()) {
                    s.executeUpdate(queryRenameTableLogouts);
                }

                // Backup shops table
                try (Statement s = con.createStatement()) {
                    s.executeUpdate(queryRenameTableShops);
                }

                // Backup log table
                try (Statement s = con.createStatement()) {
                    s.executeUpdate(queryRenameTableLogs);
                }

                // Create new shops table
                try (Statement s = con.createStatement()) {
                    s.executeUpdate(getQueryCreateTableShops());
                }

                // Create new log table
                try (Statement s = con.createStatement()) {
                    s.executeUpdate(getQueryCreateTableLog());
                }

                // Convert shop table
                try (Statement s = con.createStatement()) {
                    ResultSet rs = s.executeQuery("SELECT id,product FROM backup_shops");
                    while (rs.next()) {
                        ItemStack is = Utils.decode(rs.getString("product"));
                        int amount = is.getAmount();
                        is.setAmount(1);
                        String product = Utils.encode(is);
                        
                        String insertQuery = "INSERT INTO " + tableShops + " SELECT id,vendor,?,?,world,x,y,z,buyprice,sellprice,shoptype FROM backup_shops WHERE id = ?";
                        try (PreparedStatement ps = con.prepareStatement(insertQuery)) {
                            ps.setString(1, product);
                            ps.setInt(2, amount);
                            ps.setInt(3, rs.getInt("id"));
                            ps.executeUpdate();
                        }
                    }
                }

                // Convert log table
                try (Statement s = con.createStatement()) {
                    ResultSet rs = s.executeQuery("SELECT id,timestamp,executor,product,vendor FROM backup_shop_log");
                    while (rs.next()) {
                        String timestamp = rs.getString("timestamp");
                        long time = 0L;

                        try {
                            time = dateFormat.parse(timestamp).getTime();
                        } catch (ParseException e) {
                            plugin.debug("Failed to parse timestamp '" + timestamp + "': Time is set to 0");
                            plugin.debug(e);
                        }

                        String player = rs.getString("executor");
                        String playerUuid = player.substring(0, 36);
                        String playerName = player.substring(38, player.length() - 1);

                        String oldProduct = rs.getString("product");
                        String product = oldProduct.split(" x ")[1];
                        int amount = Integer.valueOf(oldProduct.split(" x ")[0]);

                        String vendor = rs.getString("vendor");
                        String vendorUuid = vendor.substring(0, 36);
                        String vendorName = vendor.substring(38).replaceAll("\\)( \\(ADMIN\\))?", "");
                        boolean admin = vendor.endsWith("(ADMIN)");
                        
                        String insertQuery = "INSERT INTO " + tableLogs + " SELECT id,-1,timestamp,?,?,?,?,'Unknown',?,?,?,?,world,x,y,z,price,type FROM backup_shop_log WHERE id = ?";
                        try (PreparedStatement ps = con.prepareStatement(insertQuery)) {
                            ps.setLong(1, time);
                            ps.setString(2, playerName);
                            ps.setString(3, playerUuid);
                            ps.setString(4, product);
                            ps.setInt(5, amount);
                            ps.setString(6, vendorName);
                            ps.setString(7, vendorUuid);
                            ps.setBoolean(8, admin);
                            ps.setInt(9, rs.getInt("id"));
                            ps.executeUpdate();
                        }
                    }
                }
            }

            if (needsUpdate2) {
                plugin.getLogger().info("Updating database... (#2)");

                // Create fields table
                try (Statement s = con.createStatement()) {
                    s.executeUpdate(getQueryCreateTableFields());
                }

                setDatabaseVersion(2);
            }
            
            int databaseVersion = getDatabaseVersion();

            if (databaseVersion < 3) {
                // plugin.getLogger().info("Updating database... (#3)");

                // Update database structure...

                // setDatabaseVersion(3);
            }

            int newDatabaseVersion = getDatabaseVersion();
            return needsUpdate1 || needsUpdate2 || newDatabaseVersion > databaseVersion;
        }
    }

    /**
     * <p>(Re-)Connects to the the database and initializes it.</p>
     * 
     * All tables are created if necessary and if the database
     * structure has to be updated, that is done as well.
     * 
     * @param callback Callback that - if succeeded - returns the amount of shops
     *                 that were found (as {@code int})
     */
    public void connect(final Callback<Integer> callback) {
        if (!Config.databaseTablePrefix.matches("^([a-zA-Z0-9\\-\\_]+)?$")) {
            // Only letters, numbers dashes and underscores are allowed
            plugin.getLogger().severe("Database table prefix contains illegal letters, using 'shopchest_' prefix.");
            Config.databaseTablePrefix = "shopchest_";
        }

        this.tableShops = Config.databaseTablePrefix + "shops";
        this.tableLogs = Config.databaseTablePrefix + "economy_logs";
        this.tableLogouts = Config.databaseTablePrefix + "player_logouts";
        this.tableFields = Config.databaseTablePrefix + "fields";

        new BukkitRunnable() {
            @Override
            public void run() {
                disconnect();

                try {
                    dataSource = getDataSource();
                } catch (Exception e) {
                    callback.onError(e);
                    plugin.debug(e);
                    return;
                }

                if (dataSource == null) {
                    Exception e = new IllegalStateException("Data source is null");
                    callback.onError(e);
                    plugin.debug(e);
                    return;
                }

                try (Connection con = dataSource.getConnection()) {
                    // Update database structure if necessary
                    if (update()) {
                        plugin.getLogger().info("Updating database finished");
                    }

                    // Create shop table
                    try (Statement s = con.createStatement()) {
                        s.executeUpdate(getQueryCreateTableShops());
                    }

                    // Create log table
                    try (Statement s = con.createStatement()) {
                        s.executeUpdate(getQueryCreateTableLog());
                    }

                    // Create logout table
                    try (Statement s = con.createStatement()) {
                        s.executeUpdate(getQueryCreateTableLogout());
                    }

                    // Create fields table
                    try (Statement s = con.createStatement()) {
                        s.executeUpdate(getQueryCreateTableFields());
                    }

                    // Clean up economy log
                    if (Config.cleanupEconomyLogDays > 0) {
                        cleanUpEconomy(false);
                    }

                    // Count shops entries in database
                    try (Statement s = con.createStatement()) {
                        ResultSet rs = s.executeQuery("SELECT COUNT(id) FROM " + tableShops);
                        if (rs.next()) {
                            int count = rs.getInt(1);
                            initialized = true;
                            
                            plugin.debug("Initialized database with " + count + " entries");

                            if (callback != null) {
                                callback.callSyncResult(count);
                            }
                        } else {
                            throw new SQLException("Count result set has no entries");
                        }
                    }
                } catch (SQLException e) {
                    if (callback != null) {
                        callback.callSyncError(e);
                    }
                    
                    plugin.getLogger().severe("Failed to initialize or connect to database");
                    plugin.debug("Failed to initialize or connect to database");
                    plugin.debug(e);
                }
            }
        }.runTaskAsynchronously(plugin);
    }

    /**
     * Remove a shop from the database
     *
     * @param shop     Shop to remove
     * @param callback Callback that - if succeeded - returns {@code null}
     */
    public void removeShop(final Shop shop, final Callback<Void> callback) {
        new BukkitRunnable() {
            @Override
            public void run() {
                try (Connection con = dataSource.getConnection();
                        PreparedStatement ps = con.prepareStatement("DELETE FROM " + tableShops + " WHERE id = ?")) {
                    ps.setInt(1, shop.getID());
                    ps.executeUpdate();

                    plugin.debug("Removing shop from database (#" + shop.getID() + ")");

                    if (callback != null) {
                        callback.callSyncResult(null);
                    }
                } catch (SQLException ex) {
                    if (callback != null) {
                        callback.callSyncError(ex);
                    }

                    plugin.getLogger().severe("Failed to remove shop from database");
                    plugin.debug("Failed to remove shop from database (#" + shop.getID() + ")");
                    plugin.debug(ex);
                }
            }
        }.runTaskAsynchronously(plugin);
    }

    /**
     * Get shop amounts for each player
     * 
     * @param callback Callback that returns a map of each player's shop amount
     */
    public void getShopAmounts(final Callback<Map<UUID, Integer>> callback) {
        new BukkitRunnable(){
            @Override
            public void run() {
                try (Connection con = dataSource.getConnection();
                        Statement s = con.createStatement()) {
                    ResultSet rs = s.executeQuery("SELECT vendor, COUNT(*) AS count FROM " + tableShops + " WHERE shoptype = 'NORMAL' GROUP BY vendor");

                    plugin.debug("Getting shop amounts from database");

                    Map<UUID, Integer> result = new HashMap<>();
                    while (rs.next()) {
                        UUID uuid = UUID.fromString(rs.getString("vendor"));
                        result.put(uuid, rs.getInt("count"));
                    }

                    if (callback != null) {
                        callback.callSyncResult(result);
                    }
                } catch (SQLException ex) {
                    if (callback != null) {
                        callback.callSyncError(ex);
                    }

                    plugin.getLogger().severe("Failed to get shop amounts from database");
                    plugin.debug("Failed to get shop amounts from database");
                    plugin.debug(ex);
                }        
            }
        }.runTaskAsynchronously(plugin);
    }

    /**
     * Get all shops from the database that are located in the given chunks
     * 
     * @param chunks Shops in these chunks are retrieved
     * @param callback Callback that returns an immutable collection of shops if succeeded
     */
    public void getShopsInChunks(final Chunk[] chunks, final Callback<Collection<Shop>> callback) {
        // Split chunks into packages containing each {splitSize} chunks at max
        int splitSize = 80;
        int parts = (int) Math.ceil(chunks.length / (double) splitSize);
        Chunk[][] splitChunks = new Chunk[parts][];
        for (int i = 0; i < parts; i++) {
            int size = i < parts - 1 ? splitSize : chunks.length % splitSize;
            Chunk[] tmp = new Chunk[size];
            System.arraycopy(chunks, i * splitSize, tmp, 0, size);
            splitChunks[i] = tmp;
        }

        new BukkitRunnable(){
            @Override
            public void run() {
                List<Shop> shops = new ArrayList<>();

                // Send a request for each chunk package
                for (Chunk[] newChunks : splitChunks) {

                    // Map chunks by world
                    Map<String, Set<Chunk>> chunksByWorld = new HashMap<>();
                    for (Chunk chunk : newChunks) {
                        String world = chunk.getWorld().getName();
                        Set<Chunk> chunksForWorld = chunksByWorld.getOrDefault(world, new HashSet<>());
                        chunksForWorld.add(chunk);
                        chunksByWorld.put(world, chunksForWorld);
                    }
    
                    // Create query dynamically
                    String query = "SELECT * FROM " + tableShops + " WHERE ";
                    for (String world : chunksByWorld.keySet()) {
                        query += "(world = ? AND (";
                        int chunkNum = chunksByWorld.get(world).size();
                        for (int i = 0; i < chunkNum; i++) {
                            query += "((x BETWEEN ? AND ?) AND (z BETWEEN ? AND ?)) OR ";
                        }
                        query += "1=0)) OR ";
                    }
                    query += "1=0";
    
                    try (Connection con = dataSource.getConnection();
                            PreparedStatement ps = con.prepareStatement(query)) {
                        int index = 0;
                        for (String world : chunksByWorld.keySet()) {
                            ps.setString(++index, world);
                            for (Chunk chunk : chunksByWorld.get(world)) {
                                int minX = chunk.getX() * 16;
                                int minZ = chunk.getZ() * 16;
                                ps.setInt(++index, minX);
                                ps.setInt(++index, minX + 15);
                                ps.setInt(++index, minZ);
                                ps.setInt(++index, minZ + 15);
                            }
                        }
    
                        ResultSet rs = ps.executeQuery();
                        while (rs.next()) {
                            int id = rs.getInt("id");
    
                            plugin.debug("Getting Shop... (#" + id + ")");
    
                            int x = rs.getInt("x");
                            int y = rs.getInt("y");
                            int z = rs.getInt("z");
    
                            World world = plugin.getServer().getWorld(rs.getString("world"));
                            Location location = new Location(world, x, y, z);
                            OfflinePlayer vendor = Bukkit.getOfflinePlayer(UUID.fromString(rs.getString("vendor")));
                            ItemStack itemStack = Utils.decode(rs.getString("product"));
                            int amount = rs.getInt("amount");
                            ShopProduct product = new ShopProduct(itemStack, amount);
                            double buyPrice = rs.getDouble("buyprice");
                            double sellPrice = rs.getDouble("sellprice");
                            ShopType shopType = ShopType.valueOf(rs.getString("shoptype"));
    
                            plugin.debug("Initializing new shop... (#" + id + ")");
    
                            shops.add(new Shop(id, plugin, vendor, product, location, buyPrice, sellPrice, shopType));
                        }
                    } catch (SQLException ex) {
                        if (callback != null) {
                            callback.callSyncError(ex);
                        }
    
                        plugin.getLogger().severe("Failed to get shops from database");
                        plugin.debug("Failed to get shops");
                        plugin.debug(ex);

                        return;
                    }
                }
    
                if (callback != null) {
                    callback.callSyncResult(Collections.unmodifiableCollection(shops));
                }
            };
        }.runTaskAsynchronously(plugin);
    }

    /**
     * Adds a shop to the database
     * 
     * @param shop     Shop to add
     * @param callback Callback that - if succeeded - returns the ID the shop was
     *                 given (as {@code int})
     */
    public void addShop(final Shop shop, final Callback<Integer> callback) {
        final String queryNoId = "REPLACE INTO " + tableShops + " (vendor,product,amount,world,x,y,z,buyprice,sellprice,shoptype) VALUES(?,?,?,?,?,?,?,?,?,?)";
        final String queryWithId = "REPLACE INTO " + tableShops + " (id,vendor,product,amount,world,x,y,z,buyprice,sellprice,shoptype) VALUES(?,?,?,?,?,?,?,?,?,?,?)";

        new BukkitRunnable() {
            @Override
            public void run() {
                String query = shop.hasId() ? queryWithId : queryNoId;

                try (Connection con = dataSource.getConnection();
                        PreparedStatement ps = con.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) {
                    int i = 0;
                    if (shop.hasId()) {
                        i = 1;
                        ps.setInt(1, shop.getID());
                    }

                    ps.setString(i+1, shop.getVendor().getUniqueId().toString());
                    ps.setString(i+2, Utils.encode(shop.getProduct().getItemStack()));
                    ps.setInt(i+3, shop.getProduct().getAmount());
                    ps.setString(i+4, shop.getLocation().getWorld().getName());
                    ps.setInt(i+5, shop.getLocation().getBlockX());
                    ps.setInt(i+6, shop.getLocation().getBlockY());
                    ps.setInt(i+7, shop.getLocation().getBlockZ());
                    ps.setDouble(i+8, shop.getBuyPrice());
                    ps.setDouble(i+9, shop.getSellPrice());
                    ps.setString(i+10, shop.getShopType().toString());
                    ps.executeUpdate();

                    if (!shop.hasId()) {
                        int shopId = -1;
                        ResultSet rs = ps.getGeneratedKeys();
                        if (rs.next()) {
                            shopId = rs.getInt(1);
                        }

                        shop.setId(shopId);
                    }

                    if (callback != null) {
                        callback.callSyncResult(shop.getID());
                    }

                    plugin.debug("Adding shop to database (#" + shop.getID() + ")");
                } catch (SQLException ex) {
                    if (callback != null) {
                        callback.callSyncError(ex);
                    }

                    plugin.getLogger().severe("Failed to add shop to database");
                    plugin.debug("Failed to add shop to database (#" + shop.getID() + ")");
                    plugin.debug(ex);
                }
            }
        }.runTaskAsynchronously(plugin);
    }

    /**
     * Log an economy transaction to the database
     * 
     * @param executor Player who bought/sold something
     * @param shop The {@link Shop} the player bought from or sold to
     * @param product The {@link ItemStack} that was bought/sold
     * @param price The price the product was bought or sold for
     * @param type Whether the executor bought or sold
     * @param callback Callback that - if succeeded - returns {@code null}
     */
    public void logEconomy(final Player executor, Shop shop, ShopProduct product, double price, Type type, final Callback<Void> callback) {
        final String query = "INSERT INTO " + tableLogs + " (shop_id,timestamp,time,player_name,player_uuid,product_name,product,amount,"
                + "vendor_name,vendor_uuid,admin,world,x,y,z,price,type) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)";

        if (Config.enableEconomyLog) {
            new BukkitRunnable() {
                @Override
                public void run() {
                    try (Connection con = dataSource.getConnection();
                            PreparedStatement ps = con.prepareStatement(query)) {

                        long millis = System.currentTimeMillis();

                        ps.setInt(1, shop.getID());
                        ps.setString(2, dateFormat.format(millis));
                        ps.setLong(3, millis);
                        ps.setString(4, executor.getName());
                        ps.setString(5, executor.getUniqueId().toString());
                        ps.setString(6, product.getLocalizedName());
                        ps.setString(7, Utils.encode(product.getItemStack()));
                        ps.setInt(8, product.getAmount());
                        ps.setString(9, shop.getVendor().getName());
                        ps.setString(10, shop.getVendor().getUniqueId().toString());
                        ps.setBoolean(11, shop.getShopType() == ShopType.ADMIN);
                        ps.setString(12, shop.getLocation().getWorld().getName());
                        ps.setInt(13, shop.getLocation().getBlockX());
                        ps.setInt(14, shop.getLocation().getBlockY());
                        ps.setInt(15, shop.getLocation().getBlockZ());
                        ps.setDouble(16, price);
                        ps.setString(17, type.toString());
                        ps.executeUpdate();

                        if (callback != null) {
                            callback.callSyncResult(null);
                        }

                        plugin.debug("Logged economy transaction to database");
                    } catch (SQLException ex) {
                        if (callback != null) {
                            callback.callSyncError(ex);
                        }

                        plugin.getLogger().severe("Failed to log economy transaction to database");
                        plugin.debug("Failed to log economy transaction to database");
                        plugin.debug(ex);
                    }
                }
            }.runTaskAsynchronously(plugin);
        } else {
            if (callback != null) {
                callback.callSyncResult(null);
            }
        }
    }

    /**
     * Cleans up the economy log to reduce file size
     * 
     * @param async Whether the call should be executed asynchronously
     */
    public void cleanUpEconomy(boolean async) {
        BukkitRunnable runnable = new BukkitRunnable() {
            @Override
            public void run() {
                long time = System.currentTimeMillis() - Config.cleanupEconomyLogDays * 86400000L;
                String queryCleanUpLog = "DELETE FROM " + tableLogs + " WHERE time < " + time;
                String queryCleanUpPlayers = "DELETE FROM " + tableLogouts + " WHERE time < " + time;

                try (Connection con = dataSource.getConnection();
                        Statement s = con.createStatement();
                        Statement s2 = con.createStatement()) {
                    s.executeUpdate(queryCleanUpLog);
                    s2.executeUpdate(queryCleanUpPlayers);

                    plugin.getLogger().info("Cleaned up economy log");
                    plugin.debug("Cleaned up economy log");
                } catch (SQLException ex) {
                    plugin.getLogger().severe("Failed to clean up economy log");
                    plugin.debug("Failed to clean up economy log");
                    plugin.debug(ex);
                }
            }
        };

        if (async) {
            runnable.runTaskAsynchronously(plugin);
        } else {
            runnable.run();
        }
    }

    /**
     * Get the revenue a player got while he was offline
     * 
     * @param player     Player whose revenue to get
     * @param logoutTime Time in milliseconds when he logged out the last time
     * @param callback   Callback that - if succeeded - returns the revenue the
     *                   player made while offline (as {@code double})
     */
    public void getRevenue(final Player player, final long logoutTime, final Callback<Double> callback) {
        new BukkitRunnable() {
            @Override
            public void run() {
                double revenue = 0;

                try (Connection con = dataSource.getConnection();
                        PreparedStatement ps = con.prepareStatement("SELECT * FROM " + tableLogs + " WHERE vendor_uuid = ?")) {
                    ps.setString(1, player.getUniqueId().toString());
                    ResultSet rs = ps.executeQuery();

                    while (rs.next()) {
                        long timestamp = rs.getLong("time");
                        double singleRevenue = rs.getDouble("price");
                        ShopBuySellEvent.Type type = ShopBuySellEvent.Type.valueOf(rs.getString("type"));

                        if (type == ShopBuySellEvent.Type.SELL) {
                            singleRevenue = -singleRevenue;
                        }

                        if (timestamp > logoutTime) {
                            revenue += singleRevenue;
                        }
                    }

                    if (callback != null) {
                        callback.callSyncResult(revenue);
                    }
                } catch (SQLException ex) {
                    if (callback != null) {
                        callback.callSyncError(ex);
                    }

                    plugin.getLogger().severe("Failed to get revenue from database");
                    plugin.debug("Failed to get revenue from player \"" + player.getUniqueId().toString() + "\"");
                    plugin.debug(ex);
                }
            }
        }.runTaskAsynchronously(plugin);
    }

    /**
     * Log a logout to the database
     * 
     * @param player    Player who logged out
     * @param callback  Callback that - if succeeded - returns {@code null}
     */
    public void logLogout(final Player player, final Callback<Void> callback) {
        new BukkitRunnable() {
            @Override
            public void run() {
                try (Connection con = dataSource.getConnection();
                        PreparedStatement ps = con.prepareStatement("REPLACE INTO " + tableLogouts + " (player,time) VALUES(?,?)")) {
                    ps.setString(1, player.getUniqueId().toString());
                    ps.setLong(2, System.currentTimeMillis());
                    ps.executeUpdate();

                    if (callback != null) {
                        callback.callSyncResult(null);
                    }

                    plugin.debug("Logged logout to database");
                } catch (final SQLException ex) {
                    if (callback != null) {
                        callback.callSyncError(ex);
                    }

                    plugin.getLogger().severe("Failed to log last logout to database");
                    plugin.debug("Failed to log logout to database");
                    plugin.debug(ex);
                }
            }
        }.runTaskAsynchronously(plugin);
    }

    /**
     * Get the last logout of a player
     * 
     * @param player   Player who logged out
     * @param callback Callback that - if succeeded - returns the time in
     *                 milliseconds the player logged out (as {@code long})
     *                 or {@code -1} if the player has not logged out yet.
     */
    public void getLastLogout(final Player player, final Callback<Long> callback) {
        new BukkitRunnable() {
            @Override
            public void run() {
                try (Connection con = dataSource.getConnection();
                        PreparedStatement ps = con.prepareStatement("SELECT * FROM " + tableLogouts + " WHERE player = ?")) {
                    ps.setString(1, player.getUniqueId().toString());
                    ResultSet rs = ps.executeQuery();

                    if (rs.next()) {
                        if (callback != null) {
                            callback.callSyncResult(rs.getLong("time"));
                        }
                    }

                    if (callback != null) {
                        callback.callSyncResult(-1L);
                    }
                } catch (SQLException ex) {
                    if (callback != null) {
                        callback.callSyncError(ex);
                    }

                    plugin.getLogger().severe("Failed to get last logout from database");
                    plugin.debug("Failed to get last logout from player \"" + player.getName() + "\"");
                    plugin.debug(ex);
                }
            }
        }.runTaskAsynchronously(plugin);
    }

    /**
     * Closes the data source
     */
    public void disconnect() {
        if (dataSource != null) {
            dataSource.close();
            dataSource = null;
        }
    }

    /**
     * Returns whether a connection to the database has been established
     */
    public boolean isInitialized() {
        return initialized;
    }

    public enum DatabaseType {
        SQLite, MySQL
    }
}