package tc.oc.pgm.development;

import java.io.File;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.inject.Inject;

import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.Multimap;
import com.sk89q.bukkit.util.BukkitWrappedCommandSender;
import com.sk89q.minecraft.util.commands.Command;
import com.sk89q.minecraft.util.commands.CommandContext;
import com.sk89q.minecraft.util.commands.CommandException;
import com.sk89q.minecraft.util.commands.CommandPermissions;
import net.md_5.bungee.api.ChatColor;
import net.md_5.bungee.api.chat.TranslatableComponent;
import org.apache.commons.io.FileUtils;
import org.bukkit.World;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.util.Vector;
import org.jdom2.Element;
import java.time.Duration;
import tc.oc.api.docs.PlayerId;
import tc.oc.minecraft.scheduler.SyncExecutor;
import tc.oc.api.util.Permissions;
import tc.oc.commons.bukkit.chat.Audiences;
import tc.oc.commons.bukkit.commands.CommandUtils;
import tc.oc.commons.bukkit.commands.PrettyPaginatedResult;
import tc.oc.commons.bukkit.util.BukkitUtils;
import tc.oc.commons.core.chat.Audience;
import tc.oc.commons.core.chat.Component;
import tc.oc.commons.core.commands.CommandFutureCallback;
import tc.oc.commons.core.commands.Commands;
import tc.oc.commons.core.inspect.Inspection;
import tc.oc.commons.core.inspect.MultiLineTextInspector;
import tc.oc.pgm.PGMTranslations;
import tc.oc.pgm.features.Feature;
import tc.oc.pgm.features.FeatureDefinition;
import tc.oc.pgm.features.FeatureProxy;
import tc.oc.pgm.features.SluggedFeature;
import tc.oc.pgm.filters.Filter;
import tc.oc.pgm.map.MapConfiguration;
import tc.oc.pgm.map.MapDefinition;
import tc.oc.pgm.map.MapLibrary;
import tc.oc.pgm.map.MapLogRecord;
import tc.oc.pgm.map.MapNotFoundException;
import tc.oc.pgm.map.PGMMap;
import tc.oc.pgm.match.Match;
import tc.oc.pgm.match.MatchManager;
import tc.oc.pgm.match.MatchPlayer;
import tc.oc.pgm.match.MatchScope;
import tc.oc.pgm.match.MatchState;
import tc.oc.pgm.physics.AccelerationPlayerFacet;
import tc.oc.pgm.physics.DebugVelocityPlayerFacet;
import tc.oc.pgm.physics.PlayerForce;
import tc.oc.pgm.xml.Node;

public class MapDevelopmentCommands implements Commands {

    private final MapErrorTracker mapErrorTracker;
    private final Map<String, Boolean> mapEnvironment;
    private final MapLibrary mapLibrary;
    private final MatchManager matchManager;
    private final Audiences audiences;
    private final SyncExecutor syncExecutor;

    @Inject MapDevelopmentCommands(MapErrorTracker mapErrorTracker, MapConfiguration mapConfiguration, MapLibrary mapLibrary, MatchManager matchManager, Audiences audiences, SyncExecutor syncExecutor) {
        this.mapErrorTracker = mapErrorTracker;
        this.mapEnvironment = mapConfiguration.environment();
        this.mapLibrary = mapLibrary;
        this.matchManager = matchManager;
        this.audiences = audiences;
        this.syncExecutor = syncExecutor;
    }

    @Command(
        aliases = {"clearerrors", "clearxmlerrors"},
        desc = "Clears XML errors"
    )
    @CommandPermissions(Permissions.MAPERRORS)
    public void clearErrorsCommand(CommandContext args, CommandSender sender) throws CommandException {
        mapErrorTracker.clearAllErrors();
        sender.sendMessage(ChatColor.GREEN + PGMTranslations.get().t("command.development.clearErrors.success", sender));
    }

    @Command(
        aliases = {"errors", "xmlerrors"},
        usage = "[-p page] [map name]",
        desc = "Reads back XML errors",
        min = 0,
        max = -1,
        flags = "p:"
    )
    @CommandPermissions(Permissions.MAPERRORS)
    public List<String> errorsCommand(CommandContext args, final CommandSender sender) throws CommandException {
        final String mapName = args.argsLength() > 0 ? args.getJoinedStrings(0) : "";
        if(args.getSuggestionContext() != null) {
            return tc.oc.pgm.commands.CommandUtils.completeMapName(mapName);
        }

        Multimap<MapDefinition, MapLogRecord> errors = mapErrorTracker.getErrors();
        PGMMap filterMap = null;
        if(!mapName.isEmpty()) {
            filterMap = tc.oc.pgm.commands.CommandUtils.getMap(mapName, sender);
            Multimap<MapDefinition, MapLogRecord> filtered = ArrayListMultimap.create();
            filtered.putAll(filterMap, errors.get(filterMap));
            errors = filtered;
        }

        new PrettyPaginatedResult<Map.Entry<MapDefinition, MapLogRecord>>(filterMap == null ? "Map Errors (" + errors.keySet().size() + " maps)"
                                                                                            : filterMap.getName() + " Errors") {
            @Override
            public String format(Map.Entry<MapDefinition, MapLogRecord> entry, int index) {
                return entry.getValue().getLegacyFormattedMessage();
            }

            @Override
            public String formatEmpty() {
                return ChatColor.GREEN + PGMTranslations.get().t("command.development.listErrors.noErrors", sender);
            }
        }.display(new BukkitWrappedCommandSender(sender), errors.entries(), args.getFlagInteger('p', 1));
        return null;
    }

    @Command(
        aliases = {"loadnewmaps", "findnewmaps", "newmaps"},
        desc = "Scan for new maps and load them",
        min = 0,
        max = 0
    )
    @CommandPermissions("pgm.loadnewmaps")
    public void loadNewMaps(CommandContext args, CommandSender sender) throws CommandException {
        final Audience audience = audiences.get(sender);
        audience.sendMessage(new Component(new TranslatableComponent("command.loadNewMaps.loading"), ChatColor.WHITE));
        // Clear errors for maps that failed to load, because we want to see those errors again
        mapErrorTracker.clearErrorsExcept(mapLibrary.getMaps());
        try {
            final Set<PGMMap> newMaps = matchManager.loadMapsAndRotations();

            if(newMaps.isEmpty()) {
                audience.sendMessage(new Component(new TranslatableComponent("command.loadNewMaps.noNewMaps"), ChatColor.WHITE));
            } else if(newMaps.size() == 1) {
                audience.sendMessage(new Component(new TranslatableComponent("command.loadNewMaps.foundSingleMap", new Component(newMaps.iterator().next().getInfo().name, ChatColor.YELLOW)), ChatColor.WHITE));
            } else {
                audience.sendMessage(new Component(new TranslatableComponent("command.loadNewMaps.foundMultipleMaps", new Component(Integer.toString(newMaps.size()), ChatColor.AQUA)), ChatColor.WHITE));
            }
        } catch(MapNotFoundException e) {
            audience.sendWarning(new TranslatableComponent("command.loadNewMaps.noMaps"), false);
        }
    }

    @Command(
        aliases = {"matchfeatures", "features"},
        desc = "Lists all features by ID and type",
        min = 0,
        max = 1
    )
    @CommandPermissions(Permissions.MAPDEV)
    public void featuresCommand(CommandContext args, CommandSender sender) throws CommandException {
        final Match match = tc.oc.pgm.commands.CommandUtils.getMatch(sender);
        new PrettyPaginatedResult<Feature>("Match Features") {
            @Override
            public String format(Feature feature, int i) {
                String text = (i + 1) + ". " + ChatColor.RED + feature.getClass().getSimpleName();
                if(feature instanceof SluggedFeature) {
                    text += ChatColor.GRAY + " - " +ChatColor.GOLD + ((SluggedFeature) feature).slug();
                }
                return text;
            }
        }.display(new BukkitWrappedCommandSender(sender),
                  match.features().all().collect(Collectors.toList()),
                  args.getInteger(0, 1));
    }

    @Command(
        aliases = {"mapfeatures", "mapf"},
        desc = "Lists all map features by ID and type",
        usage = "[-a(nonymous)] [-v(erbose)] [-l(ocations)] [-t type] [-i id]",
        flags = "avlt:i:",
        min = 0,
        max = 0
    )
    @CommandPermissions(Permissions.MAPDEV)
    public void mapFeaturesCommand(CommandContext args, CommandSender sender) throws CommandException {
        final PGMMap map = tc.oc.pgm.commands.CommandUtils.getMatch(sender).getMap();
        final boolean verbose = args.hasFlag('v');
        final boolean locate = args.hasFlag('l');
        final boolean anonymous = args.hasFlag('a');
        final Optional<String> typeFilter = CommandUtils.flag(args, 't').map(String::toLowerCase);
        final Optional<String> idFilter = CommandUtils.flag(args, 'i').map(String::toLowerCase);

        Stream<? extends FeatureDefinition> features = map.getContext().features().all();
        if(typeFilter.isPresent()) {
            features = features.filter(f -> f.inspectType().toLowerCase().contains(typeFilter.get()));
        }
        if(idFilter.isPresent()) {
            features = features.filter(f -> f instanceof FeatureProxy &&
                                            ((FeatureProxy) f).getId().toLowerCase().contains(idFilter.get()));
        } else if(!anonymous) {
            features = features.filter(f -> f instanceof FeatureProxy);
        }

        features.forEach(feature -> {
            final Component c = new Component(feature.inspectType(), ChatColor.BLUE);

            feature.inspectIdentity().ifPresent(id -> c.extra(" ").extra(new Component(id, ChatColor.YELLOW)));

            if(locate) {
                final Element element = map.getContext().features().definitionNode(feature);
                if(element != null) {
                    c.extra(" ").extra(new Component(new Node(element).describeWithLocation(), ChatColor.DARK_AQUA));
                }
            }

            sender.sendMessage(c);

            if(verbose) {
                feature.inspect(new MultiLineTextInspector(), Inspection.defaults())
                       .forEach(line -> sender.sendMessage(new Component("  " + BukkitUtils.escapeColors(line), ChatColor.GOLD)));
            }
        });
    }

    @Command(
        aliases = {"feature", "fl"},
        desc = "Prints information regarding a specific feature",
        min = 1,
        max = -1
    )
    @CommandPermissions(Permissions.MAPDEV)
    public void featureCommand(CommandContext args, CommandSender sender) throws CommandException {
        final String slug = args.getJoinedStrings(0);
        final Optional<Feature<?>> feature = matchManager.getCurrentMatch(sender).features().bySlug(slug);
        if(feature.isPresent()) {
            sender.sendMessage(ChatColor.GOLD + slug + ChatColor.GRAY + " corresponds to: " + ChatColor.WHITE + feature.get().toString());
        } else {
            sender.sendMessage(ChatColor.RED + "No feature by the name of " + ChatColor.GOLD + slug + ChatColor.RED + " was found.");
        }
    }

    @Command(
        aliases = {"velocity", "vel"},
        desc = "Apply a velocity to a player",
        min = 3,
        max = 4
    )
    @CommandPermissions(Permissions.MAPDEV)
    public void velocity(CommandContext args, CommandSender sender) throws CommandException {
        Player target = CommandUtils.getPlayerOrSelf(args, sender, 3);
        Vector velocity = new Vector(args.getDouble(0), args.getDouble(1), args.getDouble(2));
        sender.sendMessage(String.format("Applying velocity (%.2f, %.2f, %.2f) to " + target.getName(sender),
                                         velocity.getX(), velocity.getY(), velocity.getZ()));
        target.setVelocity(velocity);
    }

    @Command(
        aliases = {"impulse", "imp"},
        desc = "Apply an impulse to a player",
        min = 3,
        max = 4
    )
    @CommandPermissions(Permissions.MAPDEV)
    public void impulse(CommandContext args, CommandSender sender) throws CommandException {
        Player target = CommandUtils.getPlayerOrSelf(args, sender, 3);
        Vector impulse = new Vector(args.getDouble(0), args.getDouble(1), args.getDouble(2));
        sender.sendMessage(String.format("Applying impulse (%.2f, %.2f, %.2f) to " + target.getName(sender),
                                         impulse.getX(), impulse.getY(), impulse.getZ()));
        target.applyImpulse(impulse, true);
    }

    @Command(
        aliases = {"accelerate", "acc"},
        desc = "Apply a continuous force to a player for a period of time",
        min = 4,
        max = 5
    )
    @CommandPermissions(Permissions.MAPDEV)
    public void accelerate(CommandContext args, CommandSender sender) throws CommandException {
        final MatchPlayer target = tc.oc.pgm.commands.CommandUtils.getMatchPlayerOrSelf(args, sender, 4);
        final Duration duration = CommandUtils.getDuration(args, 0);
        final Vector accel = new Vector(args.getDouble(1), args.getDouble(2), args.getDouble(3)).multiply(1d / 20d); // per-tick

        sender.sendMessage(String.format("Applying force (%.2f, %.2f, %.2f) to %s for %s",
                                         accel.getX(), accel.getY(), accel.getZ(),
                                         target.getName(sender),
                                         duration));

        final PlayerForce force = target.facet(AccelerationPlayerFacet.class).addForce(accel);

        final Match match = target.getMatch();
        final PlayerId playerId = target.getPlayerId();
        match.getScheduler(MatchScope.LOADED).createDelayedTask(duration, () -> {
            final MatchPlayer target0 = match.getPlayer(playerId);
            if(target0 != null) {
                target0.facet(AccelerationPlayerFacet.class).removeForce(force);
            }
        });
    }

    @Command(
        aliases = {"filter", "fil"},
        desc = "Query a filter by ID with yourself or the given player",
        usage = "filter [player]",
        min = 1,
        max = 2
    )
    @CommandPermissions(Permissions.MAPDEV)
    public void filter(CommandContext args, CommandSender sender) throws CommandException {
        MatchPlayer target = tc.oc.pgm.commands.CommandUtils.getMatchPlayerOrSelf(args, sender, 1);
        String id = args.getString(0);
        Filter filter = tc.oc.pgm.commands.CommandUtils.getFeatureDefinition(id, sender, Filter.class);

        Filter.QueryResponse response = filter.query(target);
        String out = ChatColor.BLUE + id +
                     ChatColor.DARK_GRAY + "(" +
                     ChatColor.GRAY + target.getName(sender) +
                     ChatColor.DARK_GRAY + ") -> ";

        switch(response) {
            case DENY:      out += ChatColor.DARK_RED; break;
            case ABSTAIN:   out += ChatColor.YELLOW; break;
            case ALLOW:     out += ChatColor.GREEN; break;
        }

        sender.sendMessage(out + response.name());
    }

    @Command(
        aliases = {"updatemap", "savemap"},
        desc = "Updates the original map file to changes in game",
        flags = "f"
    )
    @CommandPermissions("pgm.updatemap")
    public void updateMap(CommandContext args, final CommandSender sender) throws CommandException {
        final Match match = matchManager.getCurrentMatch(sender);
        final PGMMap map = match.getMap();
        final Logger logger = map.getLogger();

        if(match.matchState() != MatchState.Idle && !args.hasFlag('f')) {
            sender.sendMessage(ChatColor.RED + PGMTranslations.get().t("command.map.update.running", sender));
        } else {
            World world = match.getWorld();
            logger.info("Saving world");
            world.save();

            final Path worldFolder = world.getWorldFolder().toPath();

            try {
                // Prune void regions from the world
                Path regionFolder = worldFolder.resolve("region");
                File[] regionFiles = regionFolder.toFile().listFiles();
                if(regionFiles == null) {
                    logger.info("No region folder");
                } else {
                    logger.info("Pruning empty regions");
                    for(File file : regionFiles) {
                        Matcher matcher = Pattern.compile("r\\.(-?\\d+).(-?\\d+).mca").matcher(file.getName());
                        if(!matcher.matches()) continue;

                        int regionX = Integer.parseInt(matcher.group(1));
                        int regionZ = Integer.parseInt(matcher.group(2));
                        int minX = regionX << 5;
                        int minZ = regionZ << 5;
                        int maxX = minX + 32;
                        int maxZ = minZ + 32;
                        boolean empty = true;

                        for(int x = minX; x < maxX; x++) {
                            for(int z = minZ; z < maxZ; z++) {
                                if(!world.getChunkAt(x, z).isEmpty()) empty = false;
                            }
                        }

                        if(empty) {
                            logger.info("  empty: " + file.getName());
                            if(!file.delete()) {
                                throw new CommandException(PGMTranslations.get().t("command.map.update.deleteFailed", sender, file.getName()));
                            }
                        } else {
                            logger.info("  non-empty: " + file.getName());
                        }
                    }
                }

                final Path sourceWorldFolder = map.getFolder().getAbsolutePath();
                Path sourceRegionFolder = sourceWorldFolder.resolve("region");
                Path sourceRegionBackup = sourceWorldFolder.resolve("region_backup");

                if(Files.exists(sourceRegionFolder)) {
                    logger.info("Copying source /region to /region_backup");
                    Files.copy(sourceRegionFolder, sourceRegionBackup, StandardCopyOption.REPLACE_EXISTING);
                }

                logger.info("Searching for changed files");
                Files.walkFileTree(worldFolder, new SimpleFileVisitor<Path>() {
                    @Override
                    public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
                        Path relativePath = worldFolder.relativize(file);
                        File sourceFile = sourceWorldFolder.resolve(relativePath).toFile();
                        // Always copy region files (mca)
                        if(!sourceFile.exists() && !file.getFileName().toString().endsWith(".mca")) {
                            logger.info("  skipping: " + relativePath);
                        }
                        else if(FileUtils.contentEquals(file.toFile(), sourceFile)) {
                            logger.info("  unchanged: " + relativePath);
                        }
                        else {
                            logger.info("  changed: " + relativePath);
                            Files.copy(file, sourceFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
                        }
                        return FileVisitResult.CONTINUE;
                    }
                });

                if(Files.exists(sourceRegionBackup)) {
                    logger.info("Deleting region backup");
                    Files.delete(sourceRegionBackup);
                }
            } catch(IOException e) {
                throw new CommandException(e.toString());
            }

            sender.sendMessage(ChatColor.GREEN + PGMTranslations.get().t("command.map.update.success", sender));
        }
    }

    @Command(
        aliases = {"pushmaps"},
        desc = "Synchronizes ALL loaded maps with the database",
        min = 0,
        max = 0
    )
    @CommandPermissions(Permissions.DEVELOPER)
    public void pushMaps(CommandContext args, final CommandSender sender) throws CommandException {
        Audience audience = audiences.get(sender);
        audience.sendMessage(new Component("Pushing " + mapLibrary.getMaps().size() + " maps..."));

        syncExecutor.callback(
            mapLibrary.pushAllMaps(),
            CommandFutureCallback.onSuccess(sender, args, response ->
                audience.sendMessage(new Component(response.toString()))
            )
        );
    }

    @Command(
        aliases = {"environment", "env"},
        desc = "Get/set map environment variables"
    )
    @CommandPermissions(Permissions.MAPDEV)
    public void environment(CommandContext args, final CommandSender sender) throws CommandException {
        final Audience audience = audiences.get(sender);

        boolean reset = false;
        Map<String, Boolean> vars = new HashMap<>();
        Pattern pattern = Pattern.compile("([A-Za-z0-9_]+)=(true|false)");

        for(int i = 0; i < args.argsLength(); i++) {
            String arg = args.getString(i);
            if("reset".equals(arg)) {
                reset = true;
            } else {
                Matcher matcher = pattern.matcher(arg);
                if(!matcher.matches()) {
                    throw new CommandException("Can't understand variable assignment '" + arg + "'");
                }
                vars.put(matcher.group(1).toLowerCase(), "true".equals(matcher.group(2)));
            }
        }

        if(reset) mapEnvironment.clear();
        mapEnvironment.putAll(vars);

        for(Map.Entry<String, Boolean> entry : mapEnvironment.entrySet()) {
            audience.sendMessage(
                new Component(ChatColor.GRAY)
                    .extra(new Component(entry.getKey(), ChatColor.WHITE))
                    .extra("=")
                    .extra(new Component(String.valueOf(entry.getValue()), ChatColor.BLUE))
            );
        }
    }

    @Command(
        aliases = {"debugvelocity"},
        desc = "Dump debug info about a player's velocity to the console",
        usage = "[player]",
        min = 0,
        max = 1
    )
    @CommandPermissions(Permissions.MAPDEV)
    public void debugVelocity(CommandContext args, CommandSender sender) throws CommandException {
        final MatchPlayer player = tc.oc.pgm.commands.CommandUtils.getMatchPlayerOrSelf(args, sender, 0);
        final DebugVelocityPlayerFacet facet = player.facet(DebugVelocityPlayerFacet.class);
        final boolean enabled = !facet.isEnabled();
        facet.setEnabled(enabled);
        sender.sendMessage(new Component("Velocity debug for " + player.getName() + " is " + (enabled ? "ENABLED" : "DISABLED")));
    }
}