/*
 *   This file is a part of the SteamWar software.
 *
 *   Copyright (C) 2025 SteamWar.de-Serverteam
 *
 *   This program is free software: you can redistribute it and/or modify
 *   it under the terms of the GNU Affero General Public License as published by
 *   the Free Software Foundation, either version 3 of the License, or
 *   (at your option) any later version.
 *
 *   This program is distributed in the hope that it will be useful,
 *   but WITHOUT ANY WARRANTY; without even the implied warranty of
 *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 *   GNU Affero General Public License for more details.
 *
 *   You should have received a copy of the GNU Affero General Public License
 *   along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

package de.steamwar.bausystem.utils;

import de.steamwar.Reflection;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.experimental.UtilityClass;
import org.bukkit.*;
import org.bukkit.block.*;
import org.bukkit.block.data.*;
import org.bukkit.block.data.type.Hopper;
import org.bukkit.block.data.type.*;
import org.bukkit.entity.Player;
import org.bukkit.event.block.BlockCanBuildEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.*;
import org.bukkit.util.RayTraceResult;
import org.bukkit.util.Vector;

import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;

@UtilityClass
public class PlaceItemUtils {

    // https://github.com/Articdive/ArticData/blob/1.20.1/1_20_1_tags/1_20_1_block_tags.json
    // #minecraft:replaceable
    private static final Set<String> replaceables;

    static {
        replaceables = new HashSet<>(Arrays.asList(
                "minecraft:air",
                "minecraft:water",
                "minecraft:lava",
                "minecraft:grass",
                "minecraft:fern",
                "minecraft:dead_bush",
                "minecraft:seagrass",
                "minecraft:tall_seagrass",
                "minecraft:fire",
                "minecraft:soul_fire",
                "minecraft:snow",
                "minecraft:vine",
                "minecraft:glow_lichen",
                "minecraft:light",
                "minecraft:tall_grass",
                "minecraft:large_fern",
                "minecraft:structure_void",
                "minecraft:void_air",
                "minecraft:cave_air",
                "minecraft:bubble_column",
                "minecraft:warped_roots",
                "minecraft:nether_sprouts",
                "minecraft:crimson_roots",
                "minecraft:hanging_roots"))
                .stream()
                .map(s -> s.substring(10))
                .map(String::toUpperCase)
                .collect(Collectors.toSet());
    }

    private static final Class<?> blockPosition = Reflection.getClass("net.minecraft.core.BlockPos");
    private static final Reflection.Constructor blockPositionConstructor = Reflection.getConstructor(blockPosition, int.class, int.class, int.class);
    private static final Class<?> craftBlock = Reflection.getClass("org.bukkit.craftbukkit.block.CraftBlockState");
    private static final Class<?> craftWorld = Reflection.getClass("org.bukkit.craftbukkit.CraftWorld");
    private static final Reflection.Field<?> positionAccessor = Reflection.getField(craftBlock, blockPosition, 0);
    private static final Reflection.Field<?> worldAccessor = Reflection.getField(craftBlock, craftWorld, 0);

    /**
     * Attempt to place an {@link ItemStack} the {@link Player} is holding against a {@link Block} inside the World.
     * This can be easily used inside the {@link org.bukkit.event.player.PlayerInteractEvent} to mimik placing a
     * block without any minecraft related GUI's etc. executing.
     *
     * @param player the Player placing the block
     * @param itemStack the ItemStack to be placed
     * @param against the Block at which the player aims (does not need to be in range of the player)
     * @param againstSide the BlockFace the player aims
     * @param hand the Hand the player is using
     * @param force allow illegal states to be created by placing the block
     * @param applyPhysics apply physics while placing the block
     * @param rotateAway rotate everything in the opposite direction, so a block facing the Player will face away, and the other way round
     * @param playSound enables sound of placing
     */
    public PlaceItemResult placeItem(Player player, ItemStack itemStack, Block against, BlockFace againstSide, EquipmentSlot hand, boolean force, boolean applyPhysics, boolean rotateAway, boolean playSound) {
        // If the ItemStack is null or air we cannot place it
        if (itemStack == null) return PlaceItemResult.NO_ITEM_HELD;
        if (itemStack.getType().isAir()) return PlaceItemResult.NO_ITEM_HELD;

        // This Block should be replaced by the new Block
        Block block = against.getRelative(againstSide);

        // We cannot place any Item that is not also a Block, this is checked by testing for the ItemMeta
        ItemMeta itemMeta = itemStack.getItemMeta();
        if (!(itemMeta instanceof BlockDataMeta)) {
            return PlaceItemResult.NO_BLOCK_ITEM_HELD;
        }

        BlockDataMeta blockDataMeta = (BlockDataMeta) itemMeta;

        // Converting the Item Material to a Block Material
        // e.g. Material.REDSTONE -> Material.REDSTONE_WIRE
        Material typeToPlace = PlaceItemWrapper.ITEM_MATERIAL_TO_BLOCK_MATERIAL.getOrDefault(itemStack.getType(), itemStack.getType());

        BlockData blockData = null;
        AtomicBoolean usedForcePlace = new AtomicBoolean();
        if (againstSide == BlockFace.NORTH || againstSide == BlockFace.SOUTH || againstSide == BlockFace.EAST || againstSide == BlockFace.WEST) {
            // Try Wall Placement first
            blockData = toBlockData(player, blockDataMeta, PlaceItemWrapper.BLOCK_MATERIAL_TO_WALL_BLOCK_MATERIAL.getOrDefault(typeToPlace, typeToPlace));
            if (blockData != null && !canPlace(block, blockData, force, usedForcePlace)) {
                // Check if default Rotation from input could be valid
                BlockFace rotation = getRotation(blockData);
                setRotation(blockData, againstSide);
                if (!canPlace(block, blockData, force, usedForcePlace)) {
                    setRotation(blockData, rotation);
                }
            }
        }

        // Try default Placement
        if (blockData == null || !canPlace(block, blockData, force, usedForcePlace)) {
            blockData = toBlockData(player, blockDataMeta, typeToPlace);
        }

        if (blockData != null && !canPlace(block, blockData, force, usedForcePlace)) {
            if (blockData instanceof FaceAttachable) {
                // FaceAttachable Blocks should be placed on the Ceiling/Floor if possible
                // This applies mainly to Lever and Buttons
                FaceAttachable faceAttachable = (FaceAttachable) blockData;
                boolean topFirst = isHitHalfTop(player);
                faceAttachable.setAttachedFace(topFirst ? FaceAttachable.AttachedFace.CEILING : FaceAttachable.AttachedFace.FLOOR);
                if (!canPlace(block, blockData, force, usedForcePlace)) {
                    faceAttachable.setAttachedFace(topFirst ? FaceAttachable.AttachedFace.FLOOR : FaceAttachable.AttachedFace.CEILING);
                }
                if (!canPlace(block, blockData, force, usedForcePlace)) {
                    return PlaceItemResult.NO_VALID_PLACEMENT;
                }
            }
        }

        if (blockData == null) return PlaceItemResult.NO_BLOCK_ITEM_HELD;

        // Placing a Block inside of Water should set it to Waterlogged
        if (blockData instanceof Waterlogged && block.getType() == Material.WATER) {
            Levelled levelled = (Levelled) block.getBlockData();
            ((Waterlogged) blockData).setWaterlogged(levelled.getLevel() == levelled.getMaximumLevel());
        }

        if (blockData instanceof Slab) {
            // Slabs can be set at Top or Bottom
            ((Slab) blockData).setType(isHitHalfTop(player) ? Slab.Type.TOP : Slab.Type.BOTTOM);
            if (againstSide == BlockFace.DOWN) {
                ((Slab) blockData).setType(Slab.Type.TOP);
            }
        } else if (blockData instanceof Stairs) {
            // Stairs can be set at Top or Bottom
            ((Stairs) blockData).setHalf(isHitHalfTop(player) ? Bisected.Half.TOP : Bisected.Half.BOTTOM);
            if (againstSide == BlockFace.DOWN) {
                ((Stairs) blockData).setHalf(Bisected.Half.TOP);
            }
        } else if (blockData instanceof TrapDoor) {
            // TrapDoors can be set at Top or Bottom
            ((TrapDoor) blockData).setHalf(isHitHalfTop(player) ? Bisected.Half.TOP : Bisected.Half.BOTTOM);
            if (againstSide == BlockFace.DOWN) {
                ((TrapDoor) blockData).setHalf(Bisected.Half.TOP);
            }
        } else if (blockData instanceof Chain) {
            // Chains are always rotated against the block you place against
            Orientable orientable = (Orientable) blockData;
            switch (againstSide) {
                case EAST:
                case WEST:
                    orientable.setAxis(Axis.X);
                    break;
                case UP:
                case DOWN:
                    orientable.setAxis(Axis.Y);
                    break;
                case NORTH:
                case SOUTH:
                    orientable.setAxis(Axis.Z);
                    break;
            }
        } else if (blockData instanceof Hopper && (againstSide == BlockFace.UP || againstSide == BlockFace.DOWN)) {
            // Placing at the Top or Bottom of a Block result in a downwards facing Hopper
            ((Hopper) blockData).setFacing(BlockFace.DOWN);
        } else if (blockData instanceof Directional && (blockData.getMaterial().name().endsWith("_HEAD") || blockData.getMaterial().name().endsWith("_SKULL")) && againstSide != BlockFace.DOWN && againstSide != BlockFace.UP) {
            // Skulls and Heads are always rotated towards you if not in Wall variant
            ((Directional) blockData).setFacing(againstSide);
        } else if (blockData instanceof LightningRod) {
            // Lightning Rod is always rotated against the block you place against
            ((Directional) blockData).setFacing(againstSide);
        }
        if (force && blockData instanceof FaceAttachable) {
            // Forcing to Place a FaceAttachable against the Block you specified. Needs the force flag to be set
            FaceAttachable faceAttachable = (FaceAttachable) blockData;
            if (blockData instanceof Switch && againstSide == BlockFace.DOWN) {
                faceAttachable.setAttachedFace(FaceAttachable.AttachedFace.CEILING);
            } else if (againstSide == BlockFace.UP) {
                faceAttachable.setAttachedFace(FaceAttachable.AttachedFace.FLOOR);
            } else if (blockData instanceof Directional) {
                ((Directional) blockData).setFacing(againstSide);
            }
            if (blockData instanceof Switch) {
                // Levers and Buttons are always Rotated the other way
                Switch switchType = (Switch) blockData;
                switch (switchType.getAttachedFace()) {
                    case FLOOR:
                    case CEILING:
                        switchType.setFacing(switchType.getFacing().getOppositeFace());
                        break;
                }
            }
        }
        if (force && blockData instanceof Directional && !(blockData instanceof FaceAttachable) && PlaceItemWrapper.BLOCK_MATERIAL_TO_WALL_BLOCK_MATERIAL.containsValue(blockData.getMaterial())) {
            Directional directional = (Directional) blockData;
            if (directional.getFaces().contains(againstSide)) {
                directional.setFacing(againstSide);
            }
        }
        if (force && blockData instanceof Rotatable && !(blockData instanceof FaceAttachable)) {
            Rotatable rotatable = (Rotatable) blockData;
            if (againstSide != BlockFace.UP && againstSide != BlockFace.DOWN) {
                rotatable.setRotation(againstSide);
            }
        }

        if (blockData instanceof RedstoneWire) {
            // Redstone Wire is connected to every Side by default
            RedstoneWire redstoneWire = (RedstoneWire) blockData;
            redstoneWire.setFace(BlockFace.NORTH, RedstoneWire.Connection.SIDE);
            redstoneWire.setFace(BlockFace.SOUTH, RedstoneWire.Connection.SIDE);
            redstoneWire.setFace(BlockFace.EAST, RedstoneWire.Connection.SIDE);
            redstoneWire.setFace(BlockFace.WEST, RedstoneWire.Connection.SIDE);
        }

        if (rotateAway) {
            // Rotate the other way if rotateAway is set to true
            BlockFace blockFace = getRotation(blockData);
            if (blockFace != null) {
                blockFace = blockFace.getOppositeFace();
                if (blockData instanceof Hopper && (blockFace == BlockFace.UP || blockFace == BlockFace.DOWN)) {
                    ((Hopper) blockData).setFacing(BlockFace.DOWN);
                } else {
                    setRotation(blockData, blockFace);
                }
            }
        }

        // Check if the Block can be build
        BlockCanBuildEvent blockCanBuildEvent = new BlockCanBuildEvent(against, player, blockData, canPlace(block, blockData, force, usedForcePlace));
        Bukkit.getPluginManager().callEvent(blockCanBuildEvent);
        if (!blockCanBuildEvent.isBuildable()) return PlaceItemResult.NO_BUILD;

        BlockState oldState = block.getState();

        // Retrieve the BlockState of the ItemStack if present
        BlockState blockState = null;
        if (itemMeta instanceof BlockStateMeta) {
            blockState = ((BlockStateMeta) itemMeta).getBlockState();
        }

        if (blockState == null) {
            // If no BlockState is present use the BlockState of the Block you want to edit
            blockState = block.getState();
        } else {
            // If a BlockState is present set the Position and World to the Block you want to place
            Location blockLocation = block.getLocation();
            positionAccessor.set(blockState, blockPositionConstructor.invoke(blockLocation.getBlockX(), blockLocation.getBlockY(), blockLocation.getBlockZ()));
            worldAccessor.set(blockState, blockLocation.getWorld());
        }

        if (blockData.getMaterial().isSolid()) {
            for (Player p : Bukkit.getOnlinePlayers()) {
                Location min = p.getLocation().add(-0.3, 0, -0.3);
                Location max = p.getLocation().add(0.3, p.isSneaking() ? 1.5 : 1.8, 0.3);

                Location blockmin = block.getLocation();
                Location blockmax = block.getLocation().add(1.0, 1.0, 1.0);
                if (
                       !(max.getX() <= blockmin.getX() || min.getX() >= blockmax.getX() ||
                                max.getY() <= blockmin.getY() || min.getY() >= blockmax.getY() ||
                                max.getZ() <= blockmin.getZ() || min.getZ() >= blockmax.getZ())
                ) {
                    return PlaceItemResult.NO_PLACE_INSIDE_PLAYER;
                }
            }
        }

        // Set the generated BlockData to the BlockState and update the world without physics
        blockState.setBlockData(blockData);
        blockState.update(true, false);

        // Check if the Block is allowed to be placed
        BlockPlaceEvent blockPlaceEvent = new BlockPlaceEvent(block, oldState, against, itemStack, player, true, hand);
        Bukkit.getPluginManager().callEvent(blockCanBuildEvent);
        if (blockPlaceEvent.isCancelled() || !blockPlaceEvent.canBuild()) {
            // Reset world
            oldState.update(true, false);
            return PlaceItemResult.NO_PLACE;
        }

        if (hasSecondBlock(blockData)) {
            // Place tht second block of a Door or Tallgrass.
            Bisected bisected = (Bisected) blockData;
            Block blockAbove = block.getRelative(0, 1, 0);
            bisected.setHalf(Bisected.Half.TOP);
            if (canPlace(blockAbove, blockData, force, usedForcePlace)) {
                if (blockData instanceof Waterlogged) {
                    ((Waterlogged) blockData).setWaterlogged(blockAbove.getType() == Material.WATER);
                }
                blockAbove.setBlockData(blockData, applyPhysics);
            } else {
                // If the second Block couldn't be placed remove the first Block as well
                oldState.update(true, false);
                return PlaceItemResult.NO_DOUBLE_HIGH_BLOCK_SPACE;
            }
        }
        if (applyPhysics) {
            // Apply Physics by placing the old State without Physics and setting the new with physics
            oldState.update(true, false);
            blockState.update(true, true);
        }

        if (itemMeta instanceof BannerMeta) {
            // Apply Banner Patterns to now placed Block in World
            BannerMeta bannerMeta = (BannerMeta) itemMeta;
            Banner banner = (Banner) block.getState();
            banner.setPatterns(bannerMeta.getPatterns());
            banner.update(true, false);
        } else if (itemMeta instanceof SkullMeta) {
            // Apply Skull Data to now placed Block in World
            SkullMeta skullMeta = (SkullMeta) itemMeta;
            Skull skull = (Skull) block.getState();
            skull.setOwnerProfile(skullMeta.getOwnerProfile());
            if (skullMeta.getOwningPlayer() != null) {
                skull.setOwningPlayer(skullMeta.getOwningPlayer());
            }
            skull.update(true, false);
        }

        if (playSound) {
            // Play the corresponding sound of placing the now placed Block
            SoundGroup soundGroup = blockData.getSoundGroup();
            block.getWorld().playSound(block.getLocation(), soundGroup.getPlaceSound(), soundGroup.getVolume() * 0.8F, soundGroup.getPitch() * 0.8F);
        }
        return usedForcePlace.get() ? PlaceItemResult.SUCCESS_FORCE : PlaceItemResult.SUCCESS;
    }

    public BlockFace[] axis = { BlockFace.NORTH, BlockFace.EAST, BlockFace.SOUTH, BlockFace.WEST };
    public BlockFace[] radial = { BlockFace.NORTH, BlockFace.NORTH_EAST, BlockFace.EAST, BlockFace.SOUTH_EAST, BlockFace.SOUTH, BlockFace.SOUTH_WEST, BlockFace.WEST, BlockFace.NORTH_WEST };

    public BlockFace yawToFace(float yaw) {
        return radial[Math.round(yaw / 45f) & 0x7];
    }

    public BlockFace yawToFaceAxis(float yaw) {
        return axis[Math.round(yaw / 90f) & 0x3];
    }

    public BlockFace toFace(Set<BlockFace> faces, float pitch, float yaw) {
        if (faces.contains(BlockFace.UP) && pitch >= 45.0) {
            return BlockFace.UP;
        }
        if (faces.contains(BlockFace.DOWN) && pitch <= -45.0) {
            return BlockFace.DOWN;
        }
        return yawToFaceAxis(yaw);
    }

    public BlockData toBlockData(Player player, BlockDataMeta blockDataMeta, Material material) {
        BlockData blockData;
        try {
            blockData = blockDataMeta.getBlockData(material);
        } catch (NullPointerException e) {
            // Some items have a BlockDataMeta but they cannot be converted to one like ItemFrame, those will be ignored
            return null;
        }

        if (blockData instanceof Stairs || blockData instanceof Observer || blockData instanceof Hopper || blockData instanceof Door) {
            // Stairs, Observer, Hopper and Doors are placed the opposite way
            Directional directional = (Directional) blockData;
            BlockFace face = toFace(directional.getFaces(), player.getLocation().getPitch(), player.getLocation().getYaw());
            directional.setFacing(face.getOppositeFace());
        } else if (blockData instanceof Orientable) {
            // Orientable only have 3 Axis: X, Y, Z
            Orientable orientable = (Orientable) blockData;
            Set<BlockFace> faces = new HashSet<>();
            Set<Axis> axisSet = orientable.getAxes();
            if (axisSet.contains(Axis.X)) {
                faces.add(BlockFace.EAST);
                faces.add(BlockFace.WEST);
            }
            if (axisSet.contains(Axis.Y)) {
                faces.add(BlockFace.UP);
                faces.add(BlockFace.DOWN);
            }
            if (axisSet.contains(Axis.Z)) {
                faces.add(BlockFace.NORTH);
                faces.add(BlockFace.SOUTH);
            }
            BlockFace face = toFace(faces, player.getLocation().getPitch(), player.getLocation().getYaw());
            switch (face) {
                case EAST:
                case WEST:
                    orientable.setAxis(Axis.X);
                    break;
                case UP:
                case DOWN:
                    orientable.setAxis(Axis.Y);
                    break;
                case NORTH:
                case SOUTH:
                    orientable.setAxis(Axis.Z);
                    break;
            }
        } else if (blockData instanceof Rotatable) {
            Rotatable rotatable = (Rotatable) blockData;
            BlockFace blockeFace = yawToFace(player.getLocation().getYaw());
            if (blockData.getMaterial().name().endsWith("_HEAD") || blockData.getMaterial().name().endsWith("_SKULL")) {
                // Wall Heads and Wall Skulls are placed the opposite way
                rotatable.setRotation(blockeFace.getOppositeFace());
            } else {
                rotatable.setRotation(blockeFace);
            }
        } else if (blockData instanceof Directional) {
            Directional directional = (Directional) blockData;
            directional.setFacing(toFace(directional.getFaces(), player.getLocation().getPitch(), player.getLocation().getYaw()));
        } else if (blockData instanceof Rail) {
            Rail rail = (Rail) blockData;
            BlockFace face = yawToFaceAxis(player.getLocation().getYaw());
            // Rails are only represented by 2 States, North_South or East_West the remaining States will be ignored here
            rail.setShape(face == BlockFace.NORTH || face == BlockFace.SOUTH ? Rail.Shape.NORTH_SOUTH : Rail.Shape.EAST_WEST);
        }

        return blockData;
    }

    private BlockFace getRotation(BlockData blockData) {
        if (blockData instanceof Rotatable) {
            return ((Rotatable) blockData).getRotation();
        } else if (blockData instanceof Directional) {
            return ((Directional) blockData).getFacing();
        } else if (blockData instanceof Rail) {
            Rail rail = (Rail) blockData;
            switch (rail.getShape()) {
                case NORTH_SOUTH:
                    return BlockFace.NORTH;
                case EAST_WEST:
                    return BlockFace.EAST;
            }
        }
        return null;
    }

    private void setRotation(BlockData blockData, BlockFace rotation) {
        if (blockData instanceof Rotatable) {
            ((Rotatable) blockData).setRotation(rotation);
        } else if (blockData instanceof Directional) {
            ((Directional) blockData).setFacing(rotation);
        } else if (blockData instanceof Rail) {
            Rail rail = (Rail) blockData;
            switch (rotation) {
                case NORTH:
                case SOUTH:
                    rail.setShape(Rail.Shape.NORTH_SOUTH);
                    break;
                case EAST:
                case WEST:
                    rail.setShape(Rail.Shape.EAST_WEST);
                    break;
            }
        }
    }

    private boolean isHitHalfTop(Player player) {
        RayTraceResult rayTraceResult = player.rayTraceBlocks(6, FluidCollisionMode.NEVER);
        if (rayTraceResult != null) {
            Vector vector = rayTraceResult.getHitPosition();
            return (vector.getY() - vector.getBlockY()) > 0.5;
        }
        return false;
    }

    private boolean hasSecondBlock(BlockData blockData) {
        if (!(blockData instanceof Bisected)) {
            return false;
        }
        if (blockData instanceof Door) {
            return true;
        }
        return blockData.getClass().getName().contains("Tall");
    }

    private boolean canPlace(Block block, BlockData blockData, boolean force, AtomicBoolean usedForcePlace) {
        if (!block.canPlace(blockData)) {
            if (force) usedForcePlace.set(true);
            return force;
        }
        return replaceables.contains(block.getType().name());
    }

    @AllArgsConstructor
    @Getter
    public enum PlaceItemResult {
        NO_ITEM_HELD(false),
        NO_BLOCK_ITEM_HELD(false),
        NO_VALID_PLACEMENT(false),
        NO_BUILD(false),
        NO_PLACE(false),
        NO_DOUBLE_HIGH_BLOCK_SPACE(false),
        NO_PLACE_INSIDE_PLAYER(false),
        SUCCESS(true),
        SUCCESS_FORCE(true),
        ;

        private final boolean success;

        public boolean wasForced() {
            return this == SUCCESS_FORCE;
        }
    }
}
