/*
 *   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.core;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.Maps;
import com.sk89q.jnbt.*;
import com.sk89q.worldedit.WorldEdit;
import com.sk89q.worldedit.WorldEditException;
import com.sk89q.worldedit.extension.input.InputParseException;
import com.sk89q.worldedit.extension.input.ParserContext;
import com.sk89q.worldedit.extension.platform.Actor;
import com.sk89q.worldedit.extension.platform.Capability;
import com.sk89q.worldedit.extension.platform.Platform;
import com.sk89q.worldedit.extent.clipboard.BlockArrayClipboard;
import com.sk89q.worldedit.extent.clipboard.Clipboard;
import com.sk89q.worldedit.extent.clipboard.io.*;
import com.sk89q.worldedit.extent.clipboard.io.legacycompat.*;
import com.sk89q.worldedit.math.BlockVector3;
import com.sk89q.worldedit.math.Vector3;
import com.sk89q.worldedit.math.transform.Transform;
import com.sk89q.worldedit.regions.CuboidRegion;
import com.sk89q.worldedit.regions.Region;
import com.sk89q.worldedit.session.ClipboardHolder;
import com.sk89q.worldedit.world.DataFixer;
import com.sk89q.worldedit.world.block.BlockState;
import com.sk89q.worldedit.world.block.BlockTypes;
import com.sk89q.worldedit.world.registry.LegacyMapper;
import de.steamwar.sql.NoClipboardException;
import de.steamwar.sql.NodeData;
import org.bukkit.entity.Player;
import org.bukkit.util.Vector;

import java.io.*;
import java.util.*;
import java.util.stream.Collectors;

import static com.google.common.base.Preconditions.checkNotNull;

@SuppressWarnings("removal")
public class WorldEditWrapper14 implements WorldEditWrapper {

    @Override
    public InputStream getPlayerClipboard(Player player) {
        return WorldEditWrapper.getPlayerClipboard(player, (outputStream, clipboard, clipboardHolder) -> {
            ClipboardWriter writer = BuiltInClipboardFormat.SPONGE_SCHEMATIC.getWriter(outputStream);
            writer.write(clipboard);
            writer.close();
        });
    }

    @Override
    public void setPlayerClipboard(Player player, InputStream is, NodeData.SchematicFormat schemFormat) {
        Clipboard clipboard = null;
        try {
            clipboard = getClipboard(is, schemFormat);
        } catch (IOException e) {
            throw new RuntimeException(e.getMessage(), e);
        }

        if (clipboard == null)
            throw new NoClipboardException();

        Actor actor = WorldEditWrapper.getWorldEditPlugin().wrapCommandSender(player);
        WorldEditWrapper.getWorldEditPlugin().getWorldEdit().getSessionManager().get(actor).setClipboard(new ClipboardHolder(clipboard));
    }

    @Override
    public Clipboard getClipboard(InputStream is, NodeData.SchematicFormat schemFormat) throws IOException {
        try {

            switch (schemFormat) {
                case SPONGE_V2:
                case SPONGE_V3:
                    return new SpongeSchematicReader(new NBTInputStream(is), this).read();
                case MCEDIT:
                    return new MCEditSchematicReader(new NBTInputStream(is)).read();
                default:
                    throw new IOException("This schematic format is currently not supported");
            }
        } catch (NullPointerException e) {
            throw new NoClipboardException();
        }
    }

    @Override
    public org.bukkit.util.Vector getOrigin(Clipboard clipboard) {
        return new org.bukkit.util.Vector(clipboard.getOrigin().getX(), clipboard.getOrigin().getY(), clipboard.getOrigin().getZ());
    }

    @Override
    public Vector getMinimum(Region region) {
        return new Vector(region.getMinimumPoint().getX(), region.getMinimumPoint().getY(), region.getMinimumPoint().getZ());
    }

    @Override
    public Vector getMaximum(Region region) {
        return new Vector(region.getMaximumPoint().getX(), region.getMaximumPoint().getY(), region.getMaximumPoint().getZ());
    }

    @Override
    public Vector applyTransform(Vector vector, Transform transform) {
        Vector3 v = Vector3.at(vector.getX(), vector.getY(), vector.getZ());
        v = transform.apply(v);
        return new org.bukkit.util.Vector(v.getX(), v.getY(), v.getZ());
    }

    @Override
    public NodeData.SchematicFormat getNativeFormat() {
        return NodeData.SchematicFormat.SPONGE_V2;
    }

    public Map<String, Tag> applyDataFixer(DataFixer fixer, int dataVersion, Map<String, Tag> values) {
        return fixer.fixUp(DataFixer.FixTypes.BLOCK_ENTITY, new CompoundTag(values), dataVersion).getValue();
    }

    private static class MCEditSchematicReader extends NBTSchematicReader {

        private final NBTInputStream inputStream;
        private final DataFixer fixer;
        private boolean faweSchem = false;
        private static final ImmutableList<NBTCompatibilityHandler> COMPATIBILITY_HANDLERS
                = ImmutableList.of(
                new SignCompatibilityHandler(),
                new FlowerPotCompatibilityHandler(),
                new NoteBlockCompatibilityHandler(),
                new SkullBlockCompatibilityHandler()
        );

        /**
         * Create a new instance.
         *
         * @param inputStream the input stream to read from
         */
        MCEditSchematicReader(NBTInputStream inputStream) {
            checkNotNull(inputStream);
            this.inputStream = inputStream;
            this.fixer = null;
        }

        @Override
        public Clipboard read() throws IOException {
            // Schematic tag
            NamedTag rootTag = inputStream.readNamedTag();
            if (!rootTag.getName().equals("Schematic")) {
                throw new IOException("Tag 'Schematic' does not exist or is not first");
            }
            CompoundTag schematicTag = (CompoundTag) rootTag.getTag();

            // Check
            Map<String, Tag> schematic = schematicTag.getValue();
            if (!schematic.containsKey("Blocks")) {
                throw new IOException("Schematic file is missing a 'Blocks' tag");
            }

            // Check type of Schematic
            String materials = requireTag(schematic, "Materials", StringTag.class).getValue();
            if (!materials.equals("Alpha")) {
                throw new IOException("Schematic file is not an Alpha schematic");
            }

            // ====================================================================
            // Metadata
            // ====================================================================

            BlockVector3 origin;
            Region region;

            // Get information
            short width = requireTag(schematic, "Width", ShortTag.class).getValue();
            short height = requireTag(schematic, "Height", ShortTag.class).getValue();
            short length = requireTag(schematic, "Length", ShortTag.class).getValue();

            int originX = 0;
            int originY = 0;
            int originZ = 0;
            try {
                originX = requireTag(schematic, "WEOriginX", IntTag.class).getValue();
                originY = requireTag(schematic, "WEOriginY", IntTag.class).getValue();
                originZ = requireTag(schematic, "WEOriginZ", IntTag.class).getValue();
                BlockVector3 min = BlockVector3.at(originX, originY, originZ);

                int offsetX = requireTag(schematic, "WEOffsetX", IntTag.class).getValue();
                int offsetY = requireTag(schematic, "WEOffsetY", IntTag.class).getValue();
                int offsetZ = requireTag(schematic, "WEOffsetZ", IntTag.class).getValue();
                BlockVector3 offset = BlockVector3.at(offsetX, offsetY, offsetZ);

                origin = min.subtract(offset);
                region = new CuboidRegion(min, min.add(width, height, length).subtract(BlockVector3.ONE));
            } catch (IOException ignored) {
                origin = BlockVector3.ZERO;
                region = new CuboidRegion(origin, origin.add(width, height, length).subtract(BlockVector3.ONE));
            }

            // ====================================================================
            // Blocks
            // ====================================================================

            // Get blocks
            byte[] blockId = requireTag(schematic, "Blocks", ByteArrayTag.class).getValue();
            byte[] blockData = requireTag(schematic, "Data", ByteArrayTag.class).getValue();
            byte[] addId = new byte[0];
            short[] blocks = new short[blockId.length]; // Have to later combine IDs

            // We support 4096 block IDs using the same method as vanilla Minecraft, where
            // the highest 4 bits are stored in a separate byte array.
            if (schematic.containsKey("AddBlocks")) {
                addId = requireTag(schematic, "AddBlocks", ByteArrayTag.class).getValue();
            }

            // Combine the AddBlocks data with the first 8-bit block ID
            for (int index = 0; index < blockId.length; index++) {
                if ((index >> 1) >= addId.length) { // No corresponding AddBlocks index
                    blocks[index] = (short) (blockId[index] & 0xFF);
                } else {
                    if ((index & 1) == 0) {
                        blocks[index] = (short) (((addId[index >> 1] & 0x0F) << 8) + (blockId[index] & 0xFF));
                    } else {
                        blocks[index] = (short) (((addId[index >> 1] & 0xF0) << 4) + (blockId[index] & 0xFF));
                    }
                }
            }

            // Need to pull out tile entities
            final ListTag tileEntityTag = getTag(schematic, "TileEntities", ListTag.class);
            List<Tag> tileEntities = tileEntityTag == null ? new ArrayList<>() : tileEntityTag.getValue();
            Map<BlockVector3, Map<String, Tag>> tileEntitiesMap = new HashMap<>();
            Map<BlockVector3, BlockState> blockStates = new HashMap<>();

            for (Tag tag : tileEntities) {
                if (!(tag instanceof CompoundTag)) continue;
                CompoundTag t = (CompoundTag) tag;
                int x = t.getInt("x");
                int y = t.getInt("y");
                int z = t.getInt("z");
                int index = y * width * length + z * width + x;
                if(index < 0 || index >= blocks.length)
                    faweSchem = true;
            }

            for (Tag tag : tileEntities) {
                if (!(tag instanceof CompoundTag)) continue;
                CompoundTag t = (CompoundTag) tag;
                Map<String, Tag> values = new HashMap<>(t.getValue());
                String id = t.getString("id");
                values.put("id", new StringTag(convertBlockEntityId(id)));
                int x = t.getInt("x");
                int y = t.getInt("y");
                int z = t.getInt("z");
                if(faweSchem){
                    x -= originX;
                    y -= originY;
                    z -= originZ;
                }

                int index = y * width * length + z * width + x;

                try{
                    BlockState block = getBlockState(blocks[index], blockData[index]);
                    BlockState newBlock = block;
                    if (newBlock != null) {
                        for (NBTCompatibilityHandler handler : COMPATIBILITY_HANDLERS) {
                            if (handler.isAffectedBlock(newBlock)) {
                                newBlock = handler.updateNBT(block, values);
                                if (newBlock == null || values.isEmpty()) {
                                    break;
                                }
                            }
                        }
                    }
                    if (values.isEmpty()) {
                        t = null;
                    } else {
                        t = new CompoundTag(values);
                    }

                    if (fixer != null && t != null) {
                        t = fixer.fixUp(DataFixer.FixTypes.BLOCK_ENTITY, t, -1);
                    }

                    BlockVector3 vec = BlockVector3.at(x, y, z);
                    if (t != null) {
                        tileEntitiesMap.put(vec, t.getValue());
                    }
                    blockStates.put(vec, newBlock);
                }catch(ArrayIndexOutOfBoundsException e){
                    //ignored
                }
            }

            BlockArrayClipboard clipboard = new BlockArrayClipboard(region);
            clipboard.setOrigin(origin);


            for (int x = 0; x < width; ++x) {
                for (int y = 0; y < height; ++y) {
                    for (int z = 0; z < length; ++z) {
                        int index = y * width * length + z * width + x;
                        BlockVector3 pt = BlockVector3.at(x, y, z);
                        BlockState state = blockStates.computeIfAbsent(pt, p -> getBlockState(blocks[index], blockData[index]));

                        try {
                            if (state != null) {
                                if (tileEntitiesMap.containsKey(pt)) {
                                    clipboard.setBlock(region.getMinimumPoint().add(pt), state.toBaseBlock(new CompoundTag(tileEntitiesMap.get(pt))));
                                } else {
                                    clipboard.setBlock(region.getMinimumPoint().add(pt), state);
                                }
                            }
                        } catch (WorldEditException ignored) { // BlockArrayClipboard won't throw this
                        }
                    }
                }
            }

            return clipboard;
        }

        private String convertBlockEntityId(String id) {
            switch (id) {
                case "Cauldron":
                    return "brewing_stand";
                case "Control":
                    return "command_block";
                case "DLDetector":
                    return "daylight_detector";
                case "Trap":
                    return "dispenser";
                case "EnchantTable":
                    return "enchanting_table";
                case "EndGateway":
                    return "end_gateway";
                case "AirPortal":
                    return "end_portal";
                case "EnderChest":
                    return "ender_chest";
                case "FlowerPot":
                    return "flower_pot";
                case "RecordPlayer":
                    return "jukebox";
                case "MobSpawner":
                    return "mob_spawner";
                case "Music":
                case "noteblock":
                    return "note_block";
                case "Structure":
                    return "structure_block";
                case "Chest":
                    return "chest";
                case "Sign":
                    return "sign";
                case "Banner":
                    return "banner";
                case "Beacon":
                    return "beacon";
                case "Comparator":
                    return "comparator";
                case "Dropper":
                    return "dropper";
                case "Furnace":
                    return "furnace";
                case "Hopper":
                    return "hopper";
                case "Skull":
                    return "skull";
                default:
                    return id;
            }
        }

        private BlockState getBlockState(int id, int data) {
            return LegacyMapper.getInstance().getBlockFromLegacy(id, data);
        }

        @Override
        public void close() throws IOException {
            inputStream.close();
        }
    }
    public static class SpongeSchematicReader extends NBTSchematicReader {

        private final NBTInputStream inputStream;
        private DataFixer fixer = null;
        private int schematicVersion = -1;
        private int dataVersion = -1;
        private boolean faweSchem = false;
        private final WorldEditWrapper14 wrapper;

        /**
         * Create a new instance.
         *
         * @param inputStream the input stream to read from
         */
        public SpongeSchematicReader(NBTInputStream inputStream, WorldEditWrapper14 wrapper) {
            checkNotNull(inputStream);
            this.inputStream = inputStream;
            this.wrapper = wrapper;
        }

        @Override
        public Clipboard read() throws IOException {
            CompoundTag schematicTag = getBaseTag();
            Map<String, Tag> schematic = schematicTag.getValue();

            final Platform platform = WorldEdit.getInstance().getPlatformManager()
                    .queryCapability(Capability.WORLD_EDITING);
            int liveDataVersion = platform.getDataVersion();

            if (schematicVersion == 1) {
                dataVersion = 1631; // this is a relatively safe assumption unless someone imports a schematic from 1.12, e.g. sponge 7.1-
                fixer = platform.getDataFixer();
                return readVersion1(schematicTag);
            } else if (schematicVersion == 2 || schematicVersion == 3) {
                dataVersion = requireTag(schematic, "DataVersion", IntTag.class).getValue();
                if (dataVersion < liveDataVersion) {
                    fixer = platform.getDataFixer();
                }

                return readVersion1(schematicTag);
            }
            throw new IOException("This schematic version is currently not supported");
        }

        @Override
        public OptionalInt getDataVersion() {
            try {
                CompoundTag schematicTag = getBaseTag();
                Map<String, Tag> schematic = schematicTag.getValue();
                if (schematicVersion == 1) {
                    return OptionalInt.of(1631);
                } else if (schematicVersion == 2) {
                    return OptionalInt.of(requireTag(schematic, "DataVersion", IntTag.class).getValue());
                }
                return OptionalInt.empty();
            } catch (IOException e) {
                return OptionalInt.empty();
            }
        }

        private CompoundTag getBaseTag() throws IOException {
            NamedTag rootTag = inputStream.readNamedTag();
            CompoundTag schematicTag = (CompoundTag) rootTag.getTag();

            // Check
            Map<String, Tag> schematic = schematicTag.getValue();

            if (schematic.size() == 1) {
                schematicTag = requireTag(schematic, "Schematic", CompoundTag.class);
                schematic = schematicTag.getValue();
            } else if (!rootTag.getName().equals("Schematic")) {
                throw new IOException("Tag 'Schematic' does not exist or is not first");
            }

            schematicVersion = requireTag(schematic, "Version", IntTag.class).getValue();
            return schematicTag;
        }

        private BlockArrayClipboard readVersion1(CompoundTag schematicTag) throws IOException {
            BlockVector3 origin;
            Region region;
            Map<String, Tag> schematic = schematicTag.getValue();

            int width = requireTag(schematic, "Width", ShortTag.class).getValue();
            int height = requireTag(schematic, "Height", ShortTag.class).getValue();
            int length = requireTag(schematic, "Length", ShortTag.class).getValue();

            IntArrayTag offsetTag = getTag(schematic, "Offset", IntArrayTag.class);
            int[] offsetParts;
            if (offsetTag != null) {
                offsetParts = offsetTag.getValue();
                if  (offsetParts.length != 3) {
                    throw new IOException("Invalid offset specified in schematic.");
                }
            } else {
                offsetParts = new int[] {0, 0, 0};
            }

            BlockVector3 min = BlockVector3.at(offsetParts[0], offsetParts[1], offsetParts[2]);

            CompoundTag metadataTag = getTag(schematic, "Metadata", CompoundTag.class);
            int offsetX = 0;
            int offsetY = 0;
            int offsetZ = 0;
            if (metadataTag != null && metadataTag.containsKey("WEOffsetX")) {
                // We appear to have WorldEdit Metadata
                Map<String, Tag> metadata = metadataTag.getValue();
                offsetX = requireTag(metadata, "WEOffsetX", IntTag.class).getValue();
                offsetY = requireTag(metadata, "WEOffsetY", IntTag.class).getValue();
                offsetZ = requireTag(metadata, "WEOffsetZ", IntTag.class).getValue();
                BlockVector3 offset = BlockVector3.at(offsetX, offsetY, offsetZ);
                origin = min.subtract(offset);
                region = new CuboidRegion(min, min.add(width, height, length).subtract(BlockVector3.ONE));
            } else {
                origin = min;
                region = new CuboidRegion(origin, origin.add(width, height, length).subtract(BlockVector3.ONE));
            }

            Map<String, Tag> blockContainer = null;
            boolean v3Mode = false;

            if (schematicVersion == 3) {
                blockContainer = requireTag(schematic, "Blocks", CompoundTag.class).getValue();
                v3Mode = true;
            }

            Map<String, Tag> paletteObject = requireTag(v3Mode ? blockContainer: schematic, "Palette", CompoundTag.class).getValue();

            Map<Integer, BlockState> palette = new HashMap<>();

            ParserContext parserContext = new ParserContext();
            parserContext.setRestricted(false);
            parserContext.setTryLegacy(false);
            parserContext.setPreferringWildcard(false);

            for (String palettePart : paletteObject.keySet()) {
                int id = requireTag(paletteObject, palettePart, IntTag.class).getValue();
                if (fixer != null) {
                    palettePart = fixer.fixUp(DataFixer.FixTypes.BLOCK_STATE, palettePart, dataVersion);
                }
                BlockState state;
                try {
                    state = WorldEdit.getInstance().getBlockFactory().parseFromInput(palettePart, parserContext).toImmutableState();
                } catch (InputParseException e) {
                    state = BlockTypes.AIR.getDefaultState();
                }
                palette.put(id, state);
            }

            byte[] blocks = requireTag(v3Mode ? blockContainer: schematic, v3Mode ? "Data" : "BlockData", ByteArrayTag.class).getValue();

            Map<BlockVector3, Map<String, Tag>> tileEntitiesMap = new HashMap<>();
            ListTag tileEntities = getTag(v3Mode ? blockContainer: schematic, "BlockEntities", ListTag.class);
            if (tileEntities == null) {
                tileEntities = getTag(v3Mode ? blockContainer: schematic, "TileEntities", ListTag.class);
            }
            if (tileEntities != null) {
                List<Map<String, Tag>> tileEntityTags = tileEntities.getValue().stream()
                        .map(tag -> (CompoundTag) tag)
                        .map(CompoundTag::getValue)
                        .collect(Collectors.toList());

                for (Map<String, Tag> tileEntity : tileEntityTags) {
                    int[] pos = requireTag(tileEntity, "Pos", IntArrayTag.class).getValue();
                    if(pos[0] < 0 || pos[0] >= width || pos[1] < 0 || pos[1] >= height || pos[2] < 0 || pos[2] >= length)
                        faweSchem = true;
                }

                for (Map<String, Tag> tileEntity : tileEntityTags) {
                    int[] pos = requireTag(tileEntity, "Pos", IntArrayTag.class).getValue();
                    final BlockVector3 pt = BlockVector3.at(pos[0], pos[1], pos[2]);
                    Map<String, Tag> values = Maps.newHashMap(v3Mode ? requireTag(tileEntity, "Data", CompoundTag.class).getValue() : tileEntity);
                    if(faweSchem){
                        values.put("x", new IntTag(pt.getBlockX() - offsetX));
                        values.put("y", new IntTag(pt.getBlockY() - offsetY));
                        values.put("z", new IntTag(pt.getBlockZ() - offsetZ));
                    }else{
                        values.putIfAbsent("x", new IntTag(pt.getBlockX()));
                        values.putIfAbsent("y", new IntTag(pt.getBlockY()));
                        values.putIfAbsent("z", new IntTag(pt.getBlockZ()));
                    }
                    values.putIfAbsent("id", values.get("Id"));
                    values.remove("Id");
                    values.remove("Pos");
                    if (fixer != null) {
                        tileEntity = wrapper.applyDataFixer(fixer, dataVersion, values);
                    } else {
                        tileEntity = values;
                    }
                    tileEntitiesMap.put(pt, tileEntity);
                }
            }

            BlockArrayClipboard clipboard = new BlockArrayClipboard(region);
            clipboard.setOrigin(origin);

            int index = 0;
            int i = 0;
            int value;
            int varintLength;
            while (i < blocks.length) {
                value = 0;
                varintLength = 0;

                while (true) {
                    value |= (blocks[i] & 127) << (varintLength++ * 7);
                    if (varintLength > 5) {
                        throw new IOException("VarInt too big (probably corrupted data)");
                    }
                    if ((blocks[i] & 128) != 128) {
                        i++;
                        break;
                    }
                    i++;
                }
                // index = (y * length * width) + (z * width) + x
                int y = index / (width * length);
                int z = (index % (width * length)) / width;
                int x = (index % (width * length)) % width;
                BlockState state = palette.get(value);
                BlockVector3 pt = BlockVector3.at(x, y, z);
                try {
                    if (tileEntitiesMap.containsKey(pt)) {
                        clipboard.setBlock(clipboard.getMinimumPoint().add(pt), state.toBaseBlock(new CompoundTag(tileEntitiesMap.get(pt))));
                    } else {
                        clipboard.setBlock(clipboard.getMinimumPoint().add(pt), state);
                    }
                } catch (WorldEditException e) {
                    throw new IOException("Failed to load a block in the schematic");
                }

                index++;
            }

            return clipboard;
        }

        @Override
        public void close() throws IOException {
            inputStream.close();
        }
    }
}
