package io.papermc.paper.chunk.system;

import ca.spottedleaf.concurrentutil.collection.SRSWLinkedQueue;
import ca.spottedleaf.concurrentutil.executor.standard.PrioritisedExecutor;
import ca.spottedleaf.concurrentutil.util.ConcurrentUtil;
import io.papermc.paper.chunk.system.scheduling.ChunkHolderManager;
import io.papermc.paper.configuration.GlobalConfiguration;
import io.papermc.paper.util.CoordinateUtils;
import io.papermc.paper.util.TickThread;
import io.papermc.paper.util.player.SingleUserAreaMap;
import it.unimi.dsi.fastutil.longs.Long2ByteOpenHashMap;
import it.unimi.dsi.fastutil.longs.LongArrayList;
import it.unimi.dsi.fastutil.longs.LongComparator;
import it.unimi.dsi.fastutil.longs.LongHeapPriorityQueue;
import it.unimi.dsi.fastutil.longs.LongIterator;
import it.unimi.dsi.fastutil.longs.LongLinkedOpenHashSet;
import it.unimi.dsi.fastutil.longs.LongOpenHashSet;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientboundSetChunkCacheCenterPacket;
import net.minecraft.network.protocol.game.ClientboundSetChunkCacheRadiusPacket;
import net.minecraft.network.protocol.game.ClientboundSetSimulationDistancePacket;
import net.minecraft.server.level.ChunkTrackingView;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.level.TicketType;
import net.minecraft.server.network.PlayerChunkSender;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.GameRules;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.levelgen.BelowZeroRetrogen;
import org.bukkit.craftbukkit.entity.CraftPlayer;
import org.bukkit.entity.Player;
import java.lang.invoke.VarHandle;
import java.util.ArrayDeque;
import java.util.Arrays;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;

public class RegionizedPlayerChunkLoader {

    // expected that this list returns for a given radius, the set of chunks ordered
    // by manhattan distance
    private static final long[][] SEARCH_RADIUS_ITERATION_LIST = new long[64+2+1][];
    static {
        for (int i = 0; i < SEARCH_RADIUS_ITERATION_LIST.length; ++i) {
            // a BFS around -x, -z, +x, +z will give increasing manhatten distance
            SEARCH_RADIUS_ITERATION_LIST[i] = generateBFSOrder(i);
        }
    }

    private static void expandQuadrants(final CustomLongArray input, final int size) {
        final int len = input.size();
        final long[] array = input.elements();

        int writeIndex = size - 1;
        for (int i = len - 1; i >= 0; --i) {
            final long key = array[i];
            final int chunkX = CoordinateUtils.getChunkX(key);
            final int chunkZ = CoordinateUtils.getChunkZ(key);

            if ((chunkX | chunkZ) < 0 || (i != 0 && chunkX == 0 && chunkZ == 0)) {
                throw new IllegalStateException();
            }

            // Q4
            if (chunkZ != 0) {
                array[writeIndex--] = CoordinateUtils.getChunkKey(chunkX, -chunkZ);
            }
            // Q3
            if (chunkX != 0 && chunkZ != 0) {
                array[writeIndex--] = CoordinateUtils.getChunkKey(-chunkX, -chunkZ);
            }
            // Q2
            if (chunkX != 0) {
                array[writeIndex--] = CoordinateUtils.getChunkKey(-chunkX, chunkZ);
            }

            array[writeIndex--] = key;
        }

        input.forceSize(size);

        if (writeIndex != -1) {
            throw new IllegalStateException();
        }
    }

    private static long[] generateBFSOrder(final int radius) {
        // by using only the first quadrant, we can reduce the total element size by 4 when spreading
        final CustomLongArray[] byDistance = makeQ1BFS(radius);

        // to increase generation parallelism, we want to space the chunks out so that they are not nearby when generating
        // this also means we are minimising locality
        // but, we need to maintain sorted order by manhatten distance

        // per manhatten distance we transform the chunk list so that each element is maximally spaced out from each other
        for (int i = 0, len = byDistance.length; i < len; ++i) {
            final CustomLongArray points = byDistance[i];
            final int expectedSize = getDistanceSize(i, radius);

            final CustomLongArray spread = spread(points, expectedSize);
            // add in Q2, Q3, Q4
            expandQuadrants(spread, expectedSize);

            byDistance[i] = spread;
        }

        // now, rebuild the list so that it still maintains manhatten distance order
        final CustomLongArray ret = new CustomLongArray((2 * radius + 1) * (2 * radius + 1));

        for (final CustomLongArray dist : byDistance) {
            ret.addAll(dist);
        }

        return ret.elements();
    }

    public static final TicketType<Long> REGION_PLAYER_TICKET = TicketType.create("region_player_ticket", Long::compareTo);

    public static final int MIN_VIEW_DISTANCE = 2;
    public static final int MAX_VIEW_DISTANCE = 32;

    public static final int TICK_TICKET_LEVEL = 31;
    public static final int GENERATED_TICKET_LEVEL = 33 + ChunkStatus.getDistance(ChunkStatus.FULL);
    public static final int LOADED_TICKET_LEVEL = 33 + ChunkStatus.getDistance(ChunkStatus.EMPTY);

    public static final record ViewDistances(
        int tickViewDistance,
        int loadViewDistance,
        int sendViewDistance
    ) {
        public ViewDistances setTickViewDistance(final int distance) {
            return new ViewDistances(distance, this.loadViewDistance, this.sendViewDistance);
        }

        public ViewDistances setLoadViewDistance(final int distance) {
            return new ViewDistances(this.tickViewDistance, distance, this.sendViewDistance);
        }


        public ViewDistances setSendViewDistance(final int distance) {
            return new ViewDistances(this.tickViewDistance, this.loadViewDistance, distance);
        }
    }

    public static int getAPITickViewDistance(final Player player) {
        return getAPITickViewDistance(((CraftPlayer)player).getHandle());
    }

    public static int getAPITickViewDistance(final ServerPlayer player) {
        final ServerLevel level = (ServerLevel)player.level();
        final PlayerChunkLoaderData data = player.chunkLoader;
        if (data == null) {
            return level.playerChunkLoader.getAPITickDistance();
        }
        return data.lastTickDistance;
    }

    public static int getAPIViewDistance(final Player player) {
        return getAPIViewDistance(((CraftPlayer)player).getHandle());
    }

    public static int getAPIViewDistance(final ServerPlayer player) {
        final ServerLevel level = (ServerLevel)player.level();
        final PlayerChunkLoaderData data = player.chunkLoader;
        if (data == null) {
            return level.playerChunkLoader.getAPIViewDistance();
        }
        // view distance = load distance + 1
        return data.lastLoadDistance - 1;
    }

    public static int getLoadViewDistance(final ServerPlayer player) {
        final ServerLevel level = (ServerLevel)player.level();
        final PlayerChunkLoaderData data = player.chunkLoader;
        if (data == null) {
            return level.playerChunkLoader.getAPIViewDistance();
        }
        // view distance = load distance + 1
        return data.lastLoadDistance - 1;
    }

    public static int getAPISendViewDistance(final Player player) {
        return getAPISendViewDistance(((CraftPlayer)player).getHandle());
    }

    public static int getAPISendViewDistance(final ServerPlayer player) {
        final ServerLevel level = (ServerLevel)player.level();
        final PlayerChunkLoaderData data = player.chunkLoader;
        if (data == null) {
            return level.playerChunkLoader.getAPISendViewDistance();
        }
        return data.lastSendDistance;
    }

    private final ServerLevel world;

    public RegionizedPlayerChunkLoader(final ServerLevel world) {
        this.world = world;
    }

    public void addPlayer(final ServerPlayer player) {
        TickThread.ensureTickThread(player, "Cannot add player to player chunk loader async");
        if (!player.isRealPlayer) {
            return;
        }

        if (player.chunkLoader != null) {
            throw new IllegalStateException("Player is already added to player chunk loader");
        }

        final PlayerChunkLoaderData loader = new PlayerChunkLoaderData(this.world, player);

        player.chunkLoader = loader;
        loader.add();
    }

    public void updatePlayer(final ServerPlayer player) {
        final PlayerChunkLoaderData loader = player.chunkLoader;
        if (loader != null) {
            loader.update();
        }
    }

    public void removePlayer(final ServerPlayer player) {
        TickThread.ensureTickThread(player, "Cannot remove player from player chunk loader async");
        if (!player.isRealPlayer) {
            return;
        }

        final PlayerChunkLoaderData loader = player.chunkLoader;

        if (loader == null) {
            return;
        }

        loader.remove();
        player.chunkLoader = null;
    }

    public void setSendDistance(final int distance) {
        this.world.setSendViewDistance(distance);
    }

    public void setLoadDistance(final int distance) {
        this.world.setLoadViewDistance(distance);
    }

    public void setTickDistance(final int distance) {
        this.world.setTickViewDistance(distance);
    }

    // Note: follow the player chunk loader so everything stays consistent...
    public int getAPITickDistance() {
        final ViewDistances distances = this.world.getViewDistances();
        final int tickViewDistance = PlayerChunkLoaderData.getTickDistance(-1, distances.tickViewDistance);
        return tickViewDistance;
    }

    public int getAPIViewDistance() {
        final ViewDistances distances = this.world.getViewDistances();
        final int tickViewDistance = PlayerChunkLoaderData.getTickDistance(-1, distances.tickViewDistance);
        final int loadDistance = PlayerChunkLoaderData.getLoadViewDistance(tickViewDistance, -1, distances.loadViewDistance);

        // loadDistance = api view distance + 1
        return loadDistance - 1;
    }

    public int getAPISendViewDistance() {
        final ViewDistances distances = this.world.getViewDistances();
        final int tickViewDistance = PlayerChunkLoaderData.getTickDistance(-1, distances.tickViewDistance);
        final int loadDistance = PlayerChunkLoaderData.getLoadViewDistance(tickViewDistance, -1, distances.loadViewDistance);
        final int sendViewDistance = PlayerChunkLoaderData.getSendViewDistance(
            loadDistance, -1, -1, distances.sendViewDistance
        );

        return sendViewDistance;
    }

    public boolean isChunkSent(final ServerPlayer player, final int chunkX, final int chunkZ, final boolean borderOnly) {
        return borderOnly ? this.isChunkSentBorderOnly(player, chunkX, chunkZ) : this.isChunkSent(player, chunkX, chunkZ);
    }

    public boolean isChunkSent(final ServerPlayer player, final int chunkX, final int chunkZ) {
        final PlayerChunkLoaderData loader = player.chunkLoader;
        if (loader == null) {
            return false;
        }

        return loader.sentChunks.contains(CoordinateUtils.getChunkKey(chunkX, chunkZ));
    }

    public boolean isChunkSentBorderOnly(final ServerPlayer player, final int chunkX, final int chunkZ) {
        final PlayerChunkLoaderData loader = player.chunkLoader;
        if (loader == null) {
            return false;
        }

        for (int dz = -1; dz <= 1; ++dz) {
            for (int dx = -1; dx <= 1; ++dx) {
                if (!loader.sentChunks.contains(CoordinateUtils.getChunkKey(dx + chunkX, dz + chunkZ))) {
                    return true;
                }
            }
        }

        return false;
    }

    public void tick() {
        TickThread.ensureTickThread("Cannot tick player chunk loader async");
        long currTime = System.nanoTime();
        for (final ServerPlayer player : new java.util.ArrayList<>(this.world.players())) {
            final PlayerChunkLoaderData loader = player.chunkLoader;
            if (loader == null || loader.world != this.world) {
                // not our problem anymore
                continue;
            }
            loader.update(); // can't invoke plugin logic
            loader.updateQueues(currTime);
        }
    }

    public static final class PlayerChunkLoaderData {

        private static final AtomicLong ID_GENERATOR = new AtomicLong();
        private final long id = ID_GENERATOR.incrementAndGet();
        private final Long idBoxed = Long.valueOf(this.id);

        private static final long MAX_RATE = 10_000L;

        private final ServerPlayer player;
        private final ServerLevel world;

        private int lastChunkX = Integer.MIN_VALUE;
        private int lastChunkZ = Integer.MIN_VALUE;

        private int lastSendDistance = Integer.MIN_VALUE;
        private int lastLoadDistance = Integer.MIN_VALUE;
        private int lastTickDistance = Integer.MIN_VALUE;

        private int lastSentChunkCenterX = Integer.MIN_VALUE;
        private int lastSentChunkCenterZ = Integer.MIN_VALUE;

        private int lastSentChunkRadius = Integer.MIN_VALUE;
        private int lastSentSimulationDistance = Integer.MIN_VALUE;

        private boolean canGenerateChunks = true;

        private final ArrayDeque<ChunkHolderManager.TicketOperation<?, ?>> delayedTicketOps = new ArrayDeque<>();
        private final LongOpenHashSet sentChunks = new LongOpenHashSet();

        private static final byte CHUNK_TICKET_STAGE_NONE           = 0;
        private static final byte CHUNK_TICKET_STAGE_LOADING        = 1;
        private static final byte CHUNK_TICKET_STAGE_LOADED         = 2;
        private static final byte CHUNK_TICKET_STAGE_GENERATING     = 3;
        private static final byte CHUNK_TICKET_STAGE_GENERATED      = 4;
        private static final byte CHUNK_TICKET_STAGE_TICK           = 5;
        private static final int[] TICKET_STAGE_TO_LEVEL = new int[] {
            ChunkHolderManager.MAX_TICKET_LEVEL + 1,
            LOADED_TICKET_LEVEL,
            LOADED_TICKET_LEVEL,
            GENERATED_TICKET_LEVEL,
            GENERATED_TICKET_LEVEL,
            TICK_TICKET_LEVEL
        };
        private final Long2ByteOpenHashMap chunkTicketStage = new Long2ByteOpenHashMap();
        {
            this.chunkTicketStage.defaultReturnValue(CHUNK_TICKET_STAGE_NONE);
        }

        // rate limiting
        private final AllocatingRateLimiter chunkSendLimiter = new AllocatingRateLimiter();
        private final AllocatingRateLimiter chunkLoadTicketLimiter = new AllocatingRateLimiter();
        private final AllocatingRateLimiter chunkGenerateTicketLimiter = new AllocatingRateLimiter();

        // queues
        private final LongComparator CLOSEST_MANHATTAN_DIST = (final long c1, final long c2) -> {
            final int c1x = CoordinateUtils.getChunkX(c1);
            final int c1z = CoordinateUtils.getChunkZ(c1);

            final int c2x = CoordinateUtils.getChunkX(c2);
            final int c2z = CoordinateUtils.getChunkZ(c2);

            final int centerX = PlayerChunkLoaderData.this.lastChunkX;
            final int centerZ = PlayerChunkLoaderData.this.lastChunkZ;

            return Integer.compare(
                Math.abs(c1x - centerX) + Math.abs(c1z - centerZ),
                Math.abs(c2x - centerX) + Math.abs(c2z - centerZ)
            );
        };
        private final LongHeapPriorityQueue sendQueue = new LongHeapPriorityQueue(CLOSEST_MANHATTAN_DIST);
        private final LongHeapPriorityQueue tickingQueue = new LongHeapPriorityQueue(CLOSEST_MANHATTAN_DIST);
        private final LongHeapPriorityQueue generatingQueue = new LongHeapPriorityQueue(CLOSEST_MANHATTAN_DIST);
        private final LongHeapPriorityQueue genQueue = new LongHeapPriorityQueue(CLOSEST_MANHATTAN_DIST);
        private final LongHeapPriorityQueue loadingQueue = new LongHeapPriorityQueue(CLOSEST_MANHATTAN_DIST);
        private final LongHeapPriorityQueue loadQueue = new LongHeapPriorityQueue(CLOSEST_MANHATTAN_DIST);

        private volatile boolean removed;

        public PlayerChunkLoaderData(final ServerLevel world, final ServerPlayer player) {
            this.world = world;
            this.player = player;
        }

        private void flushDelayedTicketOps() {
            if (this.delayedTicketOps.isEmpty()) {
                return;
            }
            this.world.chunkTaskScheduler.chunkHolderManager.performTicketUpdates(this.delayedTicketOps);
            this.delayedTicketOps.clear();
        }

        private void pushDelayedTicketOp(final ChunkHolderManager.TicketOperation<?, ?> op) {
            this.delayedTicketOps.addLast(op);
        }

        private void sendChunk(final int chunkX, final int chunkZ) {
            if (this.sentChunks.add(CoordinateUtils.getChunkKey(chunkX, chunkZ))) {
                PlayerChunkSender.sendChunk(this.player.connection, this.world, this.world.getChunkIfLoaded(chunkX, chunkZ));
                return;
            }
            throw new IllegalStateException();
        }

        private void sendUnloadChunk(final int chunkX, final int chunkZ) {
            if (!this.sentChunks.remove(CoordinateUtils.getChunkKey(chunkX, chunkZ))) {
                return;
            }
            this.sendUnloadChunkRaw(chunkX, chunkZ);
        }

        private void sendUnloadChunkRaw(final int chunkX, final int chunkZ) {
            PlayerChunkSender.dropChunkStatic(this.player, new ChunkPos(chunkX, chunkZ));
        }

        private final SingleUserAreaMap<PlayerChunkLoaderData> broadcastMap = new SingleUserAreaMap<>(this) {
            @Override
            protected void addCallback(final PlayerChunkLoaderData parameter, final int chunkX, final int chunkZ) {
                // do nothing, we only care about remove
            }

            @Override
            protected void removeCallback(final PlayerChunkLoaderData parameter, final int chunkX, final int chunkZ) {
                parameter.sendUnloadChunk(chunkX, chunkZ);
            }
        };
        private final SingleUserAreaMap<io.papermc.paper.chunk.system.RegionizedPlayerChunkLoader.PlayerChunkLoaderData> loadTicketCleanup = new SingleUserAreaMap<>(this) {
            @Override
            protected void addCallback(final PlayerChunkLoaderData parameter, final int chunkX, final int chunkZ) {
                // do nothing, we only care about remove
            }

            @Override
            protected void removeCallback(final PlayerChunkLoaderData parameter, final int chunkX, final int chunkZ) {
                final long chunk = CoordinateUtils.getChunkKey(chunkX, chunkZ);
                final byte ticketStage = parameter.chunkTicketStage.remove(chunk);
                final int level = TICKET_STAGE_TO_LEVEL[ticketStage];
                if (level > ChunkHolderManager.MAX_TICKET_LEVEL) {
                    return;
                }

                parameter.pushDelayedTicketOp(ChunkHolderManager.TicketOperation.addAndRemove(
                    chunk,
                    TicketType.UNKNOWN, level, new ChunkPos(chunkX, chunkZ),
                    REGION_PLAYER_TICKET, level, parameter.idBoxed
                ));
            }
        };
        private final SingleUserAreaMap<PlayerChunkLoaderData> tickMap = new SingleUserAreaMap<>(this) {
            @Override
            protected void addCallback(final PlayerChunkLoaderData parameter, final int chunkX, final int chunkZ) {
                // do nothing, we will detect ticking chunks when we try to load them
            }

            @Override
            protected void removeCallback(final PlayerChunkLoaderData parameter, final int chunkX, final int chunkZ) {
                final long chunk = CoordinateUtils.getChunkKey(chunkX, chunkZ);
                // note: by the time this is called, the tick cleanup should have ran - so, if the chunk is at
                // the tick stage it was deemed in range for loading. Thus, we need to move it to generated
                if (!parameter.chunkTicketStage.replace(chunk, CHUNK_TICKET_STAGE_TICK, CHUNK_TICKET_STAGE_GENERATED)) {
                    return;
                }

                // Since we are possibly downgrading the ticket level, we add an unknown ticket so that
                // the level is kept until tick().
                parameter.pushDelayedTicketOp(ChunkHolderManager.TicketOperation.addAndRemove(
                    chunk,
                    TicketType.UNKNOWN, TICK_TICKET_LEVEL, new ChunkPos(chunkX, chunkZ),
                    REGION_PLAYER_TICKET, TICK_TICKET_LEVEL, parameter.idBoxed
                ));
                // keep chunk at new generated level
                parameter.pushDelayedTicketOp(ChunkHolderManager.TicketOperation.addOp(
                    chunk,
                    REGION_PLAYER_TICKET, GENERATED_TICKET_LEVEL, parameter.idBoxed
                ));
            }
        };

        private static boolean wantChunkLoaded(final int centerX, final int centerZ, final int chunkX, final int chunkZ,
                                               final int sendRadius) {
            // expect sendRadius to be = 1 + target viewable radius
            return ChunkTrackingView.isWithinDistance(centerX, centerZ, sendRadius, chunkX, chunkZ, true);
        }

        private static int getClientViewDistance(final ServerPlayer player) {
            final Integer vd = player.requestedViewDistance();
            return vd == null ? -1 : Math.max(0, vd.intValue());
        }

        private static int getTickDistance(final int playerTickViewDistance, final int worldTickViewDistance) {
            return playerTickViewDistance < 0 ? worldTickViewDistance : playerTickViewDistance;
        }

        private static int getLoadViewDistance(final int tickViewDistance, final int playerLoadViewDistance,
                                               final int worldLoadViewDistance) {
            return Math.max(tickViewDistance + 1, playerLoadViewDistance < 0 ? worldLoadViewDistance : playerLoadViewDistance);
        }

        private static int getSendViewDistance(final int loadViewDistance, final int clientViewDistance,
                                               final int playerSendViewDistance, final int worldSendViewDistance) {
            return Math.min(
                loadViewDistance - 1,
                playerSendViewDistance < 0 ? (!GlobalConfiguration.get().chunkLoadingAdvanced.autoConfigSendDistance || clientViewDistance < 0 ? (worldSendViewDistance < 0 ? (loadViewDistance - 1) : worldSendViewDistance) : clientViewDistance + 1) : playerSendViewDistance
            );
        }

        private Packet<?> updateClientChunkRadius(final int radius) {
            this.lastSentChunkRadius = radius;
            return new ClientboundSetChunkCacheRadiusPacket(radius);
        }

        private Packet<?> updateClientSimulationDistance(final int distance) {
            this.lastSentSimulationDistance = distance;
            return new ClientboundSetSimulationDistancePacket(distance);
        }

        private Packet<?> updateClientChunkCenter(final int chunkX, final int chunkZ) {
            this.lastSentChunkCenterX = chunkX;
            this.lastSentChunkCenterZ = chunkZ;
            return new ClientboundSetChunkCacheCenterPacket(chunkX, chunkZ);
        }

        private boolean canPlayerGenerateChunks() {
            return !this.player.isSpectator() || this.world.getGameRules().getBoolean(GameRules.RULE_SPECTATORSGENERATECHUNKS);
        }

        private double getMaxChunkLoadRate() {
            final double configRate = GlobalConfiguration.get().chunkLoadingBasic.playerMaxChunkLoadRate;

            return configRate < 0.0 || configRate > (double)MAX_RATE ? (double)MAX_RATE : Math.max(1.0, configRate);
        }

        private double getMaxChunkGenRate() {
            final double configRate = GlobalConfiguration.get().chunkLoadingBasic.playerMaxChunkGenerateRate;

            return configRate < 0.0 || configRate > (double)MAX_RATE ? (double)MAX_RATE : Math.max(1.0, configRate);
        }

        private double getMaxChunkSendRate() {
            final double configRate = GlobalConfiguration.get().chunkLoadingBasic.playerMaxChunkSendRate;

            return configRate < 0.0 || configRate > (double)MAX_RATE ? (double)MAX_RATE : Math.max(1.0, configRate);
        }

        private long getMaxChunkLoads() {
            final long radiusChunks = (2L * this.lastLoadDistance + 1L) * (2L * this.lastLoadDistance + 1L);
            long configLimit = GlobalConfiguration.get().chunkLoadingAdvanced.playerMaxConcurrentChunkLoads;
            if (configLimit == 0L) {
                // by default, only allow 1/5th of the chunks in the view distance to be concurrently active
                configLimit = Math.max(5L, radiusChunks / 5L);
            } else if (configLimit < 0L) {
                configLimit = Integer.MAX_VALUE;
            } // else: use the value configured
            configLimit = configLimit - this.loadingQueue.size();

            return configLimit;
        }

        private long getMaxChunkGenerates() {
            final long radiusChunks = (2L * this.lastLoadDistance + 1L) * (2L * this.lastLoadDistance + 1L);
            long configLimit = GlobalConfiguration.get().chunkLoadingAdvanced.playerMaxConcurrentChunkGenerates;
            if (configLimit == 0L) {
                // by default, only allow 1/5th of the chunks in the view distance to be concurrently active
                configLimit = Math.max(5L, radiusChunks / 5L);
            } else if (configLimit < 0L) {
                configLimit = Integer.MAX_VALUE;
            } // else: use the value configured
            configLimit = configLimit - this.generatingQueue.size();

            return configLimit;
        }

        private boolean wantChunkSent(final int chunkX, final int chunkZ) {
            final int dx = this.lastChunkX - chunkX;
            final int dz = this.lastChunkZ - chunkZ;
            return (Math.max(Math.abs(dx), Math.abs(dz)) <= (this.lastSendDistance + 1)) && wantChunkLoaded(
                this.lastChunkX, this.lastChunkZ, chunkX, chunkZ, this.lastSendDistance
            );
        }

        private boolean wantChunkTicked(final int chunkX, final int chunkZ) {
            final int dx = this.lastChunkX - chunkX;
            final int dz = this.lastChunkZ - chunkZ;
            return Math.max(Math.abs(dx), Math.abs(dz)) <= this.lastTickDistance;
        }

        void updateQueues(final long time) {
            TickThread.ensureTickThread(this.player, "Cannot tick player chunk loader async");
            if (this.removed) {
                throw new IllegalStateException("Ticking removed player chunk loader");
            }
            // update rate limits
            final double loadRate = this.getMaxChunkLoadRate();
            final double genRate = this.getMaxChunkGenRate();
            final double sendRate = this.getMaxChunkSendRate();

            this.chunkLoadTicketLimiter.tickAllocation(time, loadRate, loadRate);
            this.chunkGenerateTicketLimiter.tickAllocation(time, genRate, genRate);
            this.chunkSendLimiter.tickAllocation(time, sendRate, sendRate);

            // try to progress chunk loads
            while (!this.loadingQueue.isEmpty()) {
                final long pendingLoadChunk = this.loadingQueue.firstLong();
                final int pendingChunkX = CoordinateUtils.getChunkX(pendingLoadChunk);
                final int pendingChunkZ = CoordinateUtils.getChunkZ(pendingLoadChunk);
                final ChunkAccess pending = this.world.chunkSource.getChunkAtImmediately(pendingChunkX, pendingChunkZ);
                if (pending == null) {
                    // nothing to do here
                    break;
                }
                // chunk has loaded, so we can take it out of the queue
                this.loadingQueue.dequeueLong();

                // try to move to generate queue
                final byte prev = this.chunkTicketStage.put(pendingLoadChunk, CHUNK_TICKET_STAGE_LOADED);
                if (prev != CHUNK_TICKET_STAGE_LOADING) {
                    throw new IllegalStateException("Previous state should be " + CHUNK_TICKET_STAGE_LOADING + ", not " + prev);
                }

                if (this.canGenerateChunks || this.isLoadedChunkGeneratable(pending)) {
                    this.genQueue.enqueue(pendingLoadChunk);
                } // else: don't want to generate, so just leave it loaded
            }

            // try to push more chunk loads
            final long maxLoads = Math.max(0L, Math.min(MAX_RATE, Math.min(this.loadQueue.size(), this.getMaxChunkLoads())));
            final int maxLoadsThisTick = (int)this.chunkLoadTicketLimiter.takeAllocation(time, loadRate, maxLoads);
            if (maxLoadsThisTick > 0) {
                final LongArrayList chunks = new LongArrayList(maxLoadsThisTick);
                for (int i = 0; i < maxLoadsThisTick; ++i) {
                    final long chunk = this.loadQueue.dequeueLong();
                    final byte prev = this.chunkTicketStage.put(chunk, CHUNK_TICKET_STAGE_LOADING);
                    if (prev != CHUNK_TICKET_STAGE_NONE) {
                        throw new IllegalStateException("Previous state should be " + CHUNK_TICKET_STAGE_NONE + ", not " + prev);
                    }
                    this.pushDelayedTicketOp(
                        ChunkHolderManager.TicketOperation.addOp(
                            chunk,
                            REGION_PLAYER_TICKET, LOADED_TICKET_LEVEL, this.idBoxed
                        )
                    );
                    chunks.add(chunk);
                    this.loadingQueue.enqueue(chunk);
                }

                // here we need to flush tickets, as scheduleChunkLoad requires tickets to be propagated with addTicket = false
                this.flushDelayedTicketOps();
                // we only need to call scheduleChunkLoad because the loaded ticket level is not enough to start the chunk
                // load - only generate ticket levels start anything, but they start generation...
                // propagate levels
                // Note: this CAN call plugin logic, so it is VITAL that our bookkeeping logic is completely done by the time this is invoked
                this.world.chunkTaskScheduler.chunkHolderManager.processTicketUpdates();

                if (this.removed) {
                    // process ticket updates may invoke plugin logic, which may remove this player
                    return;
                }

                for (int i = 0; i < maxLoadsThisTick; ++i) {
                    final long queuedLoadChunk = chunks.getLong(i);
                    final int queuedChunkX = CoordinateUtils.getChunkX(queuedLoadChunk);
                    final int queuedChunkZ = CoordinateUtils.getChunkZ(queuedLoadChunk);
                    this.world.chunkTaskScheduler.scheduleChunkLoad(
                        queuedChunkX, queuedChunkZ, ChunkStatus.EMPTY, false, PrioritisedExecutor.Priority.NORMAL, null
                    );
                    if (this.removed) {
                        return;
                    }
                }
            }

            // try to progress chunk generations
            while (!this.generatingQueue.isEmpty()) {
                final long pendingGenChunk = this.generatingQueue.firstLong();
                final int pendingChunkX = CoordinateUtils.getChunkX(pendingGenChunk);
                final int pendingChunkZ = CoordinateUtils.getChunkZ(pendingGenChunk);
                final LevelChunk pending = this.world.chunkSource.getChunkAtIfLoadedMainThreadNoCache(pendingChunkX, pendingChunkZ);
                if (pending == null) {
                    // nothing to do here
                    break;
                }

                // chunk has generated, so we can take it out of queue
                this.generatingQueue.dequeueLong();

                final byte prev = this.chunkTicketStage.put(pendingGenChunk, CHUNK_TICKET_STAGE_GENERATED);
                if (prev != CHUNK_TICKET_STAGE_GENERATING) {
                    throw new IllegalStateException("Previous state should be " + CHUNK_TICKET_STAGE_GENERATING + ", not " + prev);
                }

                // try to move to send queue
                if (this.wantChunkSent(pendingChunkX, pendingChunkZ)) {
                    this.sendQueue.enqueue(pendingGenChunk);
                }
                // try to move to tick queue
                if (this.wantChunkTicked(pendingChunkX, pendingChunkZ)) {
                    this.tickingQueue.enqueue(pendingGenChunk);
                }
            }

            // try to push more chunk generations
            final long maxGens = Math.max(0L, Math.min(MAX_RATE, Math.min(this.genQueue.size(), this.getMaxChunkGenerates())));
            final int maxGensThisTick = (int)this.chunkGenerateTicketLimiter.takeAllocation(time, genRate, maxGens);
            int ratedGensThisTick = 0;
            while (!this.genQueue.isEmpty()) {
                final long chunkKey = this.genQueue.firstLong();
                final int chunkX = CoordinateUtils.getChunkX(chunkKey);
                final int chunkZ = CoordinateUtils.getChunkZ(chunkKey);
                final ChunkAccess chunk = this.world.chunkSource.getChunkAtImmediately(chunkX, chunkZ);
                if (chunk.getStatus() != ChunkStatus.FULL) {
                    // only rate limit actual generations
                    if ((ratedGensThisTick + 1) > maxGensThisTick) {
                        break;
                    }
                    ++ratedGensThisTick;
                }

                this.genQueue.dequeueLong();

                final byte prev = this.chunkTicketStage.put(chunkKey, CHUNK_TICKET_STAGE_GENERATING);
                if (prev != CHUNK_TICKET_STAGE_LOADED) {
                    throw new IllegalStateException("Previous state should be " + CHUNK_TICKET_STAGE_LOADED + ", not " + prev);
                }
                this.pushDelayedTicketOp(
                    ChunkHolderManager.TicketOperation.addAndRemove(
                        chunkKey,
                        REGION_PLAYER_TICKET, GENERATED_TICKET_LEVEL, this.idBoxed,
                        REGION_PLAYER_TICKET, LOADED_TICKET_LEVEL, this.idBoxed
                    )
                );
                this.generatingQueue.enqueue(chunkKey);
            }

            // try to pull ticking chunks
            tick_check_outer:
            while (!this.tickingQueue.isEmpty()) {
                final long pendingTicking = this.tickingQueue.firstLong();
                final int pendingChunkX = CoordinateUtils.getChunkX(pendingTicking);
                final int pendingChunkZ = CoordinateUtils.getChunkZ(pendingTicking);

                final int tickingReq = 2;
                for (int dz = -tickingReq; dz <= tickingReq; ++dz) {
                    for (int dx = -tickingReq; dx <= tickingReq; ++dx) {
                        if ((dx | dz) == 0) {
                            continue;
                        }
                        final long neighbour = CoordinateUtils.getChunkKey(dx + pendingChunkX, dz + pendingChunkZ);
                        final byte stage = this.chunkTicketStage.get(neighbour);
                        if (stage != CHUNK_TICKET_STAGE_GENERATED && stage != CHUNK_TICKET_STAGE_TICK) {
                            break tick_check_outer;
                        }
                    }
                }
                // only gets here if all neighbours were marked as generated or ticking themselves
                this.tickingQueue.dequeueLong();
                this.pushDelayedTicketOp(
                    ChunkHolderManager.TicketOperation.addAndRemove(
                        pendingTicking,
                        REGION_PLAYER_TICKET, TICK_TICKET_LEVEL, this.idBoxed,
                        REGION_PLAYER_TICKET, GENERATED_TICKET_LEVEL, this.idBoxed
                    )
                );
                // there is no queue to add after ticking
                final byte prev = this.chunkTicketStage.put(pendingTicking, CHUNK_TICKET_STAGE_TICK);
                if (prev != CHUNK_TICKET_STAGE_GENERATED) {
                    throw new IllegalStateException("Previous state should be " + CHUNK_TICKET_STAGE_GENERATED + ", not " + prev);
                }
            }

            // try to pull sending chunks
            final long maxSends = Math.max(0L, Math.min(MAX_RATE, Integer.MAX_VALUE)); // no logic to track concurrent sends
            final int maxSendsThisTick = Math.min((int)this.chunkSendLimiter.takeAllocation(time, sendRate, maxSends), this.sendQueue.size());
            // we do not return sends that we took from the allocation back because we want to limit the max send rate, not target it
            for (int i = 0; i < maxSendsThisTick; ++i) {
                final long pendingSend = this.sendQueue.firstLong();
                final int pendingSendX = CoordinateUtils.getChunkX(pendingSend);
                final int pendingSendZ = CoordinateUtils.getChunkZ(pendingSend);
                final LevelChunk chunk = this.world.chunkSource.getChunkAtIfLoadedMainThreadNoCache(pendingSendX, pendingSendZ);
                if (!chunk.areNeighboursLoaded(1) || !TickThread.isTickThreadFor(this.world, pendingSendX, pendingSendZ)) {
                    // nothing to do
                    // the target chunk may not be owned by this region, but this should be resolved in the future
                    break;
                }
                if (!chunk.isPostProcessingDone) {
                    // not yet post-processed, need to do this so that tile entities can properly be sent to clients
                    chunk.postProcessGeneration();
                    // check if there was any recursive action
                    if (this.removed || this.sendQueue.isEmpty() || this.sendQueue.firstLong() != pendingSend) {
                        return;
                    } // else: good to dequeue and send, fall through
                }
                this.sendQueue.dequeueLong();

                this.sendChunk(pendingSendX, pendingSendZ);
                if (this.removed) {
                    // sendChunk may invoke plugin logic
                    return;
                }
            }

            this.flushDelayedTicketOps();
            // we assume propagate ticket levels happens after this call
        }

        void add() {
            TickThread.ensureTickThread(this.player, "Cannot add player asynchronously");
            if (this.removed) {
                throw new IllegalStateException("Adding removed player chunk loader");
            }
            final ViewDistances playerDistances = this.player.getViewDistances();
            final ViewDistances worldDistances = this.world.getViewDistances();
            final int chunkX = this.player.chunkPosition().x;
            final int chunkZ = this.player.chunkPosition().z;

            final int tickViewDistance = getTickDistance(playerDistances.tickViewDistance, worldDistances.tickViewDistance);
            // load view cannot be less-than tick view + 1
            final int loadViewDistance = getLoadViewDistance(tickViewDistance, playerDistances.loadViewDistance, worldDistances.loadViewDistance);
            // send view cannot be greater-than load view
            final int clientViewDistance = getClientViewDistance(this.player);
            final int sendViewDistance = getSendViewDistance(loadViewDistance, clientViewDistance, playerDistances.sendViewDistance, worldDistances.sendViewDistance);

            // send view distances
            this.player.connection.send(this.updateClientChunkRadius(sendViewDistance));
            this.player.connection.send(this.updateClientSimulationDistance(tickViewDistance));

            // add to distance maps
            this.broadcastMap.add(chunkX, chunkZ, sendViewDistance + 1);
            this.loadTicketCleanup.add(chunkX, chunkZ, loadViewDistance + 1);
            this.tickMap.add(chunkX, chunkZ, tickViewDistance);

            // update chunk center
            this.player.connection.send(this.updateClientChunkCenter(chunkX, chunkZ));

            // now we can update
            this.update();
        }

        private boolean isLoadedChunkGeneratable(final int chunkX, final int chunkZ) {
            return this.isLoadedChunkGeneratable(this.world.chunkSource.getChunkAtImmediately(chunkX, chunkZ));
        }

        private boolean isLoadedChunkGeneratable(final ChunkAccess chunkAccess) {
            final BelowZeroRetrogen belowZeroRetrogen;
            // see PortalForcer#findPortalAround
            return chunkAccess != null && (
                chunkAccess.getStatus() == ChunkStatus.FULL ||
                    ((belowZeroRetrogen = chunkAccess.getBelowZeroRetrogen()) != null && belowZeroRetrogen.targetStatus().isOrAfter(ChunkStatus.SPAWN))
            );
        }

        void update() {
            TickThread.ensureTickThread(this.player, "Cannot update player asynchronously");
            if (this.removed) {
                throw new IllegalStateException("Updating removed player chunk loader");
            }
            final ViewDistances playerDistances = this.player.getViewDistances();
            final ViewDistances worldDistances = this.world.getViewDistances();

            final int tickViewDistance = getTickDistance(playerDistances.tickViewDistance, worldDistances.tickViewDistance);
            // load view cannot be less-than tick view + 1
            final int loadViewDistance = getLoadViewDistance(tickViewDistance, playerDistances.loadViewDistance, worldDistances.loadViewDistance);
            // send view cannot be greater-than load view
            final int clientViewDistance = getClientViewDistance(this.player);
            final int sendViewDistance = getSendViewDistance(loadViewDistance, clientViewDistance, playerDistances.sendViewDistance, worldDistances.sendViewDistance);

            final ChunkPos playerPos = this.player.chunkPosition();
            final boolean canGenerateChunks = this.canPlayerGenerateChunks();
            final int currentChunkX = playerPos.x;
            final int currentChunkZ = playerPos.z;

            final int prevChunkX = this.lastChunkX;
            final int prevChunkZ = this.lastChunkZ;

            if (
                // has view distance stayed the same?
                sendViewDistance == this.lastSendDistance
                    && loadViewDistance == this.lastLoadDistance
                    && tickViewDistance == this.lastTickDistance

                    // has our chunk stayed the same?
                    && prevChunkX == currentChunkX
                    && prevChunkZ == currentChunkZ

                    // can we still generate chunks?
                    && this.canGenerateChunks == canGenerateChunks
            ) {
                // nothing we care about changed, so we're not re-calculating
                return;
            }

            // update distance maps
            this.broadcastMap.update(currentChunkX, currentChunkZ, sendViewDistance + 1);
            this.loadTicketCleanup.update(currentChunkX, currentChunkZ, loadViewDistance + 1);
            this.tickMap.update(currentChunkX, currentChunkZ, tickViewDistance);
            if (sendViewDistance > loadViewDistance || tickViewDistance > loadViewDistance) {
                throw new IllegalStateException();
            }

            // update VDs for client
            // this should be after the distance map updates, as they will send unload packets
            if (this.lastSentChunkRadius != sendViewDistance) {
                this.player.connection.send(this.updateClientChunkRadius(sendViewDistance));
            }
            if (this.lastSentSimulationDistance != tickViewDistance) {
                this.player.connection.send(this.updateClientSimulationDistance(tickViewDistance));
            }

            this.sendQueue.clear();
            this.tickingQueue.clear();
            this.generatingQueue.clear();
            this.genQueue.clear();
            this.loadingQueue.clear();
            this.loadQueue.clear();

            this.lastChunkX = currentChunkX;
            this.lastChunkZ = currentChunkZ;
            this.lastSendDistance = sendViewDistance;
            this.lastLoadDistance = loadViewDistance;
            this.lastTickDistance = tickViewDistance;
            this.canGenerateChunks = canGenerateChunks;

            // +1 since we need to load chunks +1 around the load view distance...
            final long[] toIterate = SEARCH_RADIUS_ITERATION_LIST[loadViewDistance + 1];
            // the iteration order is by increasing manhattan distance - so, we do NOT need to
            // sort anything in the queue!
            for (final long deltaChunk : toIterate) {
                final int dx = CoordinateUtils.getChunkX(deltaChunk);
                final int dz = CoordinateUtils.getChunkZ(deltaChunk);
                final int chunkX = dx + currentChunkX;
                final int chunkZ = dz + currentChunkZ;
                final long chunk = CoordinateUtils.getChunkKey(chunkX, chunkZ);
                final int squareDistance = Math.max(Math.abs(dx), Math.abs(dz));
                final int manhattanDistance = Math.abs(dx) + Math.abs(dz);

                // since chunk sending is not by radius alone, we need an extra check here to account for
                // everything <= sendDistance
                // Note: Vanilla may want to send chunks outside the send view distance, so we do need
                // the dist <= view check
                final boolean sendChunk = (squareDistance <= (sendViewDistance + 1))
                    && wantChunkLoaded(currentChunkX, currentChunkZ, chunkX, chunkZ, sendViewDistance);
                final boolean sentChunk = sendChunk ? this.sentChunks.contains(chunk) : this.sentChunks.remove(chunk);

                if (!sendChunk && sentChunk) {
                    // have sent the chunk, but don't want it anymore
                    // unload it now
                    this.sendUnloadChunkRaw(chunkX, chunkZ);
                }

                final byte stage = this.chunkTicketStage.get(chunk);
                switch (stage) {
                    case CHUNK_TICKET_STAGE_NONE: {
                        // we want the chunk to be at least loaded
                        this.loadQueue.enqueue(chunk);
                        break;
                    }
                    case CHUNK_TICKET_STAGE_LOADING: {
                        this.loadingQueue.enqueue(chunk);
                        break;
                    }
                    case CHUNK_TICKET_STAGE_LOADED: {
                        if (canGenerateChunks || this.isLoadedChunkGeneratable(chunkX, chunkZ)) {
                            this.genQueue.enqueue(chunk);
                        }
                        break;
                    }
                    case CHUNK_TICKET_STAGE_GENERATING: {
                        this.generatingQueue.enqueue(chunk);
                        break;
                    }
                    case CHUNK_TICKET_STAGE_GENERATED: {
                        if (sendChunk && !sentChunk) {
                            this.sendQueue.enqueue(chunk);
                        }
                        if (squareDistance <= tickViewDistance) {
                            this.tickingQueue.enqueue(chunk);
                        }
                        break;
                    }
                    case CHUNK_TICKET_STAGE_TICK: {
                        if (sendChunk && !sentChunk) {
                            this.sendQueue.enqueue(chunk);
                        }
                        break;
                    }
                    default: {
                        throw new IllegalStateException("Unknown stage: " + stage);
                    }
                }
            }

            // update the chunk center
            // this must be done last so that the client does not ignore any of our unload chunk packets above
            if (this.lastSentChunkCenterX != currentChunkX || this.lastSentChunkCenterZ != currentChunkZ) {
                this.player.connection.send(this.updateClientChunkCenter(currentChunkX, currentChunkZ));
            }

            this.flushDelayedTicketOps();
        }

        void remove() {
            TickThread.ensureTickThread(this.player, "Cannot add player asynchronously");
            if (this.removed) {
                throw new IllegalStateException("Removing removed player chunk loader");
            }
            this.removed = true;
            // sends the chunk unload packets
            this.broadcastMap.remove();
            // cleans up loading/generating tickets
            this.loadTicketCleanup.remove();
            // cleans up ticking tickets
            this.tickMap.remove();

            // purge queues
            this.sendQueue.clear();
            this.tickingQueue.clear();
            this.generatingQueue.clear();
            this.genQueue.clear();
            this.loadingQueue.clear();
            this.loadQueue.clear();

            // flush ticket changes
            this.flushDelayedTicketOps();

            // now all tickets should be removed, which is all of our external state
        }

        // For external checks
        public it.unimi.dsi.fastutil.longs.LongOpenHashSet getSentChunksRaw() {
            return this.sentChunks;
        }
    }

    // TODO rebase into util patch
    private static final class AllocatingRateLimiter {

        // max difference granularity in ns
        private static final long MAX_GRANULARITY = TimeUnit.SECONDS.toNanos(1L);

        private double allocation;
        private long lastAllocationUpdate;
        private double takeCarry;
        private long lastTakeUpdate;

        // rate in units/s, and time in ns
        public void tickAllocation(final long time, final double rate, final double maxAllocation) {
            final long diff = Math.min(MAX_GRANULARITY, time - this.lastAllocationUpdate);
            this.lastAllocationUpdate = time;

            this.allocation = Math.min(maxAllocation - this.takeCarry, this.allocation + rate * (diff*1.0E-9D));
        }

        // rate in units/s, and time in ns
        public long takeAllocation(final long time, final double rate, final long maxTake) {
            if (maxTake < 1L) {
                return 0L;
            }

            double ret = this.takeCarry;
            final long diff = Math.min(MAX_GRANULARITY, time - this.lastTakeUpdate);
            this.lastTakeUpdate = time;

            // note: abs(takeCarry) <= 1.0
            final double take = Math.min(Math.min((double)maxTake - this.takeCarry, this.allocation), rate * (diff*1.0E-9));

            ret += take;
            this.allocation -= take;

            final long retInteger = (long)Math.floor(ret);
            this.takeCarry = ret - (double)retInteger;

            return retInteger;
        }
    }

    static final class CountedSRSWLinkedQueue<E> {

        private final SRSWLinkedQueue<E> queue = new SRSWLinkedQueue<>();
        private volatile long countAdded;
        private volatile long countRemoved;

        private static final VarHandle COUNT_ADDED_HANDLE = ConcurrentUtil.getVarHandle(CountedSRSWLinkedQueue.class, "countAdded", long.class);
        private static final VarHandle COUNT_REMOVED_HANDLE = ConcurrentUtil.getVarHandle(CountedSRSWLinkedQueue.class, "countRemoved", long.class);

        private long getCountAddedPlain() {
            return (long)COUNT_ADDED_HANDLE.get(this);
        }

        private long getCountAddedAcquire() {
            return (long)COUNT_ADDED_HANDLE.getAcquire(this);
        }

        private void setCountAddedRelease(final long to) {
            COUNT_ADDED_HANDLE.setRelease(this, to);
        }

        private long getCountRemovedPlain() {
            return (long)COUNT_REMOVED_HANDLE.get(this);
        }

        private long getCountRemovedAcquire() {
            return (long)COUNT_REMOVED_HANDLE.getAcquire(this);
        }

        private void setCountRemovedRelease(final long to) {
            COUNT_REMOVED_HANDLE.setRelease(this, to);
        }

        public void add(final E element) {
            this.setCountAddedRelease(this.getCountAddedPlain() + 1L);
            this.queue.addLast(element);
        }

        public E poll() {
            final E ret = this.queue.poll();
            if (ret != null) {
                this.setCountRemovedRelease(this.getCountRemovedPlain() + 1L);
            }

            return ret;
        }

        public long size() {
            final long removed = this.getCountRemovedAcquire();
            final long added = this.getCountAddedAcquire();

            return added - removed;
        }
    }

    private static class CustomLongArray extends LongArrayList {

        public CustomLongArray() {
            super();
        }

        public CustomLongArray(final int expected) {
            super(expected);
        }

        public boolean addAll(final CustomLongArray list) {
            this.addElements(this.size, list.a, 0, list.size);
            return list.size != 0;
        }

        public void addUnchecked(final long value) {
            this.a[this.size++] = value;
        }

        public void forceSize(final int to) {
            this.size = to;
        }

        @Override
        public int hashCode() {
            long h = 1L;

            Objects.checkFromToIndex(0, this.size, this.a.length);

            for (int i = 0; i < this.size; ++i) {
                h = it.unimi.dsi.fastutil.HashCommon.mix(h + this.a[i]);
            }

            return (int)h;
        }

        @Override
        public boolean equals(final Object o) {
            if (o == this) {
                return true;
            }

            if (!(o instanceof CustomLongArray other)) {
                return false;
            }

            return this.size == other.size && Arrays.equals(this.a, 0, this.size, other.a, 0, this.size);
        }
    }

    private static int getDistanceSize(final int radius, final int max) {
        if (radius == 0) {
            return 1;
        }
        final int diff = radius - max;
        if (diff <= 0) {
            return 4*radius;
        }
        return 4*(max - Math.max(0, diff - 1));
    }

    private static int getQ1DistanceSize(final int radius, final int max) {
        if (radius == 0) {
            return 1;
        }
        final int diff = radius - max;
        if (diff <= 0) {
            return radius+1;
        }
        return max - diff + 1;
    }

    private static final class BasicFIFOLQueue {

        private final long[] values;
        private int head, tail;

        public BasicFIFOLQueue(final int cap) {
            if (cap <= 1) {
                throw new IllegalArgumentException();
            }
            this.values = new long[cap];
        }

        public boolean isEmpty() {
            return this.head == this.tail;
        }

        public long removeFirst() {
            final long ret = this.values[this.head];

            if (this.head == this.tail) {
                throw new IllegalStateException();
            }

            ++this.head;
            if (this.head == this.values.length) {
                this.head = 0;
            }

            return ret;
        }

        public void addLast(final long value) {
            this.values[this.tail++] = value;

            if (this.tail == this.head) {
                throw new IllegalStateException();
            }

            if (this.tail == this.values.length) {
                this.tail = 0;
            }
        }
    }

    private static CustomLongArray[] makeQ1BFS(final int radius) {
        final CustomLongArray[] ret = new CustomLongArray[2 * radius + 1];
        final BasicFIFOLQueue queue = new BasicFIFOLQueue(Math.max(1, 4 * radius) + 1);
        final LongOpenHashSet seen = new LongOpenHashSet((radius + 1) * (radius + 1));

        seen.add(CoordinateUtils.getChunkKey(0, 0));
        queue.addLast(CoordinateUtils.getChunkKey(0, 0));
        while (!queue.isEmpty()) {
            final long chunk = queue.removeFirst();
            final int chunkX = CoordinateUtils.getChunkX(chunk);
            final int chunkZ = CoordinateUtils.getChunkZ(chunk);

            final int index = Math.abs(chunkX) + Math.abs(chunkZ);
            final CustomLongArray list = ret[index];
            if (list != null) {
                list.addUnchecked(chunk);
            } else {
                (ret[index] = new CustomLongArray(getQ1DistanceSize(index, radius))).addUnchecked(chunk);
            }

            for (int i = 0; i < 4; ++i) {
                // 0 -> -1, 0
                // 1 -> 0, -1
                // 2 -> 1, 0
                // 3 -> 0, 1

                final int signInv = -(i >>> 1); // 2/3 -> -(1), 0/1 -> -(0)
                // note: -n = (~n) + 1
                // (n ^ signInv) - signInv = signInv == 0 ? ((n ^ 0) - 0 = n) : ((n ^ -1) - (-1) = ~n + 1)

                final int axis = i & 1; // 0/2 -> 0, 1/3 -> 1
                final int dx = ((axis - 1) ^ signInv) - signInv; // 0 -> -1, 1 -> 0
                final int dz = (-axis ^ signInv) - signInv; // 0 -> 0, 1 -> -1

                final int neighbourX = chunkX + dx;
                final int neighbourZ = chunkZ + dz;
                final long neighbour = CoordinateUtils.getChunkKey(neighbourX, neighbourZ);

                if ((neighbourX | neighbourZ) < 0 || Math.max(Math.abs(neighbourX), Math.abs(neighbourZ)) > radius) {
                    // don't enqueue out of range
                    continue;
                }

                if (!seen.add(neighbour)) {
                    continue;
                }

                queue.addLast(neighbour);
            }
        }

        return ret;
    }

    // doesn't appear worth optimising this function now, even though it's 70% of the call
    private static CustomLongArray spread(final CustomLongArray input, final int size) {
        final LongLinkedOpenHashSet notAdded = new LongLinkedOpenHashSet(input);
        final CustomLongArray added = new CustomLongArray(size);

        while (!notAdded.isEmpty()) {
            if (added.isEmpty()) {
                added.addUnchecked(notAdded.removeLastLong());
                continue;
            }

            long maxChunk = -1L;
            int maxDist = 0;

            // select the chunk from the not yet added set that has the largest minimum distance from
            // the current set of added chunks

            for (final LongIterator iterator = notAdded.iterator(); iterator.hasNext();) {
                final long chunkKey = iterator.nextLong();
                final int chunkX = CoordinateUtils.getChunkX(chunkKey);
                final int chunkZ = CoordinateUtils.getChunkZ(chunkKey);

                int minDist = Integer.MAX_VALUE;

                final int len = added.size();
                final long[] addedArr = added.elements();
                Objects.checkFromToIndex(0, len, addedArr.length);
                for (int i = 0; i < len; ++i) {
                    final long addedKey = addedArr[i];
                    final int addedX = CoordinateUtils.getChunkX(addedKey);
                    final int addedZ = CoordinateUtils.getChunkZ(addedKey);

                    // here we use square distance because chunk generation uses neighbours in a square radius
                    final int dist = Math.max(Math.abs(addedX - chunkX), Math.abs(addedZ - chunkZ));

                    minDist = Math.min(dist, minDist);
                }

                if (minDist > maxDist) {
                    maxDist = minDist;
                    maxChunk = chunkKey;
                }
            }

            // move the selected chunk from the not added set to the added set

            if (!notAdded.remove(maxChunk)) {
                throw new IllegalStateException();
            }

            added.addUnchecked(maxChunk);
        }

        return added;
    }
}
