package com.cjburkey.claimchunk.data.newdata;

import com.cjburkey.claimchunk.ClaimChunk;
import com.cjburkey.claimchunk.Utils;
import com.cjburkey.claimchunk.chunk.ChunkPos;
import com.cjburkey.claimchunk.chunk.DataChunk;
import com.cjburkey.claimchunk.data.conversion.IDataConverter;
import com.cjburkey.claimchunk.player.FullPlayerData;
import com.cjburkey.claimchunk.player.SimplePlayerData;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Supplier;
import javax.annotation.Nullable;

import static com.cjburkey.claimchunk.data.newdata.SqlBacking.*;

/**
 * Uses per-request database access system to save/load data so many requests
 * are made when they are needed. This is only a good system if connecting to
 * the database is very fast. Otherwise, the bulk system is much much faster
 * and won't result in constant server lag.
 *
 * @param <T> The type of the backup data system.
 * @since 0.0.13
 */
public class MySQLDataHandler<T extends IClaimChunkDataHandler> implements IClaimChunkDataHandler {

    static final String CLAIMED_CHUNKS_TABLE_NAME = "claimed_chunks";
    static final String PLAYERS_TABLE_NAME = "joined_players";
    private static final String CLAIMED_CHUNKS_ID = "id";
    private static final String CLAIMED_CHUNKS_WORLD = "world_name";
    private static final String CLAIMED_CHUNKS_X = "chunk_x_pos";
    private static final String CLAIMED_CHUNKS_Z = "chunk_z_pos";
    private static final String CLAIMED_CHUNKS_TNT = "tnt_enabled";
    private static final String CLAIMED_CHUNKS_OWNER = "owner_uuid";
    private static final String PLAYERS_UUID = "uuid";
    private static final String PLAYERS_IGN = "last_in_game_name";
    private static final String PLAYERS_NAME = "chunk_name";
    private static final String PLAYERS_LAST_JOIN = "last_join_time_ms";
    private static final String PLAYERS_ALERT = "receive_alerts";

    private static final String ACCESS_TABLE_NAME = "access_granted";
    private static final String ACCESS_ACCESS_ID = "access_id";
    private static final String ACCESS_CHUNK_ID = "chunk_id";
    private static final String ACCESS_OWNER = "owner_uuid";
    private static final String ACCESS_OTHER = "other_uuid";

    private final ClaimChunk claimChunk;
    Supplier<Connection> connection;
    private String dbName;
    private T oldDataHandler;
    private Consumer<T> onCleanOld;
    private boolean init;

    public MySQLDataHandler(ClaimChunk claimChunk, Supplier<T> oldDataHandler, Consumer<T> onCleanOld) {
        this.claimChunk = claimChunk;
        if (oldDataHandler != null) {
            this.oldDataHandler = oldDataHandler.get();
            this.onCleanOld = onCleanOld;
        }
    }

    @Override
    public void init() throws Exception {
        init = true;

        // Initialize a connection to the specified MySQL database
        dbName = claimChunk.chConfig().getString("database", "database");
        connection = connect(claimChunk.chConfig().getString("database", "hostname"),
                claimChunk.chConfig().getInt("database", "port"),
                dbName,
                claimChunk.chConfig().getString("database", "username"),
                claimChunk.chConfig().getString("database", "password"));

        // Initialize the tables if they don't yet exist
        if (getTableDoesntExist(claimChunk, connection, dbName, CLAIMED_CHUNKS_TABLE_NAME)) {
            Utils.debug("Creating claimed chunks table");
            createClaimedChunksTable();
        } else {
            migrateClaimedChunksTable0015_0016();
            Utils.debug("Found claimed chunks table");
        }
        if (getTableDoesntExist(claimChunk, connection, dbName, PLAYERS_TABLE_NAME)) {
            Utils.debug("Creating joined players table");
            createJoinedPlayersTable();
        } else {
            Utils.debug("Found joined players table");
        }
        if (getTableDoesntExist(claimChunk, connection, dbName, ACCESS_TABLE_NAME)) {
            Utils.debug("Creating access table");
            createAccessTable();
        } else {
            migrateAccessTable0015_0016();
            Utils.debug("Found access table");
        }

        if (oldDataHandler != null && claimChunk.chConfig().getBool("database", "convertOldData")) {
            IDataConverter.copyConvert(oldDataHandler, this);
            oldDataHandler.exit();
            if (onCleanOld != null) {
                onCleanOld.accept(oldDataHandler);
            }
        }
    }

    @Override
    public boolean getHasInit() {
        return init;
    }

    @Override
    public void exit() throws SQLException {
        // No closing necessary
    }

    @Override
    public void save() throws Exception {
        // No saving necessary
    }

    @Override
    public void load() {
        // No loading necessary
    }

    @Override
    public void addClaimedChunk(ChunkPos pos, UUID player) {
        String sql = String.format("INSERT INTO `%s` (`%s`, `%s`, `%s`, `%s`) VALUES (?, ?, ?, ?)",
                CLAIMED_CHUNKS_TABLE_NAME, CLAIMED_CHUNKS_WORLD, CLAIMED_CHUNKS_X, CLAIMED_CHUNKS_Z, CLAIMED_CHUNKS_OWNER);
        try (PreparedStatement statement = prep(claimChunk, connection, sql)) {
            statement.setString(1, pos.getWorld());
            statement.setInt(2, pos.getX());
            statement.setInt(3, pos.getZ());
            statement.setString(4, player.toString());
            statement.execute();
        } catch (Exception e) {
            Utils.err("Failed to claim chunk: %s", e.getMessage());
            e.printStackTrace();
        }
    }

    @Override
    public void addClaimedChunks(DataChunk[] chunks) {
        if (chunks.length == 0) return;

        StringBuilder sql = new StringBuilder(String.format("INSERT INTO `%s` (`%s`, `%s`, `%s`, `%s`) VALUES",
                CLAIMED_CHUNKS_TABLE_NAME, CLAIMED_CHUNKS_WORLD, CLAIMED_CHUNKS_X, CLAIMED_CHUNKS_Z, CLAIMED_CHUNKS_OWNER));
        for (int i = 0; i < chunks.length; i++) {
            sql.append(" (?, ?, ?, ?)");
            if (i != chunks.length - 1) sql.append(',');
        }
        try (PreparedStatement statement = prep(claimChunk, connection, sql.toString())) {
            int i = 0;
            for (DataChunk chunk : chunks) {
                statement.setString(4 * i + 1, chunk.chunk.getWorld());
                statement.setInt(4 * i + 2, chunk.chunk.getX());
                statement.setInt(4 * i + 3, chunk.chunk.getZ());
                statement.setString(4 * i + 4, chunk.player.toString());
                i++;
            }
            statement.execute();
        } catch (Exception e) {
            Utils.err("Failed add claimed chunks: %s", e.getMessage());
            e.printStackTrace();
        }
    }

    @Override
    public void removeClaimedChunk(ChunkPos pos) {
        String sql = String.format("DELETE FROM `%s` WHERE `%s`=? AND `%s`=? AND `%s`=?",
                CLAIMED_CHUNKS_TABLE_NAME, CLAIMED_CHUNKS_WORLD, CLAIMED_CHUNKS_X, CLAIMED_CHUNKS_Z);
        try (PreparedStatement statement = prep(claimChunk, connection, sql)) {
            statement.setString(1, pos.getWorld());
            statement.setInt(2, pos.getX());
            statement.setInt(3, pos.getZ());
            statement.execute();
        } catch (Exception e) {
            Utils.err("Failed to unclaim chunk: %s", e.getMessage());
            e.printStackTrace();
        }
    }

    @Override
    public boolean isChunkClaimed(ChunkPos pos) {
        String sql = String.format("SELECT count(*) FROM `%s` WHERE `%s`=? AND `%s`=? AND `%s`=?",
                CLAIMED_CHUNKS_TABLE_NAME, CLAIMED_CHUNKS_WORLD, CLAIMED_CHUNKS_X, CLAIMED_CHUNKS_Z);
        try (PreparedStatement statement = prep(claimChunk, connection, sql)) {
            statement.setString(1, pos.getWorld());
            statement.setInt(2, pos.getX());
            statement.setInt(3, pos.getZ());
            try (ResultSet result = statement.executeQuery()) {
                if (result.next()) return result.getInt(1) > 0;
            }
        } catch (Exception e) {
            Utils.err("Failed to determine if chunk was claimed: %s", e.getMessage());
            e.printStackTrace();
        }
        return false;
    }

    @Override
    @Nullable
    public UUID getChunkOwner(ChunkPos pos) {
        String sql = String.format("SELECT `%s` FROM `%s` WHERE `%s`=? AND `%s`=? AND `%s`=? LIMIT 1",
                CLAIMED_CHUNKS_OWNER, CLAIMED_CHUNKS_TABLE_NAME, CLAIMED_CHUNKS_WORLD, CLAIMED_CHUNKS_X, CLAIMED_CHUNKS_Z);
        try (PreparedStatement statement = prep(claimChunk, connection, sql)) {
            statement.setString(1, pos.getWorld());
            statement.setInt(2, pos.getX());
            statement.setInt(3, pos.getZ());
            try (ResultSet result = statement.executeQuery()) {
                if (result.next()) return UUID.fromString(result.getString(1));
            }
        } catch (Exception e) {
            Utils.err("Failed to retrieve chunk owner: %s", e.getMessage());
            e.printStackTrace();
        }
        return null;
    }

    @Override
    public DataChunk[] getClaimedChunks() {
        String sql = String.format("SELECT `%s`, `%s`, `%s`, `%s`, `%s` FROM `%s`",
                CLAIMED_CHUNKS_WORLD,
                CLAIMED_CHUNKS_X,
                CLAIMED_CHUNKS_Z,
                CLAIMED_CHUNKS_TNT,
                CLAIMED_CHUNKS_OWNER,
                CLAIMED_CHUNKS_TABLE_NAME);
        List<DataChunk> chunks = new ArrayList<>();
        try (PreparedStatement statement = prep(claimChunk, connection, sql); ResultSet result = statement.executeQuery()) {
            while (result.next()) {
                chunks.add(new DataChunk(
                        new ChunkPos(result.getString(1), result.getInt(2), result.getInt(3)),
                        UUID.fromString(result.getString(5)),
                        result.getBoolean(4)
                ));
            }
        } catch (Exception e) {
            Utils.err("Failed to get all claimed chunks: %s", e.getMessage());
            e.printStackTrace();
        }
        return chunks.toArray(new DataChunk[0]);
    }

    @Override
    public boolean toggleTnt(ChunkPos pos) {
        boolean current = isTntEnabled(pos);
        String sql = String.format("UPDATE `%s` SET `%s`=? WHERE (`%s`=?) AND (`%s`=?) AND (`%s`=?)",
                CLAIMED_CHUNKS_TABLE_NAME, CLAIMED_CHUNKS_TNT, CLAIMED_CHUNKS_WORLD, CLAIMED_CHUNKS_X, CLAIMED_CHUNKS_Z);
        try (PreparedStatement statement = prep(claimChunk, connection, sql)) {
            statement.setBoolean(1, !current);
            statement.setString(2, pos.getWorld());
            statement.setInt(3, pos.getX());
            statement.setInt(4, pos.getZ());
            statement.execute();
            return !current;
        } catch (Exception e) {
            Utils.err("Failed to update tnt enabled in chunk: %s", e.getMessage());
            e.printStackTrace();
        }
        return current;
    }

    @Override
    public boolean isTntEnabled(ChunkPos pos) {
        String sql = String.format("SELECT `%s` FROM `%s` WHERE (`%s`=?) AND (`%s`=?) AND (`%s`=?)",
                CLAIMED_CHUNKS_TNT, CLAIMED_CHUNKS_TABLE_NAME, CLAIMED_CHUNKS_WORLD, CLAIMED_CHUNKS_X, CLAIMED_CHUNKS_Z);
        try (PreparedStatement statement = prep(claimChunk, connection, sql)) {
            statement.setString(1, pos.getWorld());
            statement.setInt(2, pos.getX());
            statement.setInt(3, pos.getZ());
            try (ResultSet result = statement.executeQuery()) {
                if (result.next()) return result.getBoolean(1);
            }
        } catch (Exception e) {
            Utils.err("Failed to retrieve tnt enabled in chunk: %s", e.getMessage());
            e.printStackTrace();
        }
        return false;
    }

    @Override
    public void addPlayer(UUID player,
                          String lastIgn,
                          Set<UUID> permitted,
                          @Nullable String chunkName,
                          long lastOnlineTime,
                          boolean alerts) {
        String sql = String.format("INSERT INTO `%s` (`%s`, `%s`, `%s`, `%s`, `%s`) VALUES (?, ?, ?, ?, ?)",
                PLAYERS_TABLE_NAME, PLAYERS_UUID, PLAYERS_IGN, PLAYERS_NAME, PLAYERS_LAST_JOIN, PLAYERS_ALERT);
        try (PreparedStatement statement = prep(claimChunk, connection, sql)) {
            statement.setString(1, player.toString());
            statement.setString(2, lastIgn);
            statement.setString(3, chunkName);
            statement.setLong(4, lastOnlineTime);
            statement.setBoolean(5, alerts);
            statement.execute();
        } catch (Exception e) {
            Utils.err("Failed to add player: %s", e.getMessage());
            e.printStackTrace();
        }

        // Create the access associations separately
        givePlayersAcess(player, permitted.toArray(new UUID[0]));
    }

    @Override
    public void addPlayers(FullPlayerData[] players) {
        if (players.length == 0) return;

        StringBuilder sql = new StringBuilder(String.format("INSERT INTO `%s` (`%s`, `%s`, `%s`, `%s`, `%s`) VALUES",
                PLAYERS_TABLE_NAME, PLAYERS_UUID, PLAYERS_IGN, PLAYERS_NAME, PLAYERS_LAST_JOIN, PLAYERS_ALERT));
        for (int i = 0; i < players.length; i++) {
            givePlayersAcess(players[i].player, players[i].permitted.toArray(new UUID[0]));
            sql.append(" (?, ?, ?, ?, ?)");
            if (i != players.length - 1) sql.append(',');
        }
        try (PreparedStatement statement = prep(claimChunk, connection, sql.toString())) {
            int i = 0;
            for (FullPlayerData player : players) {
                statement.setString(5 * i + 1, player.player.toString());
                statement.setString(5 * i + 2, player.lastIgn);
                statement.setString(5 * i + 3, player.chunkName);
                statement.setLong(5 * i + 4, player.lastOnlineTime);
                statement.setBoolean(5 * i + 5, player.alert);
                i++;
            }
            statement.execute();
        } catch (Exception e) {
            Utils.err("Failed to add joined players: %s", e.getMessage());
            e.printStackTrace();
        }
    }

    @Override
    @Nullable
    public String getPlayerUsername(UUID player) {
        String sql = String.format("SELECT `%s` FROM `%s` WHERE `%s`=?",
                PLAYERS_IGN, PLAYERS_TABLE_NAME, PLAYERS_UUID);
        try (PreparedStatement statement = prep(claimChunk, connection, sql)) {
            statement.setString(1, player.toString());
            try (ResultSet result = statement.executeQuery()) {
                if (result.next()) return result.getString(1);
            }
        } catch (Exception e) {
            Utils.err("Failed to retrieve player username: %s", e.getMessage());
            e.printStackTrace();
        }
        return null;
    }

    @Override
    @Nullable
    public UUID getPlayerUUID(String username) {
        String sql = String.format("SELECT `%s` FROM `%s` WHERE `%s`=?",
                PLAYERS_UUID, PLAYERS_TABLE_NAME, PLAYERS_IGN);
        try (PreparedStatement statement = prep(claimChunk, connection, sql)) {
            statement.setString(1, username);
            try (ResultSet result = statement.executeQuery()) {
                if (result.next()) return UUID.fromString(result.getString(1));
            }
        } catch (Exception e) {
            Utils.err("Failed to retrieve player username UUID: %s", e.getMessage());
            e.printStackTrace();
        }
        return null;
    }

    @Override
    public void setPlayerLastOnline(UUID player, long time) {
        String sql = String.format("UPDATE `%s` SET `%s`=? WHERE `%s`=?",
                PLAYERS_TABLE_NAME, PLAYERS_LAST_JOIN, PLAYERS_UUID);
        try (PreparedStatement statement = prep(claimChunk, connection, sql)) {
            statement.setLong(1, time);
            statement.setString(2, player.toString());
            statement.execute();
        } catch (Exception e) {
            Utils.err("Failed update player last online time: %s", e.getMessage());
            e.printStackTrace();
        }
    }

    @Override
    public void setPlayerChunkName(UUID player, @Nullable String name) {
        String sql = String.format("UPDATE `%s` SET `%s`=? WHERE `%s`=?",
                PLAYERS_TABLE_NAME, PLAYERS_NAME, PLAYERS_UUID);
        try (PreparedStatement statement = prep(claimChunk, connection, sql)) {
            statement.setString(1, name);
            statement.setString(2, player.toString());
            statement.execute();
        } catch (Exception e) {
            Utils.err("Failed update player chunk name: %s", e.getMessage());
            e.printStackTrace();
        }
    }

    @Override
    @Nullable
    public String getPlayerChunkName(UUID player) {
        String sql = String.format("SELECT `%s` FROM `%s` WHERE `%s`=?",
                PLAYERS_NAME, PLAYERS_TABLE_NAME, PLAYERS_UUID);
        try (PreparedStatement statement = prep(claimChunk, connection, sql)) {
            statement.setString(1, player.toString());
            try (ResultSet result = statement.executeQuery()) {
                if (result.next()) return result.getString(1);
            }
        } catch (Exception e) {
            Utils.err("Failed to retrieve player chunk name: %s", e.getMessage());
            e.printStackTrace();
        }
        return null;
    }

    @Override
    public void setPlayerReceiveAlerts(UUID player, boolean alerts) {
        String sql = String.format("UPDATE `%s` SET `%s`=? WHERE `%s`=?",
                PLAYERS_TABLE_NAME, PLAYERS_ALERT, PLAYERS_UUID);
        try (PreparedStatement statement = prep(claimChunk, connection, sql)) {
            statement.setBoolean(1, alerts);
            statement.setString(2, player.toString());
            statement.execute();
        } catch (Exception e) {
            Utils.err("Failed to update player alert preference: %s", e.getMessage());
            e.printStackTrace();
        }
    }

    @Override
    public boolean getPlayerReceiveAlerts(UUID player) {
        String sql = String.format("SELECT `%s` FROM `%s` WHERE `%s`=?",
                PLAYERS_ALERT, PLAYERS_TABLE_NAME, PLAYERS_UUID);
        try (PreparedStatement statement = prep(claimChunk, connection, sql)) {
            statement.setString(1, player.toString());
            try (ResultSet result = statement.executeQuery()) {
                if (result.next()) return result.getBoolean(1);
            }
        } catch (Exception e) {
            Utils.err("Failed to retrieve player alert preference: %s", e.getMessage());
            e.printStackTrace();
        }
        return false;
    }

    @Override
    public boolean hasPlayer(UUID player) {
        String sql = String.format("SELECT count(*) FROM `%s` WHERE `%s`=?",
                PLAYERS_TABLE_NAME, PLAYERS_UUID);
        try (PreparedStatement statement = prep(claimChunk, connection, sql)) {
            statement.setString(1, player.toString());
            try (ResultSet result = statement.executeQuery()) {
                if (result.next()) return result.getInt(1) > 0;
            }
        } catch (Exception e) {
            Utils.err("Failed to retrieve player alert preference: %s", e.getMessage());
            e.printStackTrace();
        }
        return false;
    }

    @Override
    public Collection<SimplePlayerData> getPlayers() {
        String sql = String.format("SELECT `%s`, `%s`, `%s` FROM `%s` LIMIT 1",
                PLAYERS_UUID, PLAYERS_IGN, PLAYERS_LAST_JOIN, PLAYERS_TABLE_NAME);
        ArrayList<SimplePlayerData> players = new ArrayList<>();
        try (PreparedStatement statement = prep(claimChunk, connection, sql); ResultSet result = statement.executeQuery()) {
            while (result.next()) {
                players.add(new SimplePlayerData(
                        UUID.fromString(result.getString(1)),
                        result.getString(2),
                        result.getLong(3)
                ));
            }
        } catch (Exception e) {
            Utils.err("Failed to retrieve all players: %s", e.getMessage());
            e.printStackTrace();
        }
        return players;
    }

    @Override
    public FullPlayerData[] getFullPlayerData() {
        String sql = String.format("SELECT `%s`, `%s`, `%s`, `%s`, `%s` FROM `%s` LIMIT 1",
                PLAYERS_UUID, PLAYERS_IGN, PLAYERS_NAME, PLAYERS_LAST_JOIN, PLAYERS_ALERT, PLAYERS_TABLE_NAME);
        ArrayList<FullPlayerData> players = new ArrayList<>();
        try (PreparedStatement statement = prep(claimChunk, connection, sql); ResultSet result = statement.executeQuery()) {
            while (result.next()) {
                UUID uuid = UUID.fromString(result.getString(1));
                players.add(new FullPlayerData(
                        uuid,
                        result.getString(2),
                        new HashSet<>(Arrays.asList(getPlayersWithAccess(uuid))),
                        result.getString(3),
                        result.getLong(4),
                        result.getBoolean(5)
                ));
            }
        } catch (Exception e) {
            Utils.err("Failed to retrieve all players data: %s", e.getMessage());
            e.printStackTrace();
        }
        return players.toArray(new FullPlayerData[0]);
    }

    @Override
    public void setPlayerAccess(UUID owner, UUID accessor, boolean access) {
        if (access == playerHasAccess(owner, accessor)) return;
        if (access) {
            String sql = String.format("INSERT INTO `%s` (`%s`, `%s`) VALUES (?, ?)",
                    ACCESS_TABLE_NAME, ACCESS_OWNER, ACCESS_OTHER);
            try (PreparedStatement statement = prep(claimChunk, connection, sql)) {
                statement.setString(1, owner.toString());
                statement.setString(2, accessor.toString());
                statement.execute();
            } catch (Exception e) {
                Utils.err("Failed give player chunk access: %s", e.getMessage());
                e.printStackTrace();
            }
        } else {
            String sql = String.format("DELETE FROM `%s` WHERE `%s`=? AND `%s`=?",
                    ACCESS_TABLE_NAME, ACCESS_OWNER, ACCESS_OTHER);
            try (PreparedStatement statement = prep(claimChunk, connection, sql)) {
                statement.setString(1, owner.toString());
                statement.setString(2, accessor.toString());
                statement.execute();
            } catch (Exception e) {
                Utils.err("Failed to remove player chunk access: %s", e.getMessage());
                e.printStackTrace();
            }
        }
    }

    @Override
    public void givePlayersAcess(UUID owner, UUID[] accessors) {
        if (accessors.length == 0) return;

        // Determine which of the provided accessors actually need to be GIVEN access
        HashSet<UUID> withAccess = new HashSet<>(Arrays.asList(getPlayersWithAccess(owner)));
        HashSet<UUID> needAccess = new HashSet<>();
        for (UUID accessor : accessors) {
            if (!withAccess.contains(accessor)) needAccess.add(accessor);
        }

        // Use a single query to add all the access associations
        StringBuilder sql = new StringBuilder(String.format("INSERT INTO `%s` (`%s`, `%s`) VALUES",
                ACCESS_TABLE_NAME, ACCESS_OWNER, ACCESS_OTHER));
        for (int i = 0; i < needAccess.size(); i++) {
            sql.append(" (?, ?)");
            if (i != needAccess.size() - 1) sql.append(',');
        }
        try (PreparedStatement statement = prep(claimChunk, connection, sql.toString())) {
            int i = 0;
            for (UUID accessor : needAccess) {
                statement.setString(2 * i + 1, owner.toString());
                statement.setString(2 * i + 2, accessor.toString());
                i++;
            }
            statement.execute();
        } catch (Exception e) {
            Utils.err("Failed give players chunk access: %s", e.getMessage());
            e.printStackTrace();
        }
    }

    @Override
    public void takePlayersAcess(UUID owner, UUID[] accessors) {
        if (accessors.length == 0) return;

        // Use a single query to remove all the access associations
        StringBuilder sql = new StringBuilder(String.format("DELETE FROM `%s` WHERE (`%s`, `%s`) IN (",
                ACCESS_TABLE_NAME, ACCESS_OWNER, ACCESS_OTHER));
        for (int i = 0; i < accessors.length; i++) {
            sql.append("(?, ?)");
            if (i < accessors.length - 1) sql.append(", ");
        }
        sql.append(')');
        try (PreparedStatement statement = prep(claimChunk, connection, sql.toString())) {
            int i = 0;
            for (UUID accessor : accessors) {
                statement.setString(2 * i + 1, owner.toString());
                statement.setString(2 * i + 2, accessor.toString());
                i++;
            }
            statement.execute();
        } catch (Exception e) {
            Utils.err("Failed revoke players chunk access: %s", e.getMessage());
            e.printStackTrace();
        }
    }

    @Override
    public boolean playerHasAccess(UUID owner, UUID accessor) {
        String sql = String.format("SELECT count(*) FROM `%s` WHERE `%s`=? AND `%s`=?",
                ACCESS_TABLE_NAME, ACCESS_OWNER, ACCESS_OTHER);
        try (PreparedStatement statement = prep(claimChunk, connection, sql)) {
            statement.setString(1, owner.toString());
            statement.setString(2, accessor.toString());
            try (ResultSet result = statement.executeQuery()) {
                if (result.next()) return result.getInt(1) > 0;
            }
        } catch (Exception e) {
            Utils.err("Failed to check player access: %s", e.getMessage());
            e.printStackTrace();
        }
        return false;
    }

    @Override
    public UUID[] getPlayersWithAccess(UUID owner) {
        String sql = String.format("SELECT `%s` FROM `%s` WHERE `%s`=?",
                ACCESS_OTHER, ACCESS_TABLE_NAME, ACCESS_OWNER);
        List<UUID> accessors = new ArrayList<>();
        try (PreparedStatement statement = prep(claimChunk, connection, sql)) {
            statement.setString(1, owner.toString());
            try (ResultSet result = statement.executeQuery()) {
                while (result.next()) {
                    accessors.add(UUID.fromString(result.getString(1)));
                }
            }
        } catch (Exception e) {
            Utils.err("Failed to get all claimed chunks: %s", e.getMessage());
            e.printStackTrace();
        }
        return accessors.toArray(new UUID[0]);
    }

    private void createClaimedChunksTable() throws Exception {
        String sql = String.format("CREATE TABLE `%s` ("
                        + "`%s` INT NOT NULL AUTO_INCREMENT,"   // ID (for per-chunk access)
                        + "`%s` VARCHAR(64) NOT NULL,"          // World
                        + "`%s` INT NOT NULL,"                  // X
                        + "`%s` INT NOT NULL,"                  // Z
                        + "`%s` BOOL NOT NULL DEFAULT 0,"       // TNT
                        + "`%s` VARCHAR(36) NOT NULL,"          // Owner (UUIDs are always 36 chars)
                        + "PRIMARY KEY (`%2$s`)"
                        + ") ENGINE = InnoDB",
                CLAIMED_CHUNKS_TABLE_NAME,
                CLAIMED_CHUNKS_ID,
                CLAIMED_CHUNKS_WORLD,
                CLAIMED_CHUNKS_X,
                CLAIMED_CHUNKS_Z,
                CLAIMED_CHUNKS_TNT,
                CLAIMED_CHUNKS_OWNER);
        try (PreparedStatement statement = prep(claimChunk, connection, sql)) {
            statement.executeUpdate();
        } catch (Exception e) {
            Utils.err("Failed to create claimed chunks table: %s", e.getMessage());
            e.printStackTrace();
            throw e;
        }
    }

    private void createJoinedPlayersTable() throws Exception {
        String sql = String.format("CREATE TABLE `%s` ("
                        + "`%s` VARCHAR(36) NOT NULL,"              // UUID
                        + "`%s` VARCHAR(64) NOT NULL,"              // In-game name
                        + "`%s` VARCHAR(64) NULL DEFAULT NULL,"     // Chunk display name
                        + "`%s` BIGINT NOT NULL,"                   // Last join time in ms
                        + "`%s` BOOL NOT NULL,"                     // Enable alerts
                        + "PRIMARY KEY (`%2$s`)"
                        + ") ENGINE = InnoDB",
                PLAYERS_TABLE_NAME,
                PLAYERS_UUID,
                PLAYERS_IGN,
                PLAYERS_NAME,
                PLAYERS_LAST_JOIN,
                PLAYERS_ALERT);
        try (PreparedStatement statement = prep(claimChunk, connection, sql)) {
            statement.executeUpdate();
        } catch (Exception e) {
            Utils.err("Failed to create claimed chunks table: %s", e.getMessage());
            e.printStackTrace();
            throw e;
        }
    }

    private void createAccessTable() throws Exception {
        String sql = String.format("CREATE TABLE `%s` ("
                        + "`%s` INT NOT NULL AUTO_INCREMENT,"   // Access ID (for primary key)
                        + "`%s` INT NULL DEFAULT NULL,"         // Chunk ID (for per-chunk access)
                        + "`%s` VARCHAR(36) NOT NULL,"          // Granter
                        + "`%s` VARCHAR(36) NOT NULL,"          // Granted
                        + "PRIMARY KEY (`%2$s`)"
                        + ") ENGINE = InnoDB",
                ACCESS_TABLE_NAME,
                ACCESS_ACCESS_ID,
                ACCESS_CHUNK_ID,
                ACCESS_OWNER,
                ACCESS_OTHER);
        try (PreparedStatement statement = prep(claimChunk, connection, sql)) {
            statement.executeUpdate();
        } catch (Exception e) {
            Utils.err("Failed to create access table: %s", e.getMessage());
            e.printStackTrace();
            throw e;
        }
    }

    /**
     * Updates 0.0.15 access tables to 0.0.16.
     * Fixes chunk id not being nullable.
     *
     * @since 0.0.16
     */
    private void migrateAccessTable0015_0016() {
        try {
            if (!getColumnIsNullable(claimChunk, connection, ACCESS_TABLE_NAME, ACCESS_CHUNK_ID)) {
                Utils.debug("Migrating access table from 0.0.15 to 0.0.16+");

                // Allow null and make it the default
                String sql = String.format("ALTER TABLE `%s` MODIFY `%s` INT NULL DEFAULT NULL",
                        ACCESS_TABLE_NAME, ACCESS_CHUNK_ID);
                try (PreparedStatement statement = prep(claimChunk, connection, sql)) {
                    statement.executeUpdate();
                    Utils.debug("Successfully migrated access table from 0.0.15 to 0.0.16+");
                } catch (Exception e) {
                    Utils.err("Failed to migrate access table: %s", e.getMessage());
                    e.printStackTrace();
                    throw e;
                }
            }
        } catch (SQLException e) {
            Utils.err("Failed to determine if access table needs updated from 0.0.15 to 0.0.16+: %s", e.getMessage());
            e.printStackTrace();
        }
    }

    /**
     * Updates 0.0.15 claimed chunks tables to 0.0.16.
     * Allows per-chunk TNT.
     *
     * @since 0.0.16
     */
    private void migrateClaimedChunksTable0015_0016() {
        try {
            if (!getColumnExists(claimChunk, connection, dbName, CLAIMED_CHUNKS_TABLE_NAME, CLAIMED_CHUNKS_TNT)) {
                Utils.debug("Migrating claimed chunks table from 0.0.15 to 0.0.16+");

                // Allow null and make it the default
                String sql = String.format("ALTER TABLE `%s` ADD `%s` BOOL NOT NULL DEFAULT 0 AFTER `%s`",
                        CLAIMED_CHUNKS_TABLE_NAME, CLAIMED_CHUNKS_TNT, CLAIMED_CHUNKS_Z);
                try (PreparedStatement statement = prep(claimChunk, connection, sql)) {
                    statement.executeUpdate();
                    Utils.debug("Successfully migrated claimed chunks table from 0.0.15 to 0.0.16+");
                } catch (Exception e) {
                    Utils.err("Failed to migrate claimed chunks table: %s", e.getMessage());
                    e.printStackTrace();
                    throw e;
                }
            }
        } catch (SQLException e) {
            Utils.err("Failed to determine if claimed chunks table needs updated from 0.0.15 to 0.0.16+: %s", e.getMessage());
            e.printStackTrace();
        }
    }

}