package net.minestom.server.instance.block;

import it.unimi.dsi.fastutil.objects.Object2ByteMap;
import it.unimi.dsi.fastutil.objects.Object2ByteOpenHashMap;
import it.unimi.dsi.fastutil.objects.Object2IntMap;
import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap;
import net.minestom.server.data.Data;
import net.minestom.server.entity.Entity;
import net.minestom.server.entity.Player;
import net.minestom.server.gamedata.loottables.LootTable;
import net.minestom.server.gamedata.loottables.LootTableManager;
import net.minestom.server.instance.BlockModifier;
import net.minestom.server.instance.Chunk;
import net.minestom.server.instance.Instance;
import net.minestom.server.network.packet.server.play.BlockBreakAnimationPacket;
import net.minestom.server.utils.BlockPosition;
import net.minestom.server.utils.time.UpdateOption;
import net.minestom.server.utils.validate.Check;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jglrxavpok.hephaistos.nbt.NBTCompound;

import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

/**
 * Represents the handler of a custom block type which can be registered with {@link BlockManager#registerCustomBlock(CustomBlock)}.
 * <p>
 * There should be only one instance of this class for each custom block type,
 * every individual blocks will execute the callbacks present there. Each of which contains the
 * custom block position and the instance concerned.
 * <p>
 * Each block in a chunk contains 2 id, the block state id (only visual) and a custom block id corresponding to
 * {@link CustomBlock#getCustomBlockId()}. A custom block is responsible for the blocks tick, the custom break time feature,
 * and some useful callbacks.
 */
public abstract class CustomBlock {

    public static final byte MAX_STAGE = 10;

    /**
     * Instance -> break data
     * Used to store block break stage data when {@link #enableMultiPlayerBreaking()} is enabled
     */
    private final Map<Instance, InstanceBreakData> instanceBreakDataMap = new HashMap<>();

    public int getBreakEntityId(Player firstBreaker) {
        return -firstBreaker.getEntityId();
    }

    private final short defaultBlockStateId;
    private final String identifier;

    /**
     * @param defaultBlockStateId the default block state id
     * @param identifier          the custom block identifier
     */
    public CustomBlock(short defaultBlockStateId, @NotNull String identifier) {
        this.defaultBlockStateId = defaultBlockStateId;
        this.identifier = identifier;
    }

    public CustomBlock(@NotNull Block block, @NotNull String identifier) {
        this(block.getBlockId(), identifier);
    }

    /**
     * Calling delay depends on {@link #getUpdateOption()} which should be overridden.
     *
     * @param instance      the instance of the block
     * @param blockPosition the position of the block
     * @param data          the data associated with the block
     * @throws UnsupportedOperationException if {@link #getUpdateOption()}
     *                                       is not null but the update method is not overridden
     */
    public void update(@NotNull Instance instance, @NotNull BlockPosition blockPosition, @Nullable Data data) {
        throw new UnsupportedOperationException("Update method not overridden, check #getUpdateOption()");
    }

    /**
     * The update option is used to define the delay between two
     * {@link #update(Instance, BlockPosition, Data)} execution.
     * <p>
     * If this is not null, {@link #update(Instance, BlockPosition, Data)}
     * should be overridden or errors with occurs.
     *
     * @return the update option of the block, null if not any
     */
    @Nullable
    public UpdateOption getUpdateOption() {
        return null;
    }

    /**
     * Called when a custom block has been placed.
     *
     * @param instance      the instance of the block
     * @param blockPosition the position of the block
     * @param data          the data associated with the block
     */
    public abstract void onPlace(@NotNull Instance instance, @NotNull BlockPosition blockPosition, @Nullable Data data);

    /**
     * Called when a custom block has been destroyed or replaced.
     *
     * @param instance      the instance of the block
     * @param blockPosition the position of the block
     * @param data          the data associated with the block
     */
    public abstract void onDestroy(@NotNull Instance instance, @NotNull BlockPosition blockPosition, @Nullable Data data);

    /**
     * Handles interactions with this block. Can also block normal item use (containers should block when opening the
     * menu, this prevents the player from placing a block when opening it for instance).
     *
     * @param player        the player interacting
     * @param hand          the hand used to interact
     * @param blockPosition the position of this block
     * @param data          the data at this position
     * @return true if this block blocks normal item use, false otherwise
     */
    public abstract boolean onInteract(@NotNull Player player, @NotNull Player.Hand hand,
                                       @NotNull BlockPosition blockPosition, @Nullable Data data);

    /**
     * This id can be serialized in chunk file, meaning no duplicate should exist
     * Changing this value halfway should mean potentially breaking the world
     *
     * @return the custom block id
     */
    public abstract short getCustomBlockId();

    /**
     * Called when the player requests the next stage break delay
     *
     * @param player   the player who is trying to break the block
     * @param position the block position
     * @param stage    the current break stage of the block (0-10)
     * @param breakers the list containing all the players currently digging this block
     * @return the time in tick to pass to the next state, 0 to instant break it.
     * negative value allow to skip stages (-2 will skip 2 stages per tick)
     * @see #enableCustomBreakDelay() to enable/disable it
     */
    public int getBreakDelay(@NotNull Player player, @NotNull BlockPosition position, byte stage, Set<Player> breakers) {
        return 0;
    }

    /**
     * Used to enable the custom break delay from {@link #getBreakDelay(Player, BlockPosition, byte, Set)}
     * Disabling it would result in having vanilla time
     *
     * @return true to enable custom break delay
     */
    public boolean enableCustomBreakDelay() {
        return false;
    }

    /**
     * Gets if this block breaking time can be reduced by having multiple players
     * digging it.
     * <p>
     * WARNING: this should be constant, do not change this value halfway.
     *
     * @return true to enable the multi-player breaking feature
     */
    public boolean enableMultiPlayerBreaking() {
        return false;
    }

    /**
     * Gets if this {@link CustomBlock} requires any tick update.
     *
     * @return true if {@link #getUpdateOption()} is not null and the value is positive
     */
    public boolean hasUpdate() {
        final UpdateOption updateOption = getUpdateOption();
        if (updateOption == null)
            return false;

        return updateOption.getValue() > 0;
    }


    /**
     * Defines custom behaviour for entities touching this block.
     *
     * @param instance the instance
     * @param position the position at which the block is
     * @param touching the entity currently touching the block
     */
    public void handleContact(@NotNull Instance instance, @NotNull BlockPosition position, @NotNull Entity touching) {
    }

    /**
     * This is the default block state id when the custom block is set,
     * it is possible to change this value per block using
     * {@link BlockModifier#setSeparateBlocks(int, int, int, short, short)}
     * <p>
     * Meaning that you should not believe that your custom blocks id will always be this one.
     *
     * @return the default visual block id
     */
    public short getDefaultBlockStateId() {
        return defaultBlockStateId;
    }

    /**
     * The custom block identifier, used to retrieve the custom block object with
     * {@link BlockManager#getCustomBlock(String)} and to set custom block in the instance.
     *
     * @return the custom block identifier
     */
    @NotNull
    public String getIdentifier() {
        return identifier;
    }

    /**
     * Initialises data for this block.
     *
     * @param blockPosition the position of the targeted block
     * @param data          data given to 'setBlock', can be null
     * @return Data for this block. Can be null, 'data', or a new object
     */
    @Nullable
    public Data createData(@NotNull Instance instance, @NotNull BlockPosition blockPosition, @Nullable Data data) {
        return data;
    }

    /**
     * Updates this block from a neighbor. By default calls 'update' if directNeighbor is true.
     *
     * @param instance         current instance
     * @param thisPosition     this block's position
     * @param neighborPosition the neighboring block which triggered the update
     * @param directNeighbor   is the neighbor directly connected to this block? (No diagonals)
     */
    public void updateFromNeighbor(@NotNull Instance instance, @NotNull BlockPosition thisPosition,
                                   @NotNull BlockPosition neighborPosition, boolean directNeighbor) {
        if (directNeighbor && hasUpdate()) {
            update(instance, thisPosition, instance.getBlockData(thisPosition));
        }
    }

    /**
     * Called when a scheduled update on this block happens. By default, calls 'update'.
     *
     * @param instance  the instance of the block
     * @param position  the position of the block
     * @param blockData the data of the block
     */
    public void scheduledUpdate(@NotNull Instance instance, @NotNull BlockPosition position, @Nullable Data blockData) {
        update(instance, position, blockData);
    }

    /**
     * Gets the drag of this block.
     * <p>
     * It has to be between 0 and 1.
     *
     * @return the drag of this block
     */
    public float getDrag(@NotNull Instance instance, @NotNull BlockPosition blockPosition) {
        return 0.5f;
    }

    /**
     * Allows custom block to write block entity data to a given NBT compound.
     * Used to send block entity data to the client over the network.
     * Can also be used to save block entity data on disk for compatible chunk savers.
     *
     * @param position  position of the block
     * @param blockData equivalent to <pre>instance.getBlockData(position)</pre>
     * @param nbt       the nbt to write in the {@link net.minestom.server.network.packet.server.play.ChunkDataPacket}
     */
    public void writeBlockEntity(@NotNull BlockPosition position, @Nullable Data blockData, @NotNull NBTCompound nbt) {
    }

    /**
     * Called when an explosion wants to destroy this block.
     *
     * @param instance           the instance
     * @param lootTableArguments arguments used in the loot table loot generation
     * @return 'true' if the explosion should happen on this block, 'false' to cancel the destruction.
     * Returning true does NOT block the explosion rays, ie it does not change the block explosion resistance
     */
    public boolean onExplode(@NotNull Instance instance, @NotNull BlockPosition position, Data lootTableArguments) {
        return true;
    }

    /**
     * Returns the loot table associated to this block. Return null to use vanilla behavior.
     *
     * @param tableManager the loot table manager
     * @return the loot table associated to this block
     */
    @Nullable
    public LootTable getLootTable(@NotNull LootTableManager tableManager) {
        return null;
    }

    // BLOCK BREAK METHODS

    /**
     * Called when a player start digging this custom block,
     * process all necessary data if {@link #enableMultiPlayerBreaking()} is enabled.
     *
     * @param instance      the instance of the block
     * @param blockPosition the position of the block
     * @param player        the player who started digging
     */
    public void startDigging(@NotNull Instance instance, @NotNull BlockPosition blockPosition, @NotNull Player player) {
        // Stay null if multi player breaking is disabled
        Set<Player> breakers = null;

        if (enableMultiPlayerBreaking()) {
            // Multi player breaking enabled, get the breakers and cache some values
            InstanceBreakData instanceBreakData = instanceBreakDataMap.computeIfAbsent(instance, i -> new InstanceBreakData());

            Map<BlockPosition, Set<Player>> breakersMap = instanceBreakData.breakersMap;
            breakers = breakersMap.computeIfAbsent(blockPosition, pos -> new HashSet<>(1));
            breakers.add(player);

            Object2ByteMap<BlockPosition> breakStageMap = instanceBreakData.breakStageMap;
            // Set the block stage to 0, use the previous one if any
            if (!breakStageMap.containsKey(blockPosition)) {
                breakStageMap.put(blockPosition, (byte) 0);
            }

            Object2IntMap<BlockPosition> breakIdMap = instanceBreakData.breakIdMap;
            // Set the entity id used for the packet, otherwise use the previous one
            if (!breakIdMap.containsKey(blockPosition)) {
                breakIdMap.put(blockPosition, getBreakEntityId(player));
            }
        }

        // Set the player target block
        player.setTargetBlock(this, blockPosition, breakers);
    }

    /**
     * Called when a player stop digging a block,
     * does remove the block break animation if he was the only breaker.
     *
     * @param instance      the instance of the block
     * @param blockPosition the position of the block
     * @param player        the player who stopped digging
     */
    public void stopDigging(@NotNull Instance instance, @NotNull BlockPosition blockPosition, @NotNull Player player) {
        if (enableMultiPlayerBreaking()) {
            // Remove cache data
            if (instanceBreakDataMap.containsKey(instance)) {
                InstanceBreakData instanceBreakData = instanceBreakDataMap.get(instance);

                Set<Player> breakers = instanceBreakData.breakersMap.get(blockPosition);
                if (breakers != null) {
                    breakers.remove(player);
                    if (breakers.isEmpty()) {
                        // No remaining breakers

                        // Get the entity id assigned to the block break
                        final int entityId = instanceBreakData.breakIdMap.getInt(blockPosition);

                        final Chunk chunk = instance.getChunkAt(blockPosition);
                        Check.notNull(chunk, "Tried to interact with an unloaded chunk.");
                        chunk.sendPacketToViewers(new BlockBreakAnimationPacket(entityId, blockPosition, (byte) -1));

                        // Clear cache
                        removeDiggingInformation(instance, blockPosition);
                    }
                }

            }
        } else {
            // Stop the breaking animation for the specific player id
            final Chunk chunk = instance.getChunkAt(blockPosition);
            final int entityId = getBreakEntityId(player);
            Check.notNull(chunk, "Tried to interact with an unloaded chunk.");
            chunk.sendPacketToViewers(new BlockBreakAnimationPacket(entityId, blockPosition, (byte) -1));
        }
    }

    /**
     * Process one stage on the block, break it if it excess {@link #MAX_STAGE},
     * only if {@link #enableMultiPlayerBreaking()} is enabled.
     *
     * @param instance      the instance of the block
     * @param blockPosition the position of the block
     * @param player        the player who processed one stage on the block
     * @param stageIncrease the number of stage increase
     * @return true if the block can continue being digged
     * @throws IllegalStateException if {@link #enableMultiPlayerBreaking()} is disabled
     */
    public synchronized boolean processStage(@NotNull Instance instance, @NotNull BlockPosition blockPosition,
                                             @NotNull Player player, byte stageIncrease) {
        Check.stateCondition(!enableMultiPlayerBreaking(),
                "CustomBlock#processState requires having the multi player breaking feature enabled");

        if (instanceBreakDataMap.containsKey(instance)) {
            InstanceBreakData instanceBreakData = instanceBreakDataMap.get(instance);
            Object2ByteMap<BlockPosition> breakStageMap = instanceBreakData.breakStageMap;
            byte stage = breakStageMap.getByte(blockPosition);
            if (stage + stageIncrease >= MAX_STAGE) {
                instance.breakBlock(player, blockPosition);
                return false;
            } else {

                // Get the entity id assigned to the block break
                final int entityId = instanceBreakData.breakIdMap.getInt(blockPosition);

                // Send the block break animation
                final Chunk chunk = instance.getChunkAt(blockPosition);
                Check.notNull(chunk, "Tried to interact with an unloaded chunk.");
                chunk.sendPacketToViewers(new BlockBreakAnimationPacket(entityId, blockPosition, stage));

                // Refresh the stage
                stage += stageIncrease;
                breakStageMap.put(blockPosition, stage);
                return true;
            }
        }
        return false;
    }

    public void removeDiggingInformation(@NotNull Instance instance, @NotNull BlockPosition blockPosition) {
        if (!enableMultiPlayerBreaking()) {
            return;
        }

        if (instanceBreakDataMap.containsKey(instance)) {
            InstanceBreakData instanceBreakData = instanceBreakDataMap.get(instance);
            // Remove the block position from all maps
            instanceBreakData.clear(blockPosition);
        }
    }

    /**
     * Gets all the breakers of a block, only if {@link #enableMultiPlayerBreaking()} is enabled.
     *
     * @param instance      the instance of the block
     * @param blockPosition the position of the block
     * @return the {@link Set} of breakers of a block
     * @throws IllegalStateException if {@link #enableMultiPlayerBreaking()} is disabled
     */
    @Nullable
    public Set<Player> getBreakers(@NotNull Instance instance, @NotNull BlockPosition blockPosition) {
        Check.stateCondition(!enableMultiPlayerBreaking(),
                "CustomBlock#getBreakers requires having the multi player breaking feature enabled");

        if (instanceBreakDataMap.containsKey(instance)) {
            InstanceBreakData instanceBreakData = instanceBreakDataMap.get(instance);
            return instanceBreakData.breakersMap.get(blockPosition);
        }
        return null;
    }

    /**
     * Gets the block break stage at a position,
     * only work if {@link #enableMultiPlayerBreaking()} is enabled.
     *
     * @param instance      the instance of the custom block
     * @param blockPosition the position of the custom block
     * @return the break stage at the position. Can also be 0 when nonexistent
     */
    public byte getBreakStage(@NotNull Instance instance, @NotNull BlockPosition blockPosition) {
        Check.stateCondition(!enableMultiPlayerBreaking(),
                "CustomBlock#getBreakStage requires having the multi player breaking feature enabled");

        if (!instanceBreakDataMap.containsKey(instance))
            return 0;
        final InstanceBreakData instanceBreakData = instanceBreakDataMap.get(instance);
        return instanceBreakData.breakStageMap.getByte(blockPosition);
    }

    /**
     * Class used to store block break stage.
     * Only if multi player breaking is enabled.
     */
    private static class InstanceBreakData {
        // Contains all the breakers of a block
        private final Map<BlockPosition, Set<Player>> breakersMap = new HashMap<>();
        // Contains the current break stage of a block
        private final Object2ByteMap<BlockPosition> breakStageMap = new Object2ByteOpenHashMap<>();
        // Contains the entity id used by the block break packet
        private final Object2IntMap<BlockPosition> breakIdMap = new Object2IntOpenHashMap<>();

        private void clear(@NotNull BlockPosition blockPosition) {
            this.breakersMap.remove(blockPosition);
            this.breakStageMap.removeByte(blockPosition);
            this.breakIdMap.removeInt(blockPosition);
        }

    }
}
