package nz.co.jammehcow.lukkit.environment.plugin;

import com.avaje.ebean.EbeanServer;
import nz.co.jammehcow.lukkit.Main;
import nz.co.jammehcow.lukkit.Utilities;
import nz.co.jammehcow.lukkit.environment.LuaEnvironment;
import nz.co.jammehcow.lukkit.environment.plugin.commands.LukkitCommand;
import nz.co.jammehcow.lukkit.environment.wrappers.ConfigWrapper;
import nz.co.jammehcow.lukkit.environment.wrappers.LoggerWrapper;
import nz.co.jammehcow.lukkit.environment.wrappers.PluginWrapper;
import nz.co.jammehcow.lukkit.environment.wrappers.UtilitiesWrapper;
import org.bukkit.Server;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.event.Event;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.generator.ChunkGenerator;
import org.bukkit.plugin.*;
import org.luaj.vm2.*;
import org.luaj.vm2.lib.OneArgFunction;
import org.luaj.vm2.lib.VarArgFunction;
import org.luaj.vm2.lib.jse.CoerceJavaToLua;

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Optional;
import java.util.logging.Logger;

/**
 * The type Lukkit plugin.
 *
 * @author jammehcow
 */
public class LukkitPlugin implements Plugin {
    private final String name;
    private final LukkitPluginFile pluginFile;
    private final LuaValue pluginMain;
    private final LukkitPluginLoader pluginLoader;
    private final PluginDescriptionFile descriptor;
    private final File dataFolder;
    private final Logger logger;
    private final List<LukkitCommand> commands = new ArrayList<>();
    private final HashMap<Class<? extends Event>, ArrayList<LuaFunction>> eventListeners = new HashMap<>();
    private UtilitiesWrapper utilitiesWrapper;
    private LuaFunction loadCB;
    private LuaFunction enableCB;
    private LuaFunction disableCB;
    private File pluginConfig;
    private FileConfiguration config;
    private boolean enabled = false;
    private boolean naggable = true;

    /**
     * Instantiates a new Lukkit plugin.
     *
     * @param loader the loader
     * @param file   the file
     */
    public LukkitPlugin(LukkitPluginLoader loader, LukkitPluginFile file) throws InvalidPluginException {
        this.pluginFile = file;
        this.pluginLoader = loader;

        try {
            this.descriptor = new PluginDescriptionFile(this.pluginFile.getPluginYML());
        } catch (InvalidDescriptionException e) {
            e.printStackTrace();
            throw new InvalidPluginException("The description provided was invalid or missing.");
        }

        this.name = this.descriptor.getName();
        this.logger = new PluginLogger(this);
        Globals globals = LuaEnvironment.getNewGlobals(this);

        this.pluginMain = globals.load(new InputStreamReader(this.pluginFile.getResource(this.descriptor.getMain()), StandardCharsets.UTF_8), this.descriptor.getMain());
        this.dataFolder = new File(Main.instance.getDataFolder().getParentFile().getAbsolutePath() + File.separator + this.name);
        if (!this.dataFolder.exists()) //noinspection ResultOfMethodCallIgnored
            this.dataFolder.mkdir();

        this.pluginConfig = new File(this.dataFolder + File.separator + "config.yml");
        this.config = new YamlConfiguration();
        this.loadConfigWithChecks();

        setupPluginGlobals(globals);

        // Sets callbacks (if any) and loads the commands & events into memory.
        Optional<String> isValid = this.checkPluginValidity();
        if (isValid.isPresent())
            throw new InvalidPluginException("An issue occurred when loading the plugin: \n" + isValid.get());

        try {
            this.pluginMain.call();
            this.onLoad();
        } catch (LukkitPluginException e) {
            e.printStackTrace();
            LuaEnvironment.addError(e);
        }
    }

    @Override
    public File getDataFolder() {
        return this.dataFolder;
    }

    @Override
    public PluginDescriptionFile getDescription() {
        return this.descriptor;
    }

    @Override
    public FileConfiguration getConfig() {
        return this.config;
    }

    @Override
    public InputStream getResource(String path) {
        return this.pluginFile.getResource(path);
    }

    @Override
    public void saveConfig() {
        if (this.config != null) {
            try {
                this.config.save(pluginConfig);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    @Override
    public void saveDefaultConfig() {
        try {
            Files.copy(this.pluginFile.getDefaultConfig(), new File(this.dataFolder.getAbsolutePath() + File.separator + "config.yml").toPath());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    @Override
    public void saveResource(String resourcePath, boolean replace) {
        if (resourcePath.startsWith("/")) resourcePath = resourcePath.replaceFirst("/", "");

        String fileName = resourcePath.split("/")[resourcePath.split("/").length - 1];
        File resourceOutput = new File(this.dataFolder.getAbsolutePath() + File.separator + fileName);
        InputStream is = this.pluginFile.getResource(resourcePath);

        if (is != null) {
            if (!resourceOutput.exists() || replace) {
                try {
                    Files.copy(is, resourceOutput.toPath(), StandardCopyOption.REPLACE_EXISTING);
                } catch (IOException e) {
                    this.logger.severe("There was an issue copying a resource to the data folder.");
                    e.printStackTrace();
                }
            } else {
                this.logger.info("Will not export resource " + resourcePath + " to " + this.dataFolder.getName() + " as it already exists and has not been marked to be replaced.");
            }
        } else {
            this.logger.warning("The resource requested doesn't exist. Unable to find " + resourcePath + " in " + this.pluginFile.getPath());
        }
    }

    @Override
    public void reloadConfig() {
        if (this.pluginConfig != null) {
            try {
                this.config.load(this.dataFolder);
            } catch (IOException | InvalidConfigurationException e) {
                e.printStackTrace();
            }
        }
    }

    @Override
    public PluginLoader getPluginLoader() {
        return this.pluginLoader;
    }

    @Override
    public Server getServer() {
        return Main.instance.getServer();
    }

    @Override
    public boolean isEnabled() {
        return this.enabled;
    }

    @Override
    public void onEnable() {
        this.enabled = true;
        try {
            if (this.enableCB != null) this.enableCB.call(CoerceJavaToLua.coerce(this));
        } catch (LukkitPluginException e) {
            e.printStackTrace();
            LuaEnvironment.addError(e);
        }

        eventListeners.forEach((event, list) ->
                list.forEach(function ->
                        this.getServer().getPluginManager().registerEvent(event, new Listener() {
                        }, EventPriority.NORMAL, (l, e) -> function.call(CoerceJavaToLua.coerce(e)), this, false)
                ));
    }

    @Override
    public void onDisable() {
        this.enabled = false;
        try {
            if (this.disableCB != null) this.disableCB.call(CoerceJavaToLua.coerce(this));
        } catch (LukkitPluginException e) {
            e.printStackTrace();
            LuaEnvironment.addError(e);
        }
        unregisterAllCommands();
        utilitiesWrapper.close();
    }

    @Override
    public void onLoad() {
        try {
            if (this.loadCB != null) this.loadCB.call();
        } catch (LukkitPluginException e) {
            e.printStackTrace();
            LuaEnvironment.addError(e);
        }
    }

    @Override
    public boolean isNaggable() {
        return this.naggable;
    }

    @Override
    public void setNaggable(boolean isNaggable) {
        this.naggable = isNaggable;
    }

    public EbeanServer getDatabase() {
        // Deprecated
        return null;
    }

    @Override
    public ChunkGenerator getDefaultWorldGenerator(String worldName, String id) {
        return Main.instance.getDefaultWorldGenerator(worldName, id);
    }

    @Override
    public Logger getLogger() {
        return this.logger;
    }

    @Override
    public String getName() {
        return this.name;
    }

    @Override
    public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
        return true;
    }

    @Override
    public List<String> onTabComplete(CommandSender commandSender, Command command, String label, String[] strings) {
        // TODO
        return null;
    }

    public File getFile() {
        return new File(this.pluginFile.getPath());
    }

    public boolean isDevPlugin() {
        return this.pluginFile.isDevPlugin();
    }

    public void setLoadCB(LuaFunction cb) {
        this.loadCB = cb;
    }

    public void setEnableCB(LuaFunction cb) {
        this.enableCB = cb;
    }

    public void setDisableCB(LuaFunction cb) {
        this.disableCB = cb;
    }

    public Listener registerEvent(Class<? extends Event> event, LuaFunction function) {
        getEventListeners(event).add(function);
        if (this.enabled) {
            Listener listener = new Listener() {
            };

            this.getServer().getPluginManager().registerEvent(event, listener, EventPriority.NORMAL, (l, e) -> function.call(CoerceJavaToLua.coerce(e)), this, false);
        }
        return null;
    }

    public LukkitPluginFile getPluginFile() {
        return this.pluginFile;
    }

    private ArrayList<LuaFunction> getEventListeners(Class<? extends Event> event) {
        this.eventListeners.computeIfAbsent(event, k -> new ArrayList<>());
        return this.eventListeners.get(event);
    }

    // TODO: combine both config methods into one.

    private void loadConfigWithChecks() {
        InputStream internalConfig = this.pluginFile.getDefaultConfig();
        if (!this.pluginConfig.exists() && internalConfig == null) {
            // No need to do anything, there is no config.
            this.config = null;
        } else if (!this.pluginConfig.exists()) {
            // There is no external config so we'll export one from the .lkt
            try {
                Files.createFile(this.pluginConfig.toPath());
                Files.copy(internalConfig, this.pluginConfig.toPath(), StandardCopyOption.REPLACE_EXISTING);
                this.loadConfig();
            } catch (IOException e) {
                this.logger.warning("Unable to export the internal config. We have a problem.");
                e.printStackTrace();
            }
        } else {
            // There is a config externally and one internally. External is fine, just load that.
            this.loadConfig();
        }
    }

    private void loadConfig() {
        File brokenConfig;

        try {
            this.config.load(this.pluginConfig);
            return;
        } catch (InvalidConfigurationException e) {
            brokenConfig = new File(this.dataFolder.getAbsolutePath() + File.separator + "config.broken.yml");
        } catch (IOException e) {
            this.logger.severe("There was an error creating the file to move the broken config to.");
            e.printStackTrace();
            return;
        }

        try {
            Files.copy(this.pluginConfig.toPath(), brokenConfig.toPath());
            Files.copy(this.pluginFile.getDefaultConfig(), this.pluginConfig.toPath());
            this.config.load(this.pluginConfig);
        } catch (IOException e) {
            this.logger.severe("There was an error copying either the broken config to its new file or the default config to the data folder.");
            e.printStackTrace();
            return;
        } catch (InvalidConfigurationException e) {
            this.logger.severe("The internal config is invalid. If you are the plugin maintainer please verify it. If you believe this is a bug submit an issue on GitHub with your configuration.");
            e.printStackTrace();
        }

        this.logger.warning("The config at " + this.pluginConfig.getAbsolutePath() + " was invalid. It has been moved to config.broken.yml and the default config has been exported to config.yml.");
    }

    /**
     * Set up convenience methods on Lua globals
     *
     * @param globals globals to set up properties on
     */
    private void setupPluginGlobals(Globals globals) {
        globals.set("plugin", new PluginWrapper(this));
        globals.set("logger", new LoggerWrapper(this));
        // use a member as its internal threadpool needs to be shutdown upon disabling the plugin
        utilitiesWrapper = new UtilitiesWrapper(this);
        globals.set("util", utilitiesWrapper);
        globals.set("config", new ConfigWrapper(this));

        OneArgFunction oldRequire = (OneArgFunction) globals.get("require");

        globals.set("require", new OneArgFunction() {
            @Override
            public LuaValue call(LuaValue luaValue) {
                String path = luaValue.checkjstring();
                if (!path.endsWith(".lua"))
                    path += ".lua";

                // Replace all but last dot
                path = path.replaceAll("\\.(?=[^.]*\\.)", "/");

                InputStream resource = pluginFile.getResource(path);

                if (resource == null) {
                    return oldRequire.call(luaValue);
                }

                try {
                    return globals.load(new InputStreamReader(resource, "UTF-8"), luaValue.checkjstring()).call();
                } catch (UnsupportedEncodingException e) {
                    e.printStackTrace();
                }
                return NIL;

            }
        });

        globals.set("import", new OneArgFunction() {
            @Override
            public LuaValue call(LuaValue luaValue) {
                try {
                    String path = luaValue.checkjstring();
                    if (path.startsWith("$"))
                        path = "org.bukkit" + path.substring(1);
                    if (path.startsWith("#"))
                        path = "nz.co.jammehcow.lukkit.environment" + path.substring(1);
                    return CoerceJavaToLua.coerce(Class.forName(path));
                } catch (ClassNotFoundException e) {
                    e.printStackTrace();
                }
                return NIL;
            }
        });
        globals.set("newInstance", new VarArgFunction() {
            @Override
            public LuaValue invoke(Varargs vargs) {
                String classPath = vargs.checkjstring(1);
                LuaValue args = vargs.optvalue(2, LuaValue.NIL);

                // Parse classpath shorthands
                if (classPath.startsWith("$"))
                    classPath = "org.bukkit" + classPath.substring(1);
                else if (classPath.startsWith("#"))
                    classPath = "nz.co.jammehcow.lukkit.environment" + classPath.substring(1);

                // Validate the classpath isn't just bullshit
                if (!Utilities.isClassPathValid(classPath)) {
                    LukkitPluginException classPathException = new LukkitPluginException("An invalid classpath \"" +
                            classPath + "\" was provided to the \"newInstance\" method");
                    LuaEnvironment.addError(classPathException);

                    throw classPathException;
                }

                LuaString classPathValue = LuaValue.valueOf(classPath);
                LuaValue newInstanceMethod = globals.get("luajava").get("newInstance");

                switch (args.type()) {
                    case LuaValue.TNIL:
                        return newInstanceMethod.invoke(classPathValue).checkvalue(1);
                    case LuaValue.TTABLE:
                        LuaTable argTable = args.checktable();
                        LuaValue[] varargArray = new LuaValue[argTable.length() + 1];
                        varargArray[0] = classPathValue;

                        for (int iKey = 1; iKey < varargArray.length; iKey++) {
                            varargArray[iKey] = argTable.get(iKey);
                        }

                        return newInstanceMethod.invoke(varargArray).checkvalue(1);
                    default:
                        LukkitPluginException exception = new LukkitPluginException("Second argument of newInstance " +
                                "must be of type table, not " + args.typename());
                        LuaEnvironment.addError(exception);
                        throw exception;
                }
            }
        });
    }

    private Optional<String> checkPluginValidity() {
        if (this.pluginMain == null) {
            return Optional.of("Unable to load the main Lua file. It may be missing from the plugin file or corrupted.");
        } else if (this.descriptor == null) {
            return Optional.of("Unable to load the plugin's description file. It may be missing from the plugin file or corrupted.");
        }

        return Optional.empty();
    }

    public void registerCommand(LukkitCommand command) {
        commands.add(command);
        try {
            command.register();
        } catch (NoSuchFieldException | IllegalAccessException e) {
            e.printStackTrace();
        }
    }

    public void unregisterCommand(LukkitCommand command) {
        commands.remove(command);
        try {
            command.unregister();
        } catch (NoSuchFieldException | IllegalAccessException e) {
            e.printStackTrace();
        }
    }

    public void unregisterAllCommands() {
        // Create new array to get rid of concurrent modification
        List<LukkitCommand> cmds = new ArrayList<>();
        cmds.addAll(commands);
        cmds.forEach(this::unregisterCommand);
    }
}