public class BlockPos {

    private final String world;
    private final int x;
    private final int y;
    private final int z;

    public static BlockPos of(Block block) {
        return new BlockPos(block.getWorld().getName(), block.getX(), block.getY(), block.getZ());
    }

    public static BlockPos of(String world, int x, int y, int z) {
        return new BlockPos(world, x, y, z);
    }

    private BlockPos(String world, int x, int y, int z) {
        this.world = world;
        this.x = x;
        this.y = y;
        this.z = z;
    }

    public String getWorld() {
        return world;
    }

    public int getX() {
        return x;
    }

    public int getY() {
        return y;
    }

    public int getZ() {
        return z;
    }

    public Block getBlock(Server server) {
        if (server.getWorld(world) == null) return null;
        return server.getWorld(world).getBlockAt(x, y, z);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        BlockPos blockPos = (BlockPos) o;
        return x == blockPos.x &&
                Double.compare(blockPos.y, y) == 0 &&
                Double.compare(blockPos.z, z) == 0 &&
                Objects.equals(world, blockPos.world);
    }

    @Override
    public int hashCode() {
        return Objects.hash(world, x, y, z);
    }

    @Override
    public String toString() {
        return "BlockPos{" +
                "world='" + world + '\'' +
                ", x=" + x +
                ", y=" + y +
                ", z=" + z +
                '}';
    }
} public class ChunkPos {

    private final String world;
    private final int x;
    private final int z;

    public static ChunkPos of(Chunk chunk) {
        return new ChunkPos(chunk.getWorld().getName(), chunk.getX(), chunk.getZ());
    }

    public static ChunkPos of(ChunkSnapshot snapshot) {
        return new ChunkPos(snapshot.getWorldName(), snapshot.getX(), snapshot.getZ());
    }

    public static ChunkPos of(String world, int x, int z) {
        return new ChunkPos(world, x, z);
    }

    private ChunkPos(String world, int x, int z) {
        this.world = world;
        this.x = x;
        this.z = z;
    }

    public String getWorld() {
        return world;
    }

    public int getX() {
        return x;
    }

    public int getZ() {
        return z;
    }

    public Chunk getChunk(Server server) {
        if (server.getWorld(world) == null) return null;
        return server.getWorld(world).getChunkAt(x, z);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        ChunkPos chunkPos = (ChunkPos) o;
        return x == chunkPos.x &&
                z == chunkPos.z &&
                Objects.equals(world, chunkPos.world);
    }

    @Override
    public int hashCode() {
        return Objects.hash(world, x, z);
    }

    @Override
    public String toString() {
        return "ChunkPos{" +
                "world='" + world + '\'' +
                ", x=" + x +
                ", z=" + z +
                '}';
    }
} public class ChunkWorth {

    private final Map<WorthType, Double> worth;
    private Map<Material, Integer> materials;
    private Map<EntityType, Integer> spawners;
    private long nextRecalculation;

    public ChunkWorth() {
        this(new EnumMap<>(WorthType.class), new EnumMap<>(Material.class), new EnumMap<>(EntityType.class));
    }

    public ChunkWorth(Map<WorthType, Double> worth, Map<Material, Integer> materials, Map<EntityType, Integer> spawners) {
        this.worth = worth;
        this.materials = materials;
        this.spawners = spawners;
    }

    public double getWorth(WorthType worthType) {
        return worth.getOrDefault(worthType, 0d);
    }

    public void setWorth(WorthType worthType, double worth) {
        if (!WorthType.isPlaced(worthType)) {
            throw new IllegalArgumentException("Liquid worth cannot be associated with chunks!");
        }

        worth = Math.max(0, worth);
        this.worth.put(worthType, worth);
    }

    public Map<WorthType, Double> getWorth() {
        return worth;
    }

    public Map<Material, Integer> getMaterials() {
        return materials;
    }

    public Map<EntityType, Integer> getSpawners() {
        return spawners;
    }

    public void setMaterials(Map<Material, Integer> materials) {
        this.materials = materials;
    }

    public void setSpawners(Map<EntityType, Integer> spawners) {
        this.spawners = spawners;
    }

    public void addMaterials(Map<Material, Integer> materials) {
        for (Map.Entry<Material, Integer> material : materials.entrySet()) {
            int amount = Math.max(0, this.materials.getOrDefault(material.getKey(), 0) + material.getValue());
            this.materials.put(material.getKey(), amount);
        }
    }

    public void addSpawners(Map<EntityType, Integer> spawners) {
        for (Map.Entry<EntityType, Integer> spawner : spawners.entrySet()) {
            int amount = Math.max(0, this.spawners.getOrDefault(spawner.getKey(), 0) + spawner.getValue());
            this.spawners.put(spawner.getKey(), amount);
        }
    }

    public void addWorth(WorthType worthType, double worth) {
        setWorth(worthType, getWorth(worthType) + worth);
    }

    public long getNextRecalculation() {
        return nextRecalculation;
    }

    public void setNextRecalculation(long nextRecalculation) {
        this.nextRecalculation = nextRecalculation;
    }

    @Override
    public String toString() {
        return "ChunkWorth{" +
                "worth=" + worth +
                ", materials=" + materials +
                ", spawners=" + spawners +
                ", nextRecalculation=" + nextRecalculation +
                '}';
    }
} public class ChatListener implements Listener, PluginService {

    private static final String RANK_PLACEHOLDER = "{rank}";
    private final FactionsTopPlugin plugin;

    public ChatListener(FactionsTopPlugin plugin) {
        this.plugin = plugin;
    }

    @Override
    public void initialize() {
        plugin.getServer().getPluginManager().registerEvents(this, plugin);
    }

    @Override
    public void terminate() {
        HandlerList.unregisterAll(this);
    }

    @EventHandler(ignoreCancelled = true, priority = EventPriority.LOW)
    public void replacePlaceholders(AsyncPlayerChatEvent event) {
        // Do nothing if placeholder should not be applied.
        if (!plugin.getSettings().isChatEnabled()) {
            return;
        }

        String placeholder = plugin.getSettings().getChatRankPlaceholder();

        if (!event.getFormat().contains(placeholder)) {
            return;
        }

        String factionId = plugin.getFactionsHook().getFaction(event.getPlayer());
        String format = event.getFormat();

        // Set rank not found if player is in an ignored faction.
        if (plugin.getSettings().getIgnoredFactionIds().contains(factionId)) {
            event.setFormat(format.replace(placeholder, plugin.getSettings().getChatRankNotFound()));
            return;
        }

        // Or if faction currently has no rank.
        FactionWorth worth = plugin.getWorthManager().getWorth(factionId);
        int rank = plugin.getWorthManager().getOrderedFactions().indexOf(worth) + 1;

        if (rank == 0) {
            event.setFormat(format.replace(placeholder, plugin.getSettings().getChatRankNotFound()));
            return;
        }

        // Update chat format with rank found placeholder.
        String rankFound = plugin.getSettings().getChatRankFound().replace(RANK_PLACEHOLDER, String.valueOf(rank));
        format = format.replace(placeholder, rankFound);
        event.setFormat(format);
    }
} public class CommandListener implements Listener, PluginService {

    private static final Pattern VERSION_COMMAND = Pattern.compile("f( |)top( |)(v(ersion|))($| .*)");
    private static final Pattern RECALCULATE_COMMAND = Pattern.compile("f( |)top( |)(rec(alc(ulate|)|))($| .*)");
    private static final Pattern RELOAD_COMMAND = Pattern.compile("f( |)top( |)(r(eload|))($| .*)");
    private final FactionsTopPlugin plugin;

    public CommandListener(FactionsTopPlugin plugin) {
        this.plugin = plugin;
    }

    @Override
    public void initialize() {
        plugin.getServer().getPluginManager().registerEvents(this, plugin);
    }

    @Override
    public void terminate() {
        HandlerList.unregisterAll(this);
    }

    @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
    public void onCommand(PlayerCommandPreprocessEvent event) {
        event.setMessage("/" + attemptRebind(event.getMessage().substring(1)));
    }

    @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
    public void onCommand(ServerCommandEvent event) {
        event.setCommand(attemptRebind(event.getCommand()));
    }

    private String attemptRebind(String command) {
        if (VERSION_COMMAND.matcher(command).matches()) {
            return command.replaceFirst("f( |)top( |)(v(ersion|))", "ftopversion");
        }

        if (RECALCULATE_COMMAND.matcher(command).matches()) {
            return command.replaceFirst("f( |)top( |)(rec(alc(ulate|)|))", "ftoprecalculate");
        }

        if (RELOAD_COMMAND.matcher(command).matches()) {
            return command.replaceFirst("f( |)top( |)(r(eload|))", "ftopreload");
        }

        String newCommand = replaceFirst(command, "ftop", plugin.getSettings().getCommandAliases());
        if (newCommand != null) {
            return newCommand;
        }

        newCommand = replaceFirst(command, "ftopgui", plugin.getSettings().getGuiCommandAliases());
        if (newCommand != null) {
            return newCommand;
        }

        return command;
    }

    private String replaceFirst(String command, String replace, List<String> aliases) {
        for (String alias : aliases) {
            String newCommand = replaceFirst(command, alias, replace);
            if (newCommand != null) {
                return newCommand;
            }
        }
        return null;
    }

    private String replaceFirst(String command, String alias, String replace) {
        // Do nothing if not an alias.
        if (!command.startsWith(alias)) {
            return null;
        }

        // Do nothing if not followed by whitespace.
        if (command.length() > alias.length() && Character.SPACE_SEPARATOR != command.charAt(alias.length())) {
            return null;
        }

        return command.replaceFirst(alias, replace);
    }
} public class GuiListener implements Listener, PluginService {

    private final FactionsTopPlugin plugin;

    public GuiListener(FactionsTopPlugin plugin) {
        this.plugin = plugin;
    }

    @Override
    public void initialize() {
        plugin.getServer().getPluginManager().registerEvents(this, plugin);
    }

    @Override
    public void terminate() {
        HandlerList.unregisterAll(this);
    }

    @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
    public void registerClick(InventoryClickEvent event) {
        GuiContext context = plugin.getGuiManager().getContext(event.getClickedInventory());
        if (context == null) {
            return;
        }

        event.setCancelled(true);

        if (context.getSlots().size() >= event.getSlot()) {
            context.getSlots().get(event.getSlot()).handleClick(context);
        }
    }

    @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
    public void denyMovement(InventoryClickEvent event) {
        GuiContext context = plugin.getGuiManager().getContext(event.getInventory());
        if (context != null) {
            event.setCancelled(true);
        }
    }

    @EventHandler(priority = EventPriority.MONITOR)
    public void unloadInventory(InventoryCloseEvent event) {
        plugin.getGuiManager().unloadGui(event.getInventory());
    }
} public class WorthListener extends BukkitRunnable implements Listener, PluginService {

    private final FactionsTopPlugin plugin;
    private final Map<BlockPos, ChestWorth> chests = new HashMap<>();
    private final Set<String> recentDisbands = new HashSet<>();

    public WorthListener(FactionsTopPlugin plugin) {
        this.plugin = plugin;
    }

    @Override
    public void initialize() {
        plugin.getServer().getPluginManager().registerEvents(this, plugin);
        runTaskTimer(plugin, 1, 1);
    }

    @Override
    public void terminate() {
        HandlerList.unregisterAll(this);
        cancel();
    }

    @Override
    public void run() {
        recentDisbands.clear();
    }

    @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
    public void updateWorth(BlockPlaceEvent event) {
        updateWorth(event.getBlock(), RecalculateReason.PLACE, false);
    }

    @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
    public void updateWorth(BlockBreakEvent event) {
        updateWorth(event.getBlock(), RecalculateReason.BREAK, true);
    }

    @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
    public void updateWorth(EntityExplodeEvent event) {
        event.blockList().forEach(block -> updateWorth(block, RecalculateReason.EXPLODE, true));
    }

    private void updateWorth(Block block, RecalculateReason reason, boolean negate) {
        // Do nothing if this area should not be calculated.
        String factionId = plugin.getFactionsHook().getFactionAt(block);
        if (plugin.getSettings().getIgnoredFactionIds().contains(factionId)) {
            return;
        }

        // Get the worth type and price of this event.
        int multiplier = negate ? -1 : 1;
        double price = multiplier * plugin.getSettings().getBlockPrice(block.getType());
        WorthType worthType = WorthType.BLOCK;
        Map<Material, Integer> materials = new HashMap<>();
        Map<EntityType, Integer> spawners = new HashMap<>();

        plugin.getWorthManager().add(block.getChunk(), reason, worthType, price,
                ImmutableMap.of(block.getType(), multiplier), spawners);

        switch (block.getType()) {
            case MOB_SPAWNER:
                worthType = WorthType.SPAWNER;
                CreatureSpawner spawner = (CreatureSpawner) block.getState();
                EntityType spawnedType = spawner.getSpawnedType();
                multiplier *= plugin.getSpawnerStackerHook().getStackSize(spawner);
                price = multiplier * plugin.getSettings().getSpawnerPrice(spawnedType);
                spawners.put(spawnedType, multiplier);
                break;
            case CHEST:
            case TRAPPED_CHEST:
                if (plugin.getSettings().isDisableChestEvents()) {
                    return;
                }

                worthType = WorthType.CHEST;
                Chest chest = (Chest) block.getState();
                ChestWorth chestWorth = negate ? getWorthNegative(chest.getBlockInventory()) : getWorth(chest.getBlockInventory());
                price = chestWorth.getTotalWorth();
                materials.putAll(chestWorth.getMaterials());
                spawners.putAll(chestWorth.getSpawners());
                break;
            default:
                return;
        }

        // Add block price to the count.
        plugin.getWorthManager().add(block.getChunk(), reason, worthType, price, materials, spawners);
    }

    @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
    public void checkWorth(InventoryOpenEvent event) {
        // Do nothing if a player did not open the inventory or if chest events
        // are disabled.
        if (!(event.getPlayer() instanceof Player) || plugin.getSettings().isDisableChestEvents()) {
            return;
        }

        Inventory inventory = event.getInventory();

        // Set all default worth values for this chest.
        if (inventory.getHolder() instanceof DoubleChest) {
            DoubleChest chest = (DoubleChest) inventory.getHolder();
            checkWorth((Chest) chest.getLeftSide());
            checkWorth((Chest) chest.getRightSide());
        }

        if (inventory.getHolder() instanceof Chest) {
            checkWorth((Chest) inventory.getHolder());
        }
    }

    private void checkWorth(Chest chest) {
        chests.put(BlockPos.of(chest.getBlock()), getWorth(chest.getBlockInventory()));
    }

    private ChestWorth getWorth(Inventory inventory) {
        double worth = 0;
        Map<Material, Integer> materials = new HashMap<>();
        Map<EntityType, Integer> spawners = new HashMap<>();

        for (ItemStack item : inventory.getContents()) {
            if (item == null) continue;

            if (item.getType() == Material.MOB_SPAWNER) {
                int stackSize = plugin.getSpawnerStackerHook().getStackSize(item);
                EntityType spawnerType = plugin.getSpawnerStackerHook().getSpawnedType(item);
                worth += plugin.getSettings().getSpawnerPrice(spawnerType) * item.getAmount() * stackSize;

                int count = spawners.getOrDefault(spawnerType, 0);
                spawners.put(spawnerType, count + (item.getAmount() * stackSize));
                continue;
            }

            worth += plugin.getSettings().getBlockPrice(item.getType()) * item.getAmount();
            int count = materials.getOrDefault(item.getType(), 0);
            materials.put(item.getType(), count + item.getAmount());
        }

        return new ChestWorth(worth, materials, spawners);
    }

    private ChestWorth getWorthNegative(Inventory inventory) {
        double worth = 0;
        Map<Material, Integer> materials = new HashMap<>();
        Map<EntityType, Integer> spawners = new HashMap<>();

        for (ItemStack item : inventory.getContents()) {
            if (item == null) continue;

            if (item.getType() == Material.MOB_SPAWNER) {
                int stackSize = plugin.getSpawnerStackerHook().getStackSize(item);
                EntityType spawnerType = plugin.getSpawnerStackerHook().getSpawnedType(item);
                worth -= plugin.getSettings().getSpawnerPrice(spawnerType) * item.getAmount() * stackSize;

                int count = spawners.getOrDefault(spawnerType, 0);
                spawners.put(spawnerType, count - (item.getAmount() + stackSize));
                continue;
            }

            worth -= plugin.getSettings().getBlockPrice(item.getType()) * item.getAmount();
            int count = materials.getOrDefault(item.getType(), 0);
            materials.put(item.getType(), count - item.getAmount());
        }

        return new ChestWorth(worth, materials, spawners);
    }

    @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
    public void updateWorth(InventoryCloseEvent event) {
        // Do nothing if a player did not close the inventory or if chest
        // events are disabled.
        if (!(event.getPlayer() instanceof Player) || plugin.getSettings().isDisableChestEvents()) {
            return;
        }

        // Get cached values from when chest was opened and add the difference
        // to the worth manager.
        if (event.getInventory().getHolder() instanceof DoubleChest) {
            DoubleChest chest = (DoubleChest) event.getInventory().getHolder();
            updateWorth((Chest) chest.getLeftSide());
            updateWorth((Chest) chest.getRightSide());
        }

        if (event.getInventory().getHolder() instanceof Chest) {
            updateWorth((Chest) event.getInventory().getHolder());
        }
    }

    private void updateWorth(Chest chest) {
        if (chest == null) return;
        BlockPos pos = BlockPos.of(chest.getBlock());
        ChestWorth worth = chests.remove(pos);
        if (worth == null) return;

        worth = getDifference(worth, getWorth(chest.getBlockInventory()));

        plugin.getWorthManager().add(chest.getChunk(), RecalculateReason.CHEST, WorthType.CHEST,
                worth.getTotalWorth(), worth.getMaterials(), worth.getSpawners());
    }

    private ChestWorth getDifference(ChestWorth first, ChestWorth second) {
        double worth = second.getTotalWorth() - first.getTotalWorth();
        Map<Material, Integer> materials = getDifference(first.getMaterials(), second.getMaterials());
        Map<EntityType, Integer> spawners = getDifference(first.getSpawners(), second.getSpawners());
        return new ChestWorth(worth, materials, spawners);
    }

    private <T> Map<T, Integer> getDifference(Map<T, Integer> first, Map<T, Integer> second) {
        Map<T, Integer> target = new HashMap<>();

        for (Map.Entry<T, Integer> entry : first.entrySet()) {
            int difference = second.getOrDefault(entry.getKey(), 0) - entry.getValue();
            target.put(entry.getKey(), difference);
        }

        for (Map.Entry<T, Integer> entry : second.entrySet()) {
            if (target.containsKey(entry.getKey())) continue;
            target.put(entry.getKey(), entry.getValue());
        }

        return target;
    }

    @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
    public void updateWorth(FactionClaimEvent event) {
        String newFactionId = event.getFactionId();
        event.getClaims().asMap().forEach((oldFactionId, claims) -> {
            if (!oldFactionId.equals(newFactionId)) {
                plugin.getWorthManager().update(newFactionId, claims, false);
                plugin.getWorthManager().update(oldFactionId, claims, true);
            }
        });
    }

    @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
    public void removeFaction(FactionDisbandEvent event) {
        recentDisbands.add(event.getFactionId());
        plugin.getWorthManager().remove(event.getFactionId());
    }

    @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
    public void renameFaction(FactionRenameEvent event) {
        plugin.getWorthManager().rename(event.getFactionId(), event.getNewName());
    }

    @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
    public void updateWorth(FactionEconomyEvent event) {
        plugin.getWorthManager().add(event.getFactionId(), WorthType.FACTION_BALANCE, event.getNewBalance() - event.getOldBalance());
    }

    @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
    public void updateWorth(PlayerEconomyEvent event) {
        String factionId = plugin.getFactionsHook().getFaction(event.getPlayer());
        plugin.getWorthManager().add(factionId, WorthType.PLAYER_BALANCE, event.getNewBalance() - event.getOldBalance());
    }

    @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
    public void updateWorth(FactionJoinEvent event) {
        double balance = plugin.getEconomyHook().getBalance(event.getPlayer());
        plugin.getWorthManager().add(event.getFactionId(), WorthType.PLAYER_BALANCE, balance);
    }

    @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
    public void updateWorth(FactionLeaveEvent event) {
        // Do nothing if the faction was disbanded within this tick.
        if (recentDisbands.contains(event.getFactionId())) {
            return;
        }

        double balance = plugin.getEconomyHook().getBalance(event.getPlayer());
        plugin.getWorthManager().add(event.getFactionId(), WorthType.PLAYER_BALANCE, -balance);
    }

    @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
    public void recalculate(ChunkUnloadEvent event) {
        plugin.getWorthManager().recalculate(event.getChunk(), RecalculateReason.UNLOAD);
    }

    @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
    public void updateWorth(SpawnerMultiplierChangeEvent event) {
        // Do nothing if this area should not be calculated.
        Block block = event.getBlock();
        String factionId = plugin.getFactionsHook().getFactionAt(block);
        if (plugin.getSettings().getIgnoredFactionIds().contains(factionId)) {
            return;
        }

        // Get the worth type and price of this event.
        int difference = event.getNewMultiplier() - event.getOldMultiplier();
        WorthType worthType = WorthType.SPAWNER;
        Map<Material, Integer> materials = new HashMap<>();
        Map<EntityType, Integer> spawners = new HashMap<>();

        EntityType spawnType = ((CreatureSpawner) block.getState()).getSpawnedType();
        double price = difference * plugin.getSettings().getSpawnerPrice(spawnType);
        spawners.put(spawnType, difference);

        RecalculateReason reason = difference > 0 ? RecalculateReason.PLACE : RecalculateReason.BREAK;

        // Add block price to the count.
        plugin.getWorthManager().add(block.getChunk(), reason, worthType, price, materials, spawners);
    }
} public class DatabaseManager {

    private final HikariDataSource dataSource;
    private final IdentityCache identityCache;

    public static DatabaseManager create(HikariConfig hikariConfig) throws SQLException {
        return create(hikariConfig, new IdentityCache());
    }

    public static DatabaseManager create(HikariConfig hikariConfig, IdentityCache identityCache) throws SQLException {
        // Create the datasource.
        HikariDataSource dataSource = new HikariDataSource(hikariConfig);

        // Create the database manager.
        DatabaseManager manager = new DatabaseManager(dataSource, identityCache);

        // Initialize the database.
        Connection connection = dataSource.getConnection();
        manager.init(connection);
        connection.close();

        // Return the new database manager.
        return manager;
    }

    private DatabaseManager(HikariDataSource dataSource, IdentityCache identityCache) {
        this.dataSource = dataSource;
        this.identityCache = identityCache;
    }

    private void init(Connection connection) throws SQLException {
        PreparedStatement statement = connection.prepareStatement("CREATE TABLE IF NOT EXISTS `world` (" +
                "`id` INT NOT NULL AUTO_INCREMENT," +
                "`name` VARCHAR(40) NOT NULL UNIQUE," +
                "PRIMARY KEY (`id`))");
        statement.executeUpdate();

        statement = connection.prepareStatement("CREATE TABLE IF NOT EXISTS `chunk` (" +
                "`id` INT NOT NULL AUTO_INCREMENT," +
                "`world_id` INT NOT NULL," +
                "`x` INT NOT NULL," +
                "`z` INT NOT NULL," +
                "PRIMARY KEY (`id`)," +
                "FOREIGN KEY (`world_id`) REFERENCES world(`id`)," +
                "UNIQUE (`world_id`, `x`, `z`))");
        statement.executeUpdate();

        statement = connection.prepareStatement("CREATE TABLE IF NOT EXISTS `worth` (" +
                "`id` INT NOT NULL AUTO_INCREMENT," +
                "`name` VARCHAR (40) NOT NULL UNIQUE," +
                "PRIMARY KEY (`id`))");
        statement.executeUpdate();

        statement = connection.prepareStatement("CREATE TABLE IF NOT EXISTS `chunk_worth` (" +
                "`id` INT NOT NULL AUTO_INCREMENT," +
                "`chunk_id` INT NOT NULL," +
                "`worth_id` INT NOT NULL," +
                "`worth` FLOAT NOT NULL," +
                "PRIMARY KEY (`id`)," +
                "FOREIGN KEY (`chunk_id`) REFERENCES chunk(`id`)," +
                "FOREIGN KEY (`worth_id`) REFERENCES worth(`id`)," +
                "UNIQUE(`chunk_id`, `worth_id`))");
        statement.executeUpdate();

        statement = connection.prepareStatement("CREATE TABLE IF NOT EXISTS `material` (" +
                "`id` INT NOT NULL AUTO_INCREMENT," +
                "`name` VARCHAR(40) NOT NULL UNIQUE," +
                "PRIMARY KEY (`id`))");
        statement.executeUpdate();

        statement = connection.prepareStatement("CREATE TABLE IF NOT EXISTS `chunk_material_count` (" +
                "`id` INT NOT NULL AUTO_INCREMENT," +
                "`chunk_id` INT NOT NULL," +
                "`material_id` INT NOT NULL," +
                "`count` INT NOT NULL," +
                "PRIMARY KEY (`id`)," +
                "FOREIGN KEY (`chunk_id`) REFERENCES chunk(`id`)," +
                "FOREIGN KEY (`material_id`) REFERENCES material(`id`)," +
                "UNIQUE (`chunk_id`, `material_id`))");
        statement.executeUpdate();

        statement = connection.prepareStatement("CREATE TABLE IF NOT EXISTS `spawner` (" +
                "`id` INT NOT NULL AUTO_INCREMENT," +
                "`name` VARCHAR(40) NOT NULL UNIQUE," +
                "PRIMARY KEY (`id`))");
        statement.executeUpdate();

        statement = connection.prepareStatement("CREATE TABLE IF NOT EXISTS `chunk_spawner_count` (" +
                "`id` INT NOT NULL AUTO_INCREMENT," +
                "`chunk_id` INT NOT NULL," +
                "`spawner_id` INT NOT NULL," +
                "`count` INT NOT NULL," +
                "PRIMARY KEY (`id`)," +
                "FOREIGN KEY (`chunk_id`) REFERENCES chunk(`id`)," +
                "FOREIGN KEY (`spawner_id`) REFERENCES spawner(`id`)," +
                "UNIQUE (`chunk_id`, `spawner_id`))");
        statement.executeUpdate();

        statement = connection.prepareStatement("CREATE TABLE IF NOT EXISTS `block` (" +
                "`id` INT NOT NULL AUTO_INCREMENT, " +
                "`world_id` INT NOT NULL, " +
                "`x` INT NOT NULL, " +
                "`y` INT NOT NULL, " +
                "`z` INT NOT NULL, " +
                "PRIMARY KEY (`id`), " +
                "FOREIGN KEY (`world_id`) REFERENCES world(`id`), " +
                "UNIQUE (`world_id`, `x`, `y`, `z`))");
        statement.executeUpdate();

        statement = connection.prepareStatement("CREATE TABLE IF NOT EXISTS `sign` (" +
                "`id` INT NOT NULL AUTO_INCREMENT, " +
                "`block_id` INT NOT NULL UNIQUE, " +
                "`rank` INT NOT NULL, " +
                "PRIMARY KEY (`id`), " +
                "FOREIGN KEY (`block_id`) REFERENCES block(`id`))");
        statement.executeUpdate();

        statement = connection.prepareStatement("CREATE TABLE IF NOT EXISTS `faction` (" +
                "`id` VARCHAR(40) NOT NULL, " +
                "`name` VARCHAR(40) NOT NULL, " +
                "`total_worth` FLOAT NOT NULL, " +
                "`total_spawners` INT NOT NULL, " +
                "PRIMARY KEY (`id`))");
        statement.executeUpdate();

        DatabaseMetaData metaData = connection.getMetaData();
        ResultSet resultSet = metaData.getIndexInfo(null, null, "faction", true, false);

        while (resultSet.next()) {
            String indexName = resultSet.getString("INDEX_NAME");

            if (indexName.equals("name")) {
                statement = connection.prepareStatement("DROP INDEX name ON faction");
                statement.executeUpdate();
            }
        }

        statement = connection.prepareStatement("CREATE TABLE IF NOT EXISTS `faction_worth` (" +
                "`id` INT NOT NULL AUTO_INCREMENT, " +
                "`faction_id` VARCHAR(40) NOT NULL, " +
                "`worth_id` INT NOT NULL, " +
                "`worth` FLOAT NOT NULL, " +
                "PRIMARY KEY (`id`), " +
                "FOREIGN KEY (`faction_id`) REFERENCES faction(`id`), " +
                "FOREIGN KEY (`worth_id`) REFERENCES worth(`id`), " +
                "UNIQUE (`faction_id`, `worth_id`))");
        statement.executeUpdate();

        statement = connection.prepareStatement("CREATE TABLE IF NOT EXISTS `faction_material_count` (" +
                "`id` INT NOT NULL AUTO_INCREMENT, " +
                "`faction_id` VARCHAR(40) NOT NULL, " +
                "`material_id` INT NOT NULL, " +
                "`count` INT NOT NULL, " +
                "PRIMARY KEY (`id`), " +
                "FOREIGN KEY (`faction_id`) REFERENCES faction(`id`), " +
                "FOREIGN KEY (`material_id`) REFERENCES material(`id`), " +
                "UNIQUE (`faction_id`, `material_id`))");
        statement.executeUpdate();

        statement = connection.prepareStatement("CREATE TABLE IF NOT EXISTS `faction_spawner_count` (" +
                "`id` INT NOT NULL AUTO_INCREMENT, " +
                "`faction_id` VARCHAR(40) NOT NULL, " +
                "`spawner_id` INT NOT NULL, " +
                "`count` INT NOT NULL, " +
                "PRIMARY KEY (`id`), " +
                "FOREIGN KEY (`faction_id`) REFERENCES faction(`id`), " +
                "FOREIGN KEY (`spawner_id`) REFERENCES spawner(`id`), " +
                "UNIQUE (`faction_id`, `spawner_id`))");
        statement.executeUpdate();
    }

    public IdentityCache getIdentityCache() {
        return identityCache;
    }

    public DataDump load() throws SQLException {
        Connection connection = dataSource.getConnection();

        NameLoader nameLoader = NameLoader.of(connection, identityCache);
        nameLoader.load();
        nameLoader.close();

        FactionLoader factionLoader = FactionLoader.of(connection, identityCache);
        factionLoader.load();
        factionLoader.close();

        ChunkLoader chunkLoader = ChunkLoader.of(connection, identityCache);
        Map<ChunkPos, ChunkWorth> chunks = chunkLoader.load();
        chunkLoader.close();

        SignLoader signLoader = SignLoader.of(connection, identityCache);
        Multimap<Integer, BlockPos> signs = signLoader.load();
        signLoader.close();

        connection.close();

        return new DataDump(chunks, signs);
    }

    public void save(Set<Map.Entry<ChunkPos, ChunkWorth>> chunks,
                     Set<FactionWorth> factions,
                     Set<String> deletedFactions,
                     Set<Map.Entry<BlockPos, Integer>> createdSigns,
                     Set<BlockPos> deletedSigns) throws SQLException {
        Connection connection = dataSource.getConnection();

        ChunkModel chunkModel = new ChunkModel(connection, identityCache);
        chunkModel.persist(chunks);

        FactionModel factionModel = new FactionModel(connection, identityCache);
        factionModel.persist(factions, deletedFactions);

        BlockModel blockModel = BlockModel.of(connection, identityCache);

        for (Map.Entry<BlockPos, Integer> entry : createdSigns) {
            blockModel.addBatch(entry.getKey());
        }

        blockModel.executeBatch();
        blockModel.close();

        SignModel signModel = SignModel.of(connection, identityCache);
        signModel.addBatch(createdSigns);
        signModel.addBatchDelete(deletedSigns);
        signModel.executeBatch();
        signModel.close();

        connection.close();
    }

    public void close() {
        dataSource.close();
    }

    public class DataDump {

        private final Map<ChunkPos, ChunkWorth> chunks;
        private final Multimap<Integer, BlockPos> signs;

        private DataDump(Map<ChunkPos, ChunkWorth> chunks, Multimap<Integer, BlockPos> signs) {
            this.chunks = chunks;
            this.signs = signs;
        }

        public Map<ChunkPos, ChunkWorth> getChunks() {
            return chunks;
        }

        public Multimap<Integer, BlockPos> getSigns() {
            return signs;
        }
    }
} public class GuiManager {

    private static final int MAX_TITLE_SIZE = 32;
    private final FactionsTopPlugin plugin;
    private final Map<Inventory, GuiContext> inventories = new WeakHashMap<>();

    public GuiManager(FactionsTopPlugin plugin) {
        this.plugin = plugin;
    }

    public GuiContext getContext(Inventory inventory) {
        return inventories.get(inventory);
    }

    public void unloadGui(Inventory inventory) {
        inventories.remove(inventory);
    }

    public void closeInventories() {
        for (Inventory inventory : this.inventories.keySet()) {
            new ArrayList<>(inventory.getViewers()).forEach(HumanEntity::closeInventory);
        }
    }

    public void sendGui(Player player, int page) {
        int entries = plugin.getSettings().getGuiLayout().getFactionsPerPage();
        SplaySet<FactionWorth> factions = plugin.getWorthManager().getOrderedFactions();
        int maxPage = Math.max((int) Math.ceil((double) factions.size() / entries), 1);
        page = Math.max(1, Math.min(maxPage, page));

        Map<String, String> placeholders = new HashMap<>();
        placeholders.put("{page:back}", String.valueOf(page - 1));
        placeholders.put("{page:this}", String.valueOf(page));
        placeholders.put("{page:next}", String.valueOf(page + 1));
        placeholders.put("{page:last}", String.valueOf(maxPage));

        int spacer = entries * (page - 1);
        TreeIterator<FactionWorth> it = factions.iterator(spacer);

        int lines = plugin.getSettings().getGuiLineCount() * 9;
        String name = StringUtils.replace(plugin.getSettings().getGuiInventoryName(), placeholders);
        if (name.length() > MAX_TITLE_SIZE) {
            name = name.substring(0, MAX_TITLE_SIZE);
        }
        Inventory inventory = plugin.getServer().createInventory(null, lines, name);

        GuiContext context = new GuiContext(plugin, player, inventory, maxPage, page, it, placeholders);
        context.setCurrentRank(spacer + 1);

        plugin.getSettings().getGuiLayout().render(context);
        inventories.put(inventory, context);
        player.openInventory(inventory);
    }
} public class SignManager extends BukkitRunnable implements PluginService, Listener {

    private static final Pattern signRegex = Pattern.compile("\\[f(|actions)top\\]");
    private final FactionsTopPlugin plugin;
    private final Multimap<Integer, BlockPos> signs = HashMultimap.create();
    private final Map<Integer, Double> previous = new HashMap<>();

    public SignManager(FactionsTopPlugin plugin) {
        this.plugin = plugin;
    }

    public void setSigns(Multimap<Integer, BlockPos> signs) {
        this.signs.clear();
        this.signs.putAll(signs);
    }

    @Override
    public void initialize() {
        int ticks = plugin.getSettings().getSignUpdateTicks();
        runTaskTimer(plugin, ticks, ticks);
        plugin.getServer().getPluginManager().registerEvents(this, plugin);
    }

    @Override
    public void terminate() {
        cancel();
        HandlerList.unregisterAll(this);
    }

    @Override
    public void run() {
        SplaySet<FactionWorth> factions = plugin.getWorthManager().getOrderedFactions();

        for (Map.Entry<Integer, Collection<BlockPos>> entry : signs.asMap().entrySet()) {
            // Do nothing if rank is higher than factions size.
            if (entry.getKey() >= factions.size()) continue;

            // Get the faction worth.
            FactionWorth worth = factions.byIndex(entry.getKey());

            // Do not update signs if previous value is unchanged.
            double previousWorth = previous.getOrDefault(entry.getKey(), 0d);
            if (previousWorth == worth.getTotalWorth()) {
                continue;
            }

            previous.put(entry.getKey(), worth.getTotalWorth());

            // Update all signs.
            for (BlockPos pos : entry.getValue()) {
                Block block = pos.getBlock(plugin.getServer());
                if (block == null || !(block.getState() instanceof Sign)) {
                    plugin.getServer().getScheduler().runTask(plugin, () -> removeSign(pos));
                    continue;
                }

                Sign sign = (Sign) block.getState();
                sign.setLine(2, worth.getName());
                sign.setLine(3, plugin.getSettings().getCurrencyFormat().format(worth.getTotalWorth()));
                sign.update();
            }
        }
    }

    @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
    public void registerSign(SignChangeEvent event) {
        // Do nothing if the sign should not be registered.
        if (!event.getPlayer().hasPermission("factionstop.sign.create") ||
                !signRegex.matcher(event.getLine(0).toLowerCase()).find()) {
            return;
        }

        // Attempt to parse the rank for this sign.
        int rank;
        try {
            rank = Integer.parseInt(event.getLine(1));
        } catch (NumberFormatException e) {
            event.getPlayer().sendMessage(ChatColor.RED + "Invalid rank number on line 2!");
            event.setLine(0, ChatColor.DARK_RED + "[FactionsTop]");
            return;
        }

        event.setLine(0, ChatColor.DARK_BLUE + "[FactionsTop]");
        event.setLine(1, "#" + Math.max(rank, 1));

        rank = Math.max(rank - 1, 0);
        SplaySet<FactionWorth> factions = plugin.getWorthManager().getOrderedFactions();

        if (factions.size() > rank) {
            FactionWorth worth = factions.byIndex(rank);
            event.setLine(2, worth.getName());
            event.setLine(3, plugin.getSettings().getCurrencyFormat().format(worth.getTotalWorth()));
        } else {
            event.setLine(2, "-");
            event.setLine(3, "$0.00");
        }

        saveSign(BlockPos.of(event.getBlock()), rank);
    }

    private void saveSign(BlockPos pos, int rank) {
        signs.put(rank, pos);
        plugin.getPersistenceTask().queueCreatedSign(pos, rank);
    }

    @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
    public void unregisterSign(BlockBreakEvent event) {
        // Do nothing if block is not a registered sign.
        BlockPos pos = BlockPos.of(event.getBlock());
        if (!signs.containsValue(pos)) {
            return;
        }

        if (!(event.getBlock().getState() instanceof Sign)) {
            removeSign(pos);
            return;
        }

        if (!event.getPlayer().hasPermission("factionstop.sign.break")) {
            event.getPlayer().sendMessage(plugin.getSettings().getPermissionMessage());
            event.setCancelled(true);
            return;
        }

        removeSign(pos);
    }

    private void removeSign(BlockPos pos) {
        signs.values().remove(pos);
        plugin.getPersistenceTask().queueDeletedSign(pos);
    }
} public final class WorthManager implements PluginService {

    private final FactionsTopPlugin plugin;
    private final Map<ChunkPos, ChunkWorth> chunks = new HashMap<>();
    private final Map<String, FactionWorth> factions = new HashMap<>();
    private final SplaySet<FactionWorth> orderedFactions = SplaySet.create();
    private final Table<ChunkPos, WorthType, Double> recalculateQueue = HashBasedTable.create();
    private final Table<ChunkPos, Material, Integer> materialsQueue = HashBasedTable.create();

    public WorthManager(FactionsTopPlugin plugin) {
        this.plugin = plugin;
    }

    /**
     * Returns an unmodifiable view of the ordered factions.
     *
     * @return the ordered factions.
     */
    public SplaySet<FactionWorth> getOrderedFactions() {
        return orderedFactions;
    }

    /**
     * Returns all loaded faction IDs.
     *
     * @return all faction IDs.
     */
    public Set<String> getFactionIds() {
        return Collections.unmodifiableSet(factions.keySet());
    }

    /**
     * Gets the worth for a particular faction ID.
     *
     * @param factionId the faction ID.
     * @return the {@link FactionWorth} or null if no profile found.
     */
    public FactionWorth getWorth(String factionId) {
        return factions.get(factionId);
    }

    @Override
    public void initialize() {
    }

    @Override
    public void terminate() {
    }

    public Map<ChunkPos, ChunkWorth> getChunks() {
        return chunks;
    }

    public void loadChunks(Map<ChunkPos, ChunkWorth> chunks) {
        this.chunks.clear();
        this.chunks.putAll(chunks);
    }

    public void updateAllFactions() {
        factions.clear();

        for (Map.Entry<ChunkPos, ChunkWorth> chunk : chunks.entrySet()) {
            FactionWorth worth = getFactionWorth(chunk.getKey());
            if (worth != null) {
                worth.addAll(chunk.getValue());
            }
        }

        if (plugin.getSettings().isEnabled(WorthType.PLAYER_BALANCE)) {
            for (FactionWorth faction : factions.values()) {
                List<UUID> members = plugin.getFactionsHook().getMembers(faction.getFactionId());
                double balance = plugin.getEconomyHook().getTotalBalance(members);
                faction.addWorth(WorthType.PLAYER_BALANCE, balance);
            }
        }

        if (plugin.getSettings().isEnabled(WorthType.FACTION_BALANCE)) {
            for (FactionWorth faction : factions.values()) {
                double balance = plugin.getEconomyHook().getFactionBalance(faction.getFactionId());
                faction.addWorth(WorthType.FACTION_BALANCE, balance);
            }
        }

        orderedFactions.clear();

        for (FactionWorth worth : factions.values()) {
            orderedFactions.add(worth);
            plugin.getPersistenceTask().queue(worth);
        }
    }

    /**
     * Gets the chunk worth profile for a specific chunk.
     *
     * @param pos the position of the chunk.
     * @return the chunk profile.
     */
    private ChunkWorth getChunkWorth(ChunkPos pos) {
        return chunks.compute(pos, (k, v) -> {
            if (v == null) {
                v = new ChunkWorth();
            }
            return v;
        });
    }

    /**
     * Gets a faction worth profile by a chunk.
     *
     * @param pos the chunk.
     * @return the worth of a faction who has claimed this chunk, or null if no
     * valid faction owns this land.
     */
    private FactionWorth getFactionWorth(ChunkPos pos) {
        return getFactionWorth(plugin.getFactionsHook().getFactionAt(pos));
    }

    /**
     * Gets the faction worth profile by a faction ID.
     *
     * @param factionId the faction ID.
     * @return the faction worth profile or null of not a valid faction.
     */
    private FactionWorth getFactionWorth(String factionId) {
        // No faction worth is associated with ignored faction IDs.
        if (plugin.getSettings().getIgnoredFactionIds().contains(factionId)) {
            return null;
        }

        return factions.compute(factionId, (k, v) -> {
            if (v == null) {
                v = new FactionWorth(k, plugin.getFactionsHook().getFactionName(k));
                orderedFactions.add(v);
            }
            return v;
        });
    }

    /**
     * Sets a chunks worth.
     *
     * @param pos       the chunk position.
     * @param worthType the worth type.
     * @param worth     the worth value.
     */
    public void set(ChunkPos pos, WorthType worthType, double worth) {
        // Do nothing if faction worth is null.
        FactionWorth factionWorth = getFactionWorth(pos);
        if (factionWorth == null) return;

        orderedFactions.remove(factionWorth);

        // Update all stats with the new chunk data.
        ChunkWorth chunkWorth = getChunkWorth(pos);
        double oldWorth = chunkWorth.getWorth(worthType);
        chunkWorth.setWorth(worthType, worth);
        factionWorth.addWorth(worthType, worth - oldWorth);

        // If this position was added to the recalculate queue, add all queued
        // updates while the chunk was recalculated and set the next time to
        // recalculate the chunk.
        if (recalculateQueue.contains(pos, worthType)) {
            double queuedWorth = recalculateQueue.remove(pos, worthType);
            chunkWorth.addWorth(worthType, queuedWorth);
            factionWorth.addWorth(worthType, queuedWorth);
            chunkWorth.setNextRecalculation(plugin.getSettings().getChunkRecalculateMillis() + System.currentTimeMillis());
        }

        orderedFactions.add(factionWorth);
    }

    public void setMaterials(ChunkPos pos, Map<Material, Integer> materials) {
        // Do nothing if faction worth is null.
        FactionWorth factionWorth = getFactionWorth(pos);
        if (factionWorth == null) return;

        // Update all stats with the new chunk data.
        ChunkWorth chunkWorth = getChunkWorth(pos);
        factionWorth.removeMaterials(chunkWorth.getMaterials());
        chunkWorth.setMaterials(materials);
        factionWorth.addMaterials(materials);

        // Add all back all modifications made since the update was scheduled.
        if (materialsQueue.containsRow(pos)) {
            Map<Material, Integer> queued = materialsQueue.row(pos);
            chunkWorth.addMaterials(queued);
            factionWorth.addMaterials(queued);
            queued.clear();
        }

        plugin.getPersistenceTask().queue(pos, chunkWorth);
        plugin.getPersistenceTask().queue(factionWorth);
    }

    private void setSpawners(ChunkPos pos, Map<EntityType, Integer> spawners) {
        // Do nothing if faction worth is null.
        FactionWorth factionWorth = getFactionWorth(pos);
        if (factionWorth == null) return;

        // Update all stats with the new chunk data.
        ChunkWorth chunkWorth = getChunkWorth(pos);
        factionWorth.removeSpawners(chunkWorth.getSpawners());
        chunkWorth.setSpawners(spawners);
        factionWorth.addSpawners(spawners);
    }

    /**
     * Adds worth to a chunk.
     *
     * @param chunk     the chunk.
     * @param reason    the reason.
     * @param worthType the worth type.
     * @param worth     the worth value.
     */
    public void add(Chunk chunk, RecalculateReason reason, WorthType worthType, double worth,
                    Map<Material, Integer> materials, Map<EntityType, Integer> spawners) {
        // Do nothing if worth type is disabled or worth is nothing.
        if (!plugin.getSettings().isEnabled(worthType) || worth == 0) {
            return;
        }

        // Do nothing if faction worth is null.
        ChunkPos pos = ChunkPos.of(chunk);
        FactionWorth factionWorth = getFactionWorth(pos);
        if (factionWorth == null) return;

        orderedFactions.remove(factionWorth);

        // Update all stats with the new chunk data.
        ChunkWorth chunkWorth = getChunkWorth(pos);
        chunkWorth.addWorth(worthType, worth);
        chunkWorth.addMaterials(materials);
        chunkWorth.addSpawners(spawners);

        factionWorth.addWorth(worthType, worth);
        factionWorth.addMaterials(materials);
        factionWorth.addSpawners(spawners);

        orderedFactions.add(factionWorth);

        // Add this worth to the recalculation queue if the chunk is being
        // recalculated.
        if (materialsQueue.containsRow(pos)) {
            materialsQueue.row(pos).putAll(materials);
        }

        if (recalculateQueue.contains(pos, worthType)) {
            double prev = recalculateQueue.get(pos, worthType);
            recalculateQueue.put(pos, worthType, worth + prev);
            return;
        }

        // Schedule chunk for recalculation.
        recalculate(chunkWorth, pos, chunk, reason);
    }

    /**
     * Attempts to schedule a chunk for recalculation if the chunk is allowed
     * to be recalculated at this time.
     *
     * @param chunk  the chunk.
     * @param reason the reason for recalculating.
     */
    public void recalculate(Chunk chunk, RecalculateReason reason) {
        ChunkPos pos = ChunkPos.of(chunk);
        if (getFactionWorth(pos) == null) return;

        ChunkWorth chunkWorth = getChunkWorth(pos);
        recalculate(chunkWorth, pos, chunk, reason);
    }

    /**
     * Attempts to schedule a chunk for recalculation if the chunk is allowed
     * to be recalculated at this time.
     *
     * @param chunkWorth the worth associated with this chunk.
     * @param pos        the chunk position.
     * @param chunk      the chunk.
     * @param reason     the reason for recalculating.
     */
    private void recalculate(ChunkWorth chunkWorth, ChunkPos pos, Chunk chunk, RecalculateReason reason) {
        // Do not recalculate the chunk value if not within the recalculation period.
        if (chunkWorth.getNextRecalculation() >= System.currentTimeMillis() &&
                !plugin.getSettings().isBypassRecalculateDelay(reason) ||
                !plugin.getSettings().isPerformRecalculate(reason) ||
                plugin.getSettings().getChunkQueueSize() <= plugin.getChunkWorthTask().getQueueSize()) {
            FactionWorth factionWorth = getFactionWorth(pos);
            if (factionWorth != null) {
                plugin.getPersistenceTask().queue(pos, chunkWorth);
                plugin.getPersistenceTask().queue(factionWorth);
            }
            return;
        }

        // Next recalculation is scheduled once the chunk worth is re-set.
        chunkWorth.setNextRecalculation(Long.MAX_VALUE);

        // Schedule this chunk to be recalculated on a separate thread.
        if (reason == RecalculateReason.UNLOAD) {
            forceRecalculate(pos, chunk);
        }

        // Occasionally block updates are not updated in the chunk on the
        // same tick, getting the chunk snapshot in the next tick fixes
        // this issue. This does not apply to chunk unloads.
        else {
            plugin.getServer().getScheduler().runTask(plugin, () ->
                    forceRecalculate(pos, chunk));
        }
    }

    private void forceRecalculate(ChunkPos pos, Chunk chunk) {
        // Clear the recalculate queue in the event of multiple block
        // changes in the same tick.
        recalculateQueue.row(pos).clear();

        // Update the chunk spawner worth on the main thread, unfortunately
        // there is no better method of doing this. Same with chests.
        Map<EntityType, Integer> spawners = new EnumMap<>(EntityType.class);
        Map<Material, Integer> materials = new EnumMap<>(Material.class);

        if (plugin.getSettings().isEnabled(WorthType.SPAWNER)) {
            set(pos, WorthType.SPAWNER, getSpawnerWorth(chunk, spawners));
        }

        if (plugin.getSettings().isEnabled(WorthType.CHEST)) {
            set(pos, WorthType.CHEST, getChestWorth(chunk, materials, spawners));
        }

        setSpawners(pos, spawners);
        materialsQueue.row(pos).putAll(materials);

        plugin.getChunkWorthTask().queue(chunk.getChunkSnapshot());
    }

    /**
     * Calculates the spawner worth of a chunk.
     *
     * @param chunk    the chunk.
     * @param spawners the spawner totals to add to.
     * @return the chunk worth in spawners.
     */
    private double getSpawnerWorth(Chunk chunk, Map<EntityType, Integer> spawners) {
        int count;
        double worth = 0;

        for (BlockState blockState : chunk.getTileEntities()) {
            if (!(blockState instanceof CreatureSpawner)) {
                continue;
            }

            CreatureSpawner spawner = (CreatureSpawner) blockState;
            EntityType spawnType = spawner.getSpawnedType();
            int stackSize = plugin.getSpawnerStackerHook().getStackSize(spawner);
            double blockPrice = plugin.getSettings().getSpawnerPrice(spawnType) * stackSize;
            worth += blockPrice;

            if (blockPrice != 0) {
                count = spawners.getOrDefault(spawnType, 0);
                spawners.put(spawnType, count + stackSize);
            }
        }

        return worth;
    }

    /**
     * Calculates the chest worth of a chunk.
     *
     * @param chunk     the chunk.
     * @param materials the material totals to add to.
     * @param spawners  the spawner totals to add to.
     * @return the chunk worth in materials.
     */
    private double getChestWorth(Chunk chunk, Map<Material, Integer> materials, Map<EntityType, Integer> spawners) {
        int count;
        double worth = 0;
        double materialPrice;
        EntityType spawnerType;

        for (BlockState blockState : chunk.getTileEntities()) {
            if (!(blockState instanceof Chest)) {
                continue;
            }

            Chest chest = (Chest) blockState;

            for (ItemStack item : chest.getBlockInventory()) {
                if (item == null) continue;

                int stackSize = item.getAmount();

                switch (item.getType()) {
                    case MOB_SPAWNER:
                        stackSize *= plugin.getSpawnerStackerHook().getStackSize(item);
                        spawnerType = plugin.getSpawnerStackerHook().getSpawnedType(item);
                        double price = plugin.getSettings().getSpawnerPrice(spawnerType);
                        materialPrice = price * stackSize;
                        break;
                    default:
                        materialPrice = plugin.getSettings().getBlockPrice(item.getType()) * stackSize;
                        spawnerType = null;
                        break;
                }

                worth += materialPrice;

                if (materialPrice != 0) {
                    if (spawnerType == null) {
                        count = materials.getOrDefault(item.getType(), 0);
                        materials.put(item.getType(), count + stackSize);
                    } else {
                        count = spawners.getOrDefault(spawnerType, 0);
                        spawners.put(spawnerType, count + stackSize);
                    }
                }
            }
        }

        return worth;
    }

    /**
     * Updates the factions worth once the faction has claimed or unclaimed territory.
     *
     * @param factionId the faction ID.
     * @param claims    the claims affected in this transaction.
     * @param unclaimed true if the claims were unclaimed.
     */
    public void update(String factionId, Collection<ChunkPos> claims, boolean unclaimed) {
        // Do nothing if faction worth is null.
        FactionWorth factionWorth = getFactionWorth(factionId);
        if (factionWorth == null) return;

        orderedFactions.remove(factionWorth);

        // Add all placed and chest worth of each claim to the faction.
        for (ChunkPos pos : claims) {
            Chunk chunk = pos.getChunk(plugin.getServer());
            if (chunk == null) continue;

            ChunkWorth chunkWorth = getChunkWorth(pos);
            for (WorthType worthType : WorthType.getPlaced()) {
                double worth = chunkWorth.getWorth(worthType);
                factionWorth.addWorth(worthType, unclaimed ? -worth : worth);
            }

            if (unclaimed) {
                factionWorth.removeMaterials(chunkWorth.getMaterials());
                factionWorth.removeSpawners(chunkWorth.getSpawners());
            } else {
                factionWorth.addMaterials(chunkWorth.getMaterials());
                factionWorth.addSpawners(chunkWorth.getSpawners());
            }

            // Schedule chunk for recalculation.
            recalculate(chunkWorth, pos, chunk, RecalculateReason.CLAIM);
        }

        orderedFactions.add(factionWorth);
    }

    /**
     * Adds to a faction worth.
     *
     * @param factionId the faction ID.
     * @param worthType the worth type.
     * @param worth     the worth to add.
     */
    public void add(String factionId, WorthType worthType, double worth) {
        // Do nothing if the worth type is placed or disabled or worth is equal to nothing.
        if (WorthType.isPlaced(worthType) || !plugin.getSettings().isEnabled(worthType) || worth == 0) {
            return;
        }

        // Do nothing if faction worth is null.
        FactionWorth factionWorth = getFactionWorth(factionId);
        if (factionWorth == null) return;

        // Update faction with the new worth and adjust the worth position.
        orderedFactions.remove(factionWorth);
        factionWorth.addWorth(worthType, worth);
        orderedFactions.add(factionWorth);
        plugin.getPersistenceTask().queue(factionWorth);
    }

    /**
     * Renames a listed faction.
     *
     * @param factionId the faction ID.
     * @param newName   the new faction name.
     */
    public void rename(String factionId, String newName) {
        FactionWorth factionWorth = factions.getOrDefault(factionId, null);

        if (factionWorth != null) {
            factionWorth.setName(newName);
            plugin.getPersistenceTask().queue(factionWorth);
        }
    }

    /**
     * Removes a faction from the list.
     *
     * @param factionId the ID of the faction to remove.
     */
    public void remove(String factionId) {
        FactionWorth factionWorth = factions.remove(factionId);
        orderedFactions.remove(factionWorth);
        plugin.getPersistenceTask().queueDeletedFaction(factionId);
    }
} public class LastReplacer implements Supplier<String> {

    private final FactionsTopPlugin plugin;

    public LastReplacer(FactionsTopPlugin plugin) {
        this.plugin = plugin;
    }

    @Override
    public String get() {
        SplaySet<FactionWorth> factions = plugin.getWorthManager().getOrderedFactions();

        if (factions.isEmpty()) {
            return plugin.getSettings().getPlaceholdersFactionNotFound();
        }

        return factions.last().getName();
    }
} public class PlayerReplacer implements Function<Player, String> {

    private final FactionsTopPlugin plugin;

    public PlayerReplacer(FactionsTopPlugin plugin) {
        this.plugin = plugin;
    }

    @Override
    public String apply(Player player) {
        String faction = plugin.getFactionsHook().getFaction(player);
        if (faction == null) {
            return plugin.getSettings().getPlaceholdersFactionNotFound();
        }

        FactionWorth factionWorth = plugin.getWorthManager().getWorth(faction);
        if (factionWorth == null) {
            return plugin.getSettings().getPlaceholdersFactionNotFound();
        }

        int rank = plugin.getWorthManager().getOrderedFactions().indexOf(factionWorth) + 1;
        if (rank <= 0) {
            return plugin.getSettings().getPlaceholdersFactionNotFound();
        }

        return Integer.toString(rank);
    }
} public class RankReplacer implements Function<Integer, String> {

    private final FactionsTopPlugin plugin;

    public RankReplacer(FactionsTopPlugin plugin) {
        this.plugin = plugin;
    }

    @Override
    public String apply(Integer rank) {
        SplaySet<FactionWorth> factions = plugin.getWorthManager().getOrderedFactions();

        if (rank > 0 && factions.size() >= rank) {
            return factions.byIndex(rank - 1).getName();
        }

        return plugin.getSettings().getPlaceholdersFactionNotFound();
    }
} public class ChunkWorthTask extends Thread {

    private final FactionsTopPlugin plugin;
    private final BlockingQueue<ChunkSnapshot> queue = new LinkedBlockingQueue<>();

    public ChunkWorthTask(FactionsTopPlugin plugin) {
        super("factions-top-chunk-task");
        this.plugin = plugin;
    }

    public void queue(ChunkSnapshot snapshot) {
        queue.add(snapshot);
    }

    public int getQueueSize() {
        return queue.size();
    }

    @Override
    public void run() {
        while (!isInterrupted()) {
            ChunkSnapshot snapshot;
            try {
                snapshot = queue.take();
            } catch (InterruptedException e) {
                interrupt();
                break;
            }

            ChunkPos pos = ChunkPos.of(snapshot);
            double worth = 0;
            double blockPrice;
            Map<Material, Integer> materials = new EnumMap<>(Material.class);

            for (int y = 0; y < 256; y++) {
                // ChunkSnapshot#getHighestBlockYAt(x, y) for whatever reason
                // provides us with a half complete chunk in Spigot v1.10.x. So
                // we're testing if the chunk section is empty instead.
                if (snapshot.isSectionEmpty(y >> 4)) {
                    y += 15;
                    continue;
                }

                for (int x = 0; x < 16; x++) {
                    for (int z = 0; z < 16; z++) {
                        Material material = Material.getMaterial(snapshot.getBlockTypeId(x, y, z));
                        if (material == null) {
                            continue;
                        }

                        blockPrice = plugin.getSettings().getBlockPrice(material);
                        worth += blockPrice;

                        if (blockPrice != 0) {
                            int count = materials.getOrDefault(material, 0);
                            materials.put(material, count + 1);
                        }
                    }
                }
            }

            final double worthFinal = worth;
            plugin.getServer().getScheduler().runTask(plugin, () -> {
                plugin.getWorthManager().set(pos, WorthType.BLOCK, worthFinal);
                plugin.getWorthManager().setMaterials(pos, materials);
            });
        }
    }
} public class PersistenceTask extends Thread {

    private final FactionsTopPlugin plugin;
    private final Map<ChunkPos, ChunkWorth> worthCache = new HashMap<>();
    private final BlockingQueue<Map.Entry<ChunkPos, ChunkWorth>> chunkQueue = new LinkedBlockingQueue<>();
    private final BlockingQueue<FactionWorth> factionQueue = new LinkedBlockingQueue<>();
    private final BlockingQueue<String> factionDeletionQueue = new LinkedBlockingQueue<>();
    private final BlockingQueue<Map.Entry<BlockPos, Integer>> signCreationQueue = new LinkedBlockingQueue<>();
    private final BlockingQueue<BlockPos> signDeletionQueue = new LinkedBlockingQueue<>();

    public void queue(ChunkPos pos, ChunkWorth chunkWorth) {
        ChunkWorth cachedWorth = worthCache.get(pos);

        if (cachedWorth == null || !chunkWorthEquals(chunkWorth, cachedWorth)) {
            chunkQueue.add(new AbstractMap.SimpleImmutableEntry<>(pos, chunkWorth));
            worthCache.put(pos, clone(chunkWorth));
        }
    }

    public void queue(FactionWorth factionWorth) {
        if (plugin.getSettings().isDatabasePersistFactions()) {
            factionQueue.add(factionWorth);
        }
    }

    public void queue(Collection<FactionWorth> factions) {
        if (plugin.getSettings().isDatabasePersistFactions()) {
            factionQueue.addAll(factions);
        }
    }

    public void queueDeletedFaction(String factionId) {
        if (plugin.getSettings().isDatabasePersistFactions()) {
            factionDeletionQueue.add(factionId);
        }
    }

    public void queueCreatedSign(BlockPos block, int rank) {
        signCreationQueue.add(new AbstractMap.SimpleImmutableEntry<>(block, rank));
    }

    public void queueDeletedSign(BlockPos block) {
        signDeletionQueue.add(block);
    }

    public PersistenceTask(FactionsTopPlugin plugin) {
        super("factions-top-persistence-task");
        this.plugin = plugin;
    }

    @Override
    public void start() {
        loadChunkCache();
        super.start();
    }

    @Override
    public void run() {
        while (!isInterrupted()) {
            if (plugin.getSettings().isDatabasePersistFactions()) {
                deleteInvalidFactions();
            }

            persist();

            try {
                sleep(plugin.getSettings().getDatabasePersistInterval());
            } catch (InterruptedException e) {
                interrupt();
                break;
            }
        }

        persist();
    }

    private void deleteInvalidFactions() {
        Set<String> factionIds = plugin.getFactionsHook().getFactionIds();

        for (String factionId : plugin.getDatabaseManager().getIdentityCache().getFactionIds()) {
            if (!factionIds.contains(factionId)) {
                factionDeletionQueue.add(factionId);
            }
        }
    }

    private void persist() {
        Set<Map.Entry<ChunkPos, ChunkWorth>> chunks = new HashSet<>();
        chunkQueue.drainTo(chunks);

        Set<FactionWorth> factions = new HashSet<>();
        factionQueue.drainTo(factions);

        Set<String> deletedFactions = new HashSet<>();
        factionDeletionQueue.drainTo(deletedFactions);

        Set<Map.Entry<BlockPos, Integer>> createdSigns = new HashSet<>();
        signCreationQueue.drainTo(createdSigns);

        Set<BlockPos> deletedSigns = new HashSet<>();
        signDeletionQueue.drainTo(deletedSigns);

        try {
            plugin.getDatabaseManager().save(chunks, factions, deletedFactions, createdSigns, deletedSigns);
        } catch (SQLException e) {
            plugin.getLogger().log(Level.SEVERE, "Failed to persist queued updates to the database");
            plugin.getLogger().log(Level.SEVERE, "Are the database credentials in the config correct?");
            plugin.getLogger().log(Level.SEVERE, "Stack trace: ", e);

            plugin.getLogger().log(Level.INFO, "Attempting to regenerate HikariCP datasource...");

            try {
                IdentityCache identityCache = plugin.getDatabaseManager().getIdentityCache();
                plugin.getDatabaseManager().close();
                plugin.setDatabaseManager(DatabaseManager.create(plugin.getSettings().getHikariConfig(), identityCache));
                plugin.getLogger().log(Level.SEVERE, "Regeneration successful, issue is with HikariCP datasource");
            } catch (SQLException ignore) {
                plugin.getLogger().log(Level.SEVERE, "Failed to regenerate, issue not with HikariCP datasource");
            }
        }
    }

    private void loadChunkCache() {
        plugin.getWorthManager().getChunks().forEach((chunkPos, chunkWorth) ->
                worthCache.put(chunkPos, clone(chunkWorth)));
    }

    private ChunkWorth clone(ChunkWorth chunkWorth) {
        Map<WorthType, Double> worth = new EnumMap<>(WorthType.class);
        worth.putAll(chunkWorth.getWorth());

        Map<Material, Integer> materials = new EnumMap<>(Material.class);
        materials.putAll(chunkWorth.getMaterials());

        Map<EntityType, Integer> spawners = new EnumMap<>(EntityType.class);
        spawners.putAll(chunkWorth.getSpawners());

        return new ChunkWorth(worth, materials, spawners);
    }

    private boolean chunkWorthEquals(ChunkWorth chunkWorth1, ChunkWorth chunkWorth2) {
        return Objects.equals(chunkWorth1.getWorth(), chunkWorth2.getWorth()) &&
                Objects.equals(chunkWorth1.getMaterials(), chunkWorth2.getMaterials()) &&
                Objects.equals(chunkWorth1.getSpawners(), chunkWorth2.getSpawners());
    }
} public class RecalculateTask implements PluginService, Runnable {

    private final FactionsTopPlugin plugin;
    private final Stack<ChunkPos> toRecalculate = new Stack<>();
    private int taskId;

    public RecalculateTask(FactionsTopPlugin plugin) {
        this.plugin = plugin;
    }

    @Override
    public void initialize() {
        if (!isRunning()) {
            toRecalculate.addAll(plugin.getFactionsHook().getClaims());
            taskId = plugin.getServer().getScheduler().runTaskTimer(plugin, this, 1, 1).getTaskId();
            plugin.getServer().broadcastMessage(plugin.getSettings().getRecalculationStartMessage());
        } else {
            throw new IllegalStateException("Recalculation task is already running");
        }
    }

    @Override
    public void terminate() {
        if (isRunning()) {
            toRecalculate.clear();
            plugin.getServer().getScheduler().cancelTask(taskId);
            plugin.getServer().broadcastMessage(plugin.getSettings().getRecalculationStopMessage());
        } else {
            throw new IllegalStateException("No recalculation task was running");
        }
    }

    public boolean isRunning() {
        return !toRecalculate.isEmpty();
    }

    @Override
    public void run() {
        int counter = plugin.getSettings().getRecalculateChunksPerTick();

        while (isRunning()) {
            if (counter-- <= 0) {
                break;
            }

            ChunkPos pos = toRecalculate.pop();
            Chunk chunk = pos.getChunk(plugin.getServer());
            if (chunk != null && chunk.load()) {
                plugin.getWorthManager().recalculate(chunk, RecalculateReason.COMMAND);
            }
        }

        if (!isRunning()) {
            plugin.getWorthManager().updateAllFactions();
            plugin.getServer().getScheduler().cancelTask(taskId);
            plugin.getServer().broadcastMessage(plugin.getSettings().getRecalculationFinishMessage());
        }
    }
}
import com.google.common.collect.Multimap;
import net.novucs.ftop.command.*;
import net.novucs.ftop.entity.BlockPos;
import net.novucs.ftop.entity.ChunkPos;
import net.novucs.ftop.entity.ChunkWorth;
import net.novucs.ftop.hook.*;
import net.novucs.ftop.listener.ChatListener;
import net.novucs.ftop.listener.CommandListener;
import net.novucs.ftop.listener.GuiListener;
import net.novucs.ftop.listener.WorthListener;
import net.novucs.ftop.manager.DatabaseManager;
import net.novucs.ftop.manager.GuiManager;
import net.novucs.ftop.manager.SignManager;
import net.novucs.ftop.manager.WorthManager;
import net.novucs.ftop.replacer.LastReplacer;
import net.novucs.ftop.replacer.PlayerReplacer;
import net.novucs.ftop.replacer.RankReplacer;
import net.novucs.ftop.task.ChunkWorthTask;
import net.novucs.ftop.task.PersistenceTask;
import net.novucs.ftop.task.RecalculateTask;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.java.JavaPlugin;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.lang.reflect.Method;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.channels.Channels;
import java.nio.channels.ReadableByteChannel;
import java.sql.SQLException;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.logging.Level;
package net.novucs.ftop;



public final class FactionsTopPlugin extends JavaPlugin {

    private final ChunkWorthTask chunkWorthTask = new ChunkWorthTask(this);
    private final GuiManager guiManager = new GuiManager(this);
    private final PersistenceTask persistenceTask = new PersistenceTask(this);
    private final RecalculateTask recalculateTask = new RecalculateTask(this);
    private final Settings settings = new Settings(this);
    private final SignManager signManager = new SignManager(this);
    private final WorthManager worthManager = new WorthManager(this);
    private final Set<PluginService> services = new HashSet<>(Arrays.asList(
            signManager,
            worthManager,
            new GuiCommand(this),
            new RecalculateCommand(this),
            new ReloadCommand(this),
            new TextCommand(this),
            new VersionCommand(this),
            new ChatListener(this),
            new CommandListener(this),