/*
 * WorldEdit, a Minecraft world manipulation toolkit
 * Copyright (C) sk89q <http://www.sk89q.com>
 * Copyright (C) WorldEdit team and contributors
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU 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 General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

package com.sk89q.worldedit.internal.anvil;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonIOException;
import com.google.gson.JsonSyntaxException;
import com.google.gson.TypeAdapter;
import com.google.gson.reflect.TypeToken;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import com.sk89q.worldedit.internal.util.LogManagerCompat;
import com.sk89q.worldedit.math.BlockVector2;
import org.apache.logging.log4j.Logger;

import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.BiPredicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public final class ChunkDeleter {

    public static final String DELCHUNKS_FILE_NAME = "delete_chunks.json";
    private static final Logger LOGGER = LogManagerCompat.getLogger();

    private static final Comparator<BlockVector2> chunkSorter = Comparator.comparing(
            pos -> (pos.getBlockX() & 31) + (pos.getBlockZ() & 31) * 32
    );

    private static final Gson chunkDeleterGson = new GsonBuilder()
            .registerTypeAdapter(BlockVector2.class, new BlockVector2Adapter().nullSafe())
            .setPrettyPrinting()
            .create();

    public static ChunkDeletionInfo readInfo(Path chunkFile) throws IOException, JsonSyntaxException {
        String json = Files.readString(chunkFile);
        return chunkDeleterGson.fromJson(json, ChunkDeletionInfo.class);
    }

    public static void writeInfo(ChunkDeletionInfo info, Path chunkFile) throws IOException, JsonIOException {
        String json = chunkDeleterGson.toJson(info, new TypeToken<ChunkDeletionInfo>() {
        }.getType());
        try (BufferedWriter writer = Files.newBufferedWriter(chunkFile)) {
            writer.write(json);
        }
    }

    public static void runFromFile(Path chunkFile, boolean deleteOnSuccess) {
        ChunkDeleter chunkDeleter;
        try {
            chunkDeleter = createFromFile(chunkFile);
        } catch (JsonSyntaxException | IOException e) {
            LOGGER.error("Could not parse chunk deletion file. Invalid file?", e);
            return;
        }
        LOGGER.info("Found chunk deletions. Proceeding with deletion...");
        long start = System.currentTimeMillis();
        if (chunkDeleter.runDeleter()) {
            LOGGER.info("Successfully deleted {} matching chunks (out of {}, taking {} ms).",
                    chunkDeleter.getDeletedChunkCount(), chunkDeleter.getDeletionsRequested(),
                    System.currentTimeMillis() - start
            );
            if (deleteOnSuccess) {
                boolean deletedFile = false;
                try {
                    deletedFile = Files.deleteIfExists(chunkFile);
                } catch (IOException ignored) {
                }
                if (!deletedFile) {
                    LOGGER.warn("Chunk deletion file could not be cleaned up. This may have unintended consequences"
                            + " on next startup, or if /delchunks is used again.");
                }
            }
        } else {
            LOGGER.error("Error occurred while deleting chunks. "
                    + "If world errors occur, stop the server and restore the *.bak backup files.");
        }
    }

    private ChunkDeleter(ChunkDeletionInfo chunkDeletionInfo) {
        this.chunkDeletionInfo = chunkDeletionInfo;
    }

    private static ChunkDeleter createFromFile(Path chunkFile) throws IOException {
        ChunkDeletionInfo info = readInfo(chunkFile);
        if (info == null) {
            throw new IOException("Read null json. Empty file?");
        }
        return new ChunkDeleter(info);
    }

    private final ChunkDeletionInfo chunkDeletionInfo;
    private final Set<Path> backedUpRegions = new HashSet<>();
    private boolean shouldPreload;
    private int debugRate = 100;
    private int totalChunksDeleted = 0;
    private int deletionsRequested = 0;

    private boolean runDeleter() {
        return chunkDeletionInfo.batches.stream().allMatch(this::runBatch);
    }

    private boolean runBatch(ChunkDeletionInfo.ChunkBatch chunkBatch) {
        int chunkCount = chunkBatch.getChunkCount();
        LOGGER.debug("Processing deletion batch with {} chunks.", chunkCount);
        final Map<Path, Stream<BlockVector2>> regionToChunkList = groupChunks(chunkBatch);
        BiPredicate<RegionAccess, BlockVector2> predicate = createPredicates(chunkBatch.deletionPredicates);
        shouldPreload = chunkBatch.chunks == null;
        deletionsRequested += chunkCount;
        debugRate = chunkCount / 10;

        return regionToChunkList.entrySet().stream().allMatch(entry -> {
            Path regionPath = entry.getKey();
            if (!Files.exists(regionPath)) {
                return true;
            }
            if (chunkBatch.backup && !backedUpRegions.contains(regionPath)) {
                try {
                    backupRegion(regionPath);
                } catch (IOException e) {
                    LOGGER.warn("Error backing up region file: " + regionPath + ". Aborting the process.", e);
                    return false;
                }
            }
            return deleteChunks(regionPath, entry.getValue(), predicate);
        });
    }

    private Map<Path, Stream<BlockVector2>> groupChunks(ChunkDeletionInfo.ChunkBatch chunkBatch) {
        Path worldPath = Paths.get(chunkBatch.worldPath);
        if (chunkBatch.chunks != null) {
            return chunkBatch.chunks.stream()
                    .collect(Collectors.groupingBy(RegionFilePos::new))
                    .entrySet().stream().collect(Collectors.toMap(
                            e -> worldPath.resolve("region").resolve(e.getKey().getFileName()),
                            e -> e.getValue().stream().sorted(chunkSorter)
                    ));
        } else {
            final BlockVector2 minChunk = chunkBatch.minChunk;
            final BlockVector2 maxChunk = chunkBatch.maxChunk;
            final RegionFilePos minRegion = new RegionFilePos(minChunk);
            final RegionFilePos maxRegion = new RegionFilePos(maxChunk);
            Map<Path, Stream<BlockVector2>> groupedChunks = new HashMap<>();
            for (int regX = minRegion.getX(); regX <= maxRegion.getX(); regX++) {
                for (int regZ = minRegion.getZ(); regZ <= maxRegion.getZ(); regZ++) {
                    final Path regionPath = worldPath.resolve("region").resolve(new RegionFilePos(regX, regZ).getFileName());
                    if (!Files.exists(regionPath)) {
                        continue;
                    }
                    int startX = regX << 5;
                    int endX = (regX << 5) + 31;
                    int startZ = regZ << 5;
                    int endZ = (regZ << 5) + 31;

                    int minX = Math.max(Math.min(startX, endX), minChunk.getBlockX());
                    int minZ = Math.max(Math.min(startZ, endZ), minChunk.getBlockZ());
                    int maxX = Math.min(Math.max(startX, endX), maxChunk.getBlockX());
                    int maxZ = Math.min(Math.max(startZ, endZ), maxChunk.getBlockZ());
                    Stream<BlockVector2> stream = Stream.iterate(
                            BlockVector2.at(minX, minZ),
                            bv2 -> {
                                int nextX = bv2.getBlockX();
                                int nextZ = bv2.getBlockZ();
                                if (++nextX > maxX) {
                                    nextX = minX;
                                    if (++nextZ > maxZ) {
                                        return null;
                                    }
                                }
                                return BlockVector2.at(nextX, nextZ);
                            }
                    );
                    groupedChunks.put(regionPath, stream);
                }
            }
            return groupedChunks;
        }
    }

    private BiPredicate<RegionAccess, BlockVector2> createPredicates(List<ChunkDeletionInfo.DeletionPredicate> deletionPredicates) {
        if (deletionPredicates == null) {
            return (r, p) -> true;
        }
        return deletionPredicates.stream()
                .map(this::createPredicate)
                .reduce(BiPredicate::and)
                .orElse((r, p) -> true);
    }

    private BiPredicate<RegionAccess, BlockVector2> createPredicate(ChunkDeletionInfo.DeletionPredicate deletionPredicate) {
        if ("modification".equals(deletionPredicate.property)) {
            int time;
            try {
                time = Integer.parseInt(deletionPredicate.value);
            } catch (NumberFormatException e) {
                throw new IllegalStateException("Modification time predicate specified invalid time: " + deletionPredicate.value);
            }
            switch (deletionPredicate.comparison) {
                case "<":
                    return (r, p) -> {
                        try {
                            return r.getModificationTime(p) < time;
                        } catch (IOException e) {
                            return false;
                        }
                    };
                case ">":
                    return (r, p) -> {
                        try {
                            return r.getModificationTime(p) > time;
                        } catch (IOException e) {
                            return false;
                        }
                    };
                default:
                    throw new IllegalStateException("Unexpected comparison value: " + deletionPredicate.comparison);
            }
        }
        throw new IllegalStateException("Unexpected property value: " + deletionPredicate.property);
    }

    private void backupRegion(Path regionFile) throws IOException {
        Path backupFile = regionFile.resolveSibling(regionFile.getFileName() + ".bak");
        Files.copy(regionFile, backupFile, StandardCopyOption.REPLACE_EXISTING);
        backedUpRegions.add(backupFile);
    }

    private boolean deleteChunks(
            Path regionFile, Stream<BlockVector2> chunks,
            BiPredicate<RegionAccess, BlockVector2> deletionPredicate
    ) {
        try (RegionAccess region = new RegionAccess(regionFile, shouldPreload)) {
            for (Iterator<BlockVector2> iterator = chunks.iterator(); iterator.hasNext(); ) {
                BlockVector2 chunk = iterator.next();
                if (chunk == null) {
                    break;
                }
                if (deletionPredicate.test(region, chunk)) {
                    region.deleteChunk(chunk);
                    totalChunksDeleted++;
                    if (debugRate != 0 && totalChunksDeleted % debugRate == 0) {
                        LOGGER.debug("Deleted {} chunks so far.", totalChunksDeleted);
                    }
                } else {
                    LOGGER.debug("Chunk did not match predicates: " + chunk);
                }
            }
            return true;
        } catch (IOException e) {
            LOGGER.warn("Error deleting chunks from region: " + regionFile + ". Aborting the process.", e);
            return false;
        }
    }

    public int getDeletedChunkCount() {
        return totalChunksDeleted;
    }

    public int getDeletionsRequested() {
        return deletionsRequested;
    }

    private static class BlockVector2Adapter extends TypeAdapter<BlockVector2> {

        @Override
        public void write(JsonWriter out, BlockVector2 value) throws IOException {
            out.beginArray();
            out.value(value.getBlockX());
            out.value(value.getBlockZ());
            out.endArray();
        }

        @Override
        public BlockVector2 read(JsonReader in) throws IOException {
            in.beginArray();
            int x = in.nextInt();
            int z = in.nextInt();
            in.endArray();
            return BlockVector2.at(x, z);
        }

    }

    private static class RegionFilePos {

        private final int x;
        private final int z;

        RegionFilePos(BlockVector2 chunk) {
            this.x = chunk.getBlockX() >> 5;
            this.z = chunk.getBlockZ() >> 5;
        }

        RegionFilePos(int regX, int regZ) {
            this.x = regX;
            this.z = regZ;
        }

        public int getX() {
            return x;
        }

        public int getZ() {
            return z;
        }

        public String getFileName() {
            return "r." + x + "." + z + ".mca";
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) {
                return true;
            }
            if (o == null || getClass() != o.getClass()) {
                return false;
            }

            RegionFilePos that = (RegionFilePos) o;

            if (x != that.x) {
                return false;
            }
            return z == that.z;

        }

        @Override
        public int hashCode() {
            int result = x;
            result = 31 * result + z;
            return result;
        }

        @Override
        public String toString() {
            return "(" + x + ", " + z + ")";
        }

    }

}
