/*
 * This file is part of BlueMap, licensed under the MIT License (MIT).
 *
 * Copyright (c) Blue (Lukas Rieger) <https://bluecolored.de>
 * Copyright (c) contributors
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */
package de.bluecolored.bluemap.common.plugin;

import de.bluecolored.bluemap.api.debug.DebugDump;
import de.bluecolored.bluemap.common.BlueMapConfiguration;
import de.bluecolored.bluemap.common.BlueMapService;
import de.bluecolored.bluemap.common.InterruptableReentrantLock;
import de.bluecolored.bluemap.common.MissingResourcesException;
import de.bluecolored.bluemap.common.api.BlueMapAPIImpl;
import de.bluecolored.bluemap.common.config.*;
import de.bluecolored.bluemap.common.live.LivePlayersDataSupplier;
import de.bluecolored.bluemap.common.plugin.skins.PlayerSkinUpdater;
import de.bluecolored.bluemap.common.rendermanager.MapUpdateTask;
import de.bluecolored.bluemap.common.rendermanager.RenderManager;
import de.bluecolored.bluemap.common.serverinterface.ServerEventListener;
import de.bluecolored.bluemap.common.serverinterface.Server;
import de.bluecolored.bluemap.common.serverinterface.ServerWorld;
import de.bluecolored.bluemap.common.web.*;
import de.bluecolored.bluemap.common.web.http.HttpServer;
import de.bluecolored.bluemap.core.debug.StateDumper;
import de.bluecolored.bluemap.core.logger.Logger;
import de.bluecolored.bluemap.core.map.BmMap;
import de.bluecolored.bluemap.core.metrics.Metrics;
import de.bluecolored.bluemap.core.resources.resourcepack.ResourcePack;
import de.bluecolored.bluemap.core.storage.Storage;
import de.bluecolored.bluemap.core.util.FileHelper;
import de.bluecolored.bluemap.core.util.Tristate;
import de.bluecolored.bluemap.core.world.World;
import de.bluecolored.bluemap.core.world.mca.MCAWorld;
import org.jetbrains.annotations.Nullable;
import org.spongepowered.configurate.gson.GsonConfigurationLoader;
import org.spongepowered.configurate.serialize.SerializationException;

import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.net.BindException;
import java.net.InetSocketAddress;
import java.net.UnknownHostException;
import java.nio.file.Path;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.function.Predicate;
import java.util.regex.Pattern;

@DebugDump
public class Plugin implements ServerEventListener {

    public static final String PLUGIN_ID = "bluemap";
    public static final String PLUGIN_NAME = "BlueMap";

    private static final String DEBUG_FILE_LOG_NAME = "file-debug-log";

    private final InterruptableReentrantLock loadingLock = new InterruptableReentrantLock();

    private final String implementationType;
    private final Server serverInterface;

    private BlueMapService blueMap;

    private PluginState pluginState;

    private RenderManager renderManager;
    private HttpServer webServer;
    private Logger webLogger;

    private BlueMapAPIImpl api;

    private Timer daemonTimer;

    private Map<String, RegionFileWatchService> regionFileWatchServices;

    private PlayerSkinUpdater skinUpdater;

    private boolean loaded = false;

    public Plugin(String implementationType, Server serverInterface) {
        this.implementationType = implementationType.toLowerCase();
        this.serverInterface = serverInterface;

        StateDumper.global().register(this);
    }

    public void load() throws IOException {
        load(null);
    }

    private void load(@Nullable ResourcePack preloadedResourcePack) throws IOException {
        loadingLock.lock();
        try {
            synchronized (this) {

                if (loaded) return;
                unload(); //ensure nothing is left running (from a failed load or something)

                //load configs
                BlueMapConfigManager configManager = BlueMapConfigManager.builder()
                        .minecraftVersion(serverInterface.getMinecraftVersion())
                        .configRoot(serverInterface.getConfigFolder())
                        .resourcePacksFolder(serverInterface.getConfigFolder().resolve("resourcepacks"))
                        .modsFolder(serverInterface.getModsFolder().orElse(null))
                        .useMetricsConfig(serverInterface.isMetricsEnabled() == Tristate.UNDEFINED)
                        .autoConfigWorlds(serverInterface.getLoadedServerWorlds())
                        .build();
                CoreConfig coreConfig = configManager.getCoreConfig();
                WebserverConfig webserverConfig = configManager.getWebserverConfig();
                WebappConfig webappConfig = configManager.getWebappConfig();
                PluginConfig pluginConfig = configManager.getPluginConfig();

                //apply new file-logger config
                if (coreConfig.getLog().getFile() != null) {
                    ZonedDateTime zdt = ZonedDateTime.ofInstant(Instant.now(), ZoneId.systemDefault());
                    Logger.global.put(DEBUG_FILE_LOG_NAME, () -> Logger.file(
                            Path.of(String.format(coreConfig.getLog().getFile(), zdt)),
                            coreConfig.getLog().isAppend()
                    ));
                } else {
                    Logger.global.remove(DEBUG_FILE_LOG_NAME);
                }

                //load plugin state
                try {
                    GsonConfigurationLoader loader = GsonConfigurationLoader.builder()
                            .path(coreConfig.getData().resolve("pluginState.json"))
                            .build();
                    pluginState = loader.load().get(PluginState.class);
                } catch (SerializationException ex) {
                    Logger.global.logWarning("Failed to load pluginState.json (invalid format), creating a new one...");
                    pluginState = new PluginState();
                }

                //create bluemap-service
                blueMap = new BlueMapService(configManager, preloadedResourcePack);

                //try load resources
                try {
                    blueMap.getOrLoadResourcePack();
                } catch (MissingResourcesException ex) {
                    Logger.global.logWarning("BlueMap is missing important resources!");
                    Logger.global.logWarning("You must accept the required file download in order for BlueMap to work!");

                    BlueMapConfiguration configProvider = blueMap.getConfig();
                    if (configProvider instanceof BlueMapConfigManager) {
                        Logger.global.logWarning("Please check: " + ((BlueMapConfigManager) configProvider).getConfigManager().findConfigPath(Path.of("core")).toAbsolutePath().normalize());
                    }

                    Logger.global.logInfo("If you have changed the config you can simply reload the plugin using: /bluemap reload");

                    unload();
                    return;
                }

                //load maps
                Map<String, BmMap> maps = blueMap.getOrLoadMaps();

                //create and start webserver
                if (webserverConfig.isEnabled()) {
                    Path webroot = webserverConfig.getWebroot();
                    FileHelper.createDirectories(webroot);

                    RoutingRequestHandler routingRequestHandler = new RoutingRequestHandler();

                    // default route
                    routingRequestHandler.register(".*", new FileRequestHandler(webroot));

                    // map route
                    for (var mapConfigEntry : configManager.getMapConfigs().entrySet()) {
                        String id = mapConfigEntry.getKey();
                        MapConfig mapConfig = mapConfigEntry.getValue();

                        MapRequestHandler mapRequestHandler;
                        BmMap map = maps.get(id);
                        if (map != null) {
                            mapRequestHandler = new MapRequestHandler(map, serverInterface, pluginConfig, Predicate.not(pluginState::isPlayerHidden));
                        } else {
                            Storage storage = blueMap.getOrLoadStorage(mapConfig.getStorage());
                            mapRequestHandler = new MapRequestHandler(id, storage);
                        }

                        routingRequestHandler.register(
                                "maps/" + Pattern.quote(id) + "/(.*)",
                                "$1",
                                new BlueMapResponseModifier(mapRequestHandler)
                        );
                    }

                    // create web-logger
                    List<Logger> webLoggerList = new ArrayList<>();
                    if (webserverConfig.getLog().getFile() != null) {
                        ZonedDateTime zdt = ZonedDateTime.ofInstant(Instant.now(), ZoneId.systemDefault());
                        webLoggerList.add(Logger.file(
                                Path.of(String.format(webserverConfig.getLog().getFile(), zdt)),
                                webserverConfig.getLog().isAppend()
                        ));
                    }
                    webLogger = Logger.combine(webLoggerList);

                    try {
                        webServer = new HttpServer(new LoggingRequestHandler(
                                routingRequestHandler,
                                webserverConfig.getLog().getFormat(),
                                webLogger
                        ));
                        webServer.bind(new InetSocketAddress(
                                webserverConfig.resolveIp(),
                                webserverConfig.getPort()
                        ));
                        webServer.start();
                    } catch (UnknownHostException ex) {
                        throw new ConfigurationException("BlueMap failed to resolve the ip in your webserver-config.\n" +
                                "Check if that is correctly configured.", ex);
                    } catch (BindException ex) {
                        throw new ConfigurationException("BlueMap failed to bind to the configured address.\n" +
                                "This usually happens when the configured port (" + webserverConfig.getPort() + ") is already in use by some other program.", ex);
                    } catch (IOException ex) {
                        throw new ConfigurationException("BlueMap failed to initialize the webserver.\n" +
                                "Check your webserver-config if everything is configured correctly.\n" +
                                "(Make sure you DON'T use the same port for bluemap that you also use for your minecraft server)", ex);
                    }
                }

                //warn if no maps are configured
                if (maps.isEmpty()) {
                    Logger.global.logWarning("There are no valid maps configured, please check your map-configs! Disabling BlueMap...");
                    unload(true);
                    return;
                }

                //initialize render manager
                renderManager = new RenderManager();

                //update all maps
                maps.values().stream()
                        .sorted(Comparator.comparing(bmMap -> bmMap.getMapSettings().getSorting()))
                        .forEach(map -> {
                    if (pluginState.getMapState(map).isUpdateEnabled()) {
                        renderManager.scheduleRenderTask(new MapUpdateTask(map));
                    }
                });

                //update webapp and settings
                if (webappConfig.isEnabled())
                    blueMap.createOrUpdateWebApp(false);

                //start skin updater
                this.skinUpdater = new PlayerSkinUpdater(this);
                if (pluginConfig.isLivePlayerMarkers()) {
                    serverInterface.registerListener(skinUpdater);
                }

                //init timer
                daemonTimer = new Timer("BlueMap-Plugin-DaemonTimer", true);

                //periodically save
                TimerTask saveTask = new TimerTask() {
                    @Override
                    public void run() {
                        save();
                    }
                };
                daemonTimer.schedule(saveTask, TimeUnit.MINUTES.toMillis(2), TimeUnit.MINUTES.toMillis(2));

                //periodically save markers
                int writeMarkersInterval = pluginConfig.getWriteMarkersInterval();
                if (writeMarkersInterval > 0) {
                    TimerTask saveMarkersTask = new TimerTask() {
                        @Override
                        public void run() {
                            saveMarkerStates();
                        }
                    };
                    daemonTimer.schedule(saveMarkersTask, TimeUnit.SECONDS.toMillis(writeMarkersInterval), TimeUnit.SECONDS.toMillis(writeMarkersInterval));
                }

                //periodically save players
                int writePlayersInterval = pluginConfig.getWritePlayersInterval();
                if (writePlayersInterval > 0) {
                    TimerTask savePlayersTask = new TimerTask() {
                        @Override
                        public void run() {
                            savePlayerStates();
                        }
                    };
                    daemonTimer.schedule(savePlayersTask, TimeUnit.SECONDS.toMillis(writePlayersInterval), TimeUnit.SECONDS.toMillis(writePlayersInterval));
                }

                //periodically restart the file-watchers
                TimerTask fileWatcherRestartTask = new TimerTask() {
                    @Override
                    public void run() {
                        regionFileWatchServices.values().forEach(RegionFileWatchService::close);
                        regionFileWatchServices.clear();
                        initFileWatcherTasks();
                    }
                };
                daemonTimer.schedule(fileWatcherRestartTask, TimeUnit.HOURS.toMillis(1), TimeUnit.HOURS.toMillis(1));

                //periodically update all (non frozen) maps
                if (pluginConfig.getFullUpdateInterval() > 0) {
                    long fullUpdateTime = TimeUnit.MINUTES.toMillis(pluginConfig.getFullUpdateInterval());
                    TimerTask updateAllMapsTask = new TimerTask() {
                        @Override
                        public void run() {
                            for (BmMap map : maps.values()) {
                                if (pluginState.getMapState(map).isUpdateEnabled()) {
                                    renderManager.scheduleRenderTask(new MapUpdateTask(map));
                                }
                            }
                        }
                    };
                    daemonTimer.scheduleAtFixedRate(updateAllMapsTask, fullUpdateTime, fullUpdateTime);
                }

                //metrics
                TimerTask metricsTask = new TimerTask() {
                    @Override
                    public void run() {
                        if (Plugin.this.serverInterface.isMetricsEnabled().getOr(coreConfig::isMetrics))
                            Metrics.sendReport(Plugin.this.implementationType);
                    }
                };
                daemonTimer.scheduleAtFixedRate(metricsTask, TimeUnit.MINUTES.toMillis(1), TimeUnit.MINUTES.toMillis(30));

                //watch map-changes
                this.regionFileWatchServices = new HashMap<>();
                initFileWatcherTasks();

                //register listener
                serverInterface.registerListener(this);

                //enable api
                this.api = new BlueMapAPIImpl(this);
                this.api.register();

                //save webapp settings again (for api-registered scripts and styles)
                if (webappConfig.isEnabled())
                    this.getBlueMap().getWebFilesManager().saveSettings();

                //start render-manager
                if (pluginState.isRenderThreadsEnabled()) {
                    checkPausedByPlayerCount(); // <- this also starts the render-manager if it should start
                } else {
                    Logger.global.logInfo("Render-Threads are STOPPED! Use the command 'bluemap start' to start them.");
                }

                //done
                loaded = true;
            }
        } catch (ConfigurationException ex) {
            Logger.global.logWarning(ex.getFormattedExplanation());
            throw new IOException(ex);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            Logger.global.logWarning("Loading has been interrupted!");
        } finally {
            loadingLock.unlock();
        }
    }

    public void unload() {
        this.unload(false);
    }
    public void unload(boolean keepWebserver) {
        loadingLock.interruptAndLock();
        try {
            synchronized (this) {
                //save
                save();

                //disable api
                if (api != null) api.unregister();
                api = null;

                //unregister listeners
                serverInterface.unregisterAllListeners();
                skinUpdater = null;

                //stop scheduled threads
                if (daemonTimer != null) daemonTimer.cancel();
                daemonTimer = null;

                //stop file-watchers
                if (regionFileWatchServices != null) {
                    regionFileWatchServices.values().forEach(RegionFileWatchService::close);
                    regionFileWatchServices.clear();
                }
                regionFileWatchServices = null;

                //stop services
                if (renderManager != null){
                    renderManager.stop();
                    try {
                        renderManager.awaitShutdown();
                    } catch (InterruptedException ex) {
                        Thread.currentThread().interrupt();
                    }
                }
                renderManager = null;

                if (webServer != null && !keepWebserver) {
                    try {
                        webServer.close();
                    } catch (IOException ex) {
                        Logger.global.logError("Failed to close the webserver!", ex);
                    }
                    webServer = null;
                }

                if (webLogger != null) {
                    try {
                        webLogger.close();
                    } catch (Exception ex) {
                        Logger.global.logError("Failed to close the webserver-logger!", ex);
                    }
                    webLogger = null;
                }

                //close bluemap
                if (blueMap != null) {
                    try {
                        blueMap.close();
                    } catch (IOException ex) {
                        Logger.global.logError("Failed to close a bluemap-service!", ex);
                    }
                }
                blueMap = null;

                // remove file-logger
                Logger.global.remove(DEBUG_FILE_LOG_NAME);

                //clear resources
                pluginState = null;

                //done
                loaded = false;
            }
        } finally {
            loadingLock.unlock();
        }
    }

    public void reload() throws IOException {
        unload();
        load();
    }

    /**
     * {@link #reload()} but without reloading the resourcepack (if it is loaded).
     */
    public void lightReload() throws IOException {
        loadingLock.lock();
        try {
            synchronized (this) {

                if (!loaded) {
                    reload(); // reload normally
                    return;
                }

                // hold and reuse loaded resourcepack
                ResourcePack preloadedResourcePack = this.blueMap.getResourcePack();

                unload();
                load(preloadedResourcePack);

            }
        } finally {
            loadingLock.unlock();
        }
    }

    public synchronized void save() {
        if (blueMap == null) return;

        if (pluginState != null) {
            try {
                GsonConfigurationLoader loader = GsonConfigurationLoader.builder()
                        .path(blueMap.getConfig().getCoreConfig().getData().resolve("pluginState.json"))
                        .build();
                loader.save(loader.createNode().set(PluginState.class, pluginState));
            } catch (IOException ex) {
                Logger.global.logError("Failed to save pluginState.json!", ex);
            }
        }

        var maps = blueMap.getMaps();
        for (BmMap map : maps.values()) {
            map.save();
        }
    }

    public void saveMarkerStates() {
        if (blueMap == null) return;

        var maps = blueMap.getMaps();
        for (BmMap map : maps.values()) {
            map.saveMarkerState();
        }
    }

    public void savePlayerStates() {
        if (blueMap == null) return;

        var maps = blueMap.getMaps();
        for (BmMap map : maps.values()) {
            var serverWorld = serverInterface.getServerWorld(map.getWorld()).orElse(null);
            if (serverWorld == null) continue;
            var dataSupplier = new LivePlayersDataSupplier(
                    serverInterface,
                    getBlueMap().getConfig().getPluginConfig(),
                    serverWorld,
                    Predicate.not(pluginState::isPlayerHidden)
            );
            try (
                    OutputStream out = map.getStorage().writeMeta(map.getId(), BmMap.META_FILE_PLAYERS);
                    Writer writer = new OutputStreamWriter(out)
            ) {
                writer.write(dataSupplier.get());
            } catch (Exception ex) {
                Logger.global.logError("Failed to save players for map '" + map.getId() + "'!", ex);
            }
        }
    }

    public synchronized void startWatchingMap(BmMap map) {
        stopWatchingMap(map);

        try {
            RegionFileWatchService watcher = new RegionFileWatchService(renderManager, map);
            watcher.start();
            regionFileWatchServices.put(map.getId(), watcher);
        } catch (IOException ex) {
            Logger.global.logError("Failed to create file-watcher for map: " + map.getId() + " (This means the map might not automatically update)", ex);
        }
    }

    public synchronized void stopWatchingMap(BmMap map) {
        RegionFileWatchService watcher = regionFileWatchServices.remove(map.getId());
        if (watcher != null) {
            watcher.close();
        }
    }

    public boolean flushWorldUpdates(World world) throws IOException {
        var implWorld = serverInterface.getServerWorld(world).orElse(null);
        if (implWorld != null) return implWorld.persistWorldChanges();
        return false;
    }

    @Override
    public void onPlayerJoin(UUID playerUuid) {
        checkPausedByPlayerCountSoon();
    }

    @Override
    public void onPlayerLeave(UUID playerUuid) {
        checkPausedByPlayerCountSoon();
    }

    private void checkPausedByPlayerCountSoon() {
        // check is done a second later to make sure the player has actually joined/left and is no longer on the list
        try {
            daemonTimer.schedule(new TimerTask() {
                @Override
                public void run() {
                    checkPausedByPlayerCount();
                }
            }, 1000);
        } catch (IllegalStateException ex) { // Timer is cancelled for some reason
            Logger.global.logWarning("Timer is already cancelled, skipping player-limit checks!");
        }
    }

    public boolean checkPausedByPlayerCount() {
        CoreConfig coreConfig = getBlueMap().getConfig().getCoreConfig();
        PluginConfig pluginConfig = getBlueMap().getConfig().getPluginConfig();

        if (
                pluginConfig.getPlayerRenderLimit() > 0 &&
                getServerInterface().getOnlinePlayers().size() >= pluginConfig.getPlayerRenderLimit()
        ) {
            if (renderManager.isRunning()) renderManager.stop();
            return true;
        } else {
            if (!renderManager.isRunning() && getPluginState().isRenderThreadsEnabled())
                renderManager.start(coreConfig.resolveRenderThreadCount());
            return false;
        }
    }

    public @Nullable World getWorld(ServerWorld serverWorld) {
        String id = MCAWorld.id(serverWorld.getWorldFolder(), serverWorld.getDimension());
        return getBlueMap().getWorlds().get(id);
    }

    public Server getServerInterface() {
        return serverInterface;
    }

    public BlueMapService getBlueMap() {
        return blueMap;
    }

    public PluginState getPluginState() {
        return pluginState;
    }

    public RenderManager getRenderManager() {
        return renderManager;
    }

    public HttpServer getWebServer() {
        return webServer;
    }

    public boolean isLoaded() {
        return loaded;
    }

    public String getImplementationType() {
        return implementationType;
    }

    public PlayerSkinUpdater getSkinUpdater() {
        return skinUpdater;
    }

    private void initFileWatcherTasks() {
        var maps = blueMap.getMaps();
        if (maps != null) {
            for (BmMap map : maps.values()) {
                if (pluginState.getMapState(map).isUpdateEnabled()) {
                    startWatchingMap(map);
                }
            }
        }
    }

}
