/*
 * Copyright (C) 2012-present the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.pf4j.update;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import org.pf4j.PluginManager;
import org.pf4j.PluginRuntimeException;
import org.pf4j.PluginState;
import org.pf4j.PluginWrapper;
import org.pf4j.VersionManager;
import org.pf4j.update.PluginInfo.PluginRelease;
import org.pf4j.update.verifier.CompoundVerifier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.FileReader;
import java.io.IOException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;

/**
 * @author Decebal Suiu
 */
public class UpdateManager {

    private static final Logger log = LoggerFactory.getLogger(UpdateManager.class);

    private PluginManager pluginManager;
    private VersionManager versionManager;
    private String systemVersion;
    private Path repositoriesJson;

    // cache last plugin release per plugin id (the key)
    private Map<String, PluginRelease> lastPluginRelease = new HashMap<>();

    protected List<UpdateRepository> repositories;

    public UpdateManager(PluginManager pluginManager) {
        this.pluginManager = pluginManager;

        versionManager = pluginManager.getVersionManager();
        systemVersion = pluginManager.getSystemVersion();
        repositoriesJson = Paths.get("repositories.json");
    }

    public UpdateManager(PluginManager pluginManager, Path repositoriesJson) {
        this(pluginManager);

        this.repositoriesJson = repositoriesJson;
    }

    public UpdateManager(PluginManager pluginManager, List<UpdateRepository> repos) {
        this(pluginManager);

        repositories = repos == null ? new ArrayList<>() : repos;
    }

    public List<PluginInfo> getAvailablePlugins() {
        List<PluginInfo> availablePlugins = new ArrayList<>();
        for (PluginInfo plugin : getPlugins()) {
            if (pluginManager.getPlugin(plugin.id) == null) {
                availablePlugins.add(plugin);
            }
        }

        return availablePlugins;
    }

    public boolean hasAvailablePlugins() {
        for (PluginInfo plugin : getPlugins()) {
            if (pluginManager.getPlugin(plugin.id) == null) {
                return true;
            }
        }

        return false;
    }

    /**
     * Return a list of plugins that are newer versions of already installed plugins.
     *
     * @return list of plugins that have updates
     */
    public List<PluginInfo> getUpdates() {
        List<PluginInfo> updates = new ArrayList<>();
        for (PluginWrapper installed : pluginManager.getPlugins()) {
            String pluginId = installed.getPluginId();
            if (hasPluginUpdate(pluginId)) {
                updates.add(getPluginsMap().get(pluginId));
            }
        }

        return updates;
    }

    /**
     * Checks if Update Repositories has newer versions of some of the installed plugins.
     *
     * @return true if updates exist
     */
    public boolean hasUpdates() {
        return getUpdates().size() > 0;
    }

    /**
     * Get the list of plugins from all repos.
     *
     * @return List of plugin info
     */
    public List<PluginInfo> getPlugins() {
        List<PluginInfo> list = new ArrayList<>(getPluginsMap().values());
        Collections.sort(list);

        return list;
    }

    /**
     * Get a map of all plugins from all repos where key is plugin id.
     *
     * @return List of plugin info
     */
    public Map<String, PluginInfo> getPluginsMap() {
        Map<String, PluginInfo> pluginsMap = new HashMap<>();
        for (UpdateRepository repository : getRepositories()) {
            pluginsMap.putAll(repository.getPlugins());
        }

        return pluginsMap;
    }

    public List<UpdateRepository> getRepositories() {
        if (repositories == null && repositoriesJson != null) {
            refresh();
        }

        return repositories;
    }

    /**
     * Replace all repositories.
     *
     * @param repositories list of new repositories
     */
    public void setRepositories(List<UpdateRepository> repositories) {
        this.repositories = repositories;
        refresh();
    }

    /**
     * Add one {@link DefaultUpdateRepository}.
     *
     * @param id of repo
     * @param url of repo
     */
    public void addRepository(String id, URL url) {
        for (UpdateRepository ur : repositories) {
            if (ur.getId().equals(id)) {
                throw new RuntimeException("Repository with id " + id + " already exists");
            }
        }
        repositories.add(new DefaultUpdateRepository(id, url));
    }

    /**
     * Add a repo that was created by client.
     *
     * @param newRepo the new UpdateRepository to add to the list
     */
    public void addRepository(UpdateRepository newRepo) {
        for (UpdateRepository ur : repositories) {
            if (ur.getId().equals(newRepo.getId())) {
                throw new RuntimeException("Repository with id " + newRepo.getId() + " already exists");
            }
        }
        newRepo.refresh();
        repositories.add(newRepo);
    }

    /**
     * Remove a repository by id.
     *
     * @param id of repository to remove
     */
    public void removeRepository(String id) {
      for (UpdateRepository repo : getRepositories()) {
        if (id.equals(repo.getId())) {
          repositories.remove(repo);
          break;
        }
      }
      log.warn("Repository with id " + id + " not found, doing nothing");
    }

    /**
     * Refreshes all repositories, so they are forced to refresh list of plugins.
     */
    public synchronized void refresh() {
        if (repositoriesJson != null && Files.exists(repositoriesJson)) {
            initRepositoriesFromJson();
        }
        for (UpdateRepository updateRepository : repositories) {
            updateRepository.refresh();
        }
        lastPluginRelease.clear();
    }

    /**
     * Installs a plugin by id and version.
     *
     * @param id the id of plugin to install
     * @param version the version of plugin to install, on SemVer format, or null for latest
     * @return true if installation successful and plugin started
     * @exception PluginRuntimeException if plugin does not exist in repos or problems during
     */
    public synchronized boolean installPlugin(String id, String version) {
        // Download to temporary location
        Path downloaded = downloadPlugin(id, version);

        Path pluginsRoot = pluginManager.getPluginsRoot();
        Path file = pluginsRoot.resolve(downloaded.getFileName());
        try {
            Files.move(downloaded, file, REPLACE_EXISTING);
        } catch (IOException e) {
            throw new PluginRuntimeException(e, "Failed to write file '{}' to plugins folder", file);
        }

        String pluginId = pluginManager.loadPlugin(file);
        PluginState state = pluginManager.startPlugin(pluginId);

        return PluginState.STARTED.equals(state);
    }

    /**
     * Downloads a plugin with given coordinates, runs all {@link FileVerifier}s
     * and returns a path to the downloaded file.
     *
     * @param id of plugin
     * @param version of plugin or null to download latest
     * @return Path to file which will reside in a temporary folder in the system default temp area
     * @throws PluginRuntimeException if download failed
     */
    protected Path downloadPlugin(String id, String version) {
        try {
            PluginRelease release = findReleaseForPlugin(id, version);
            Path downloaded = getFileDownloader(id).downloadFile(new URL(release.url));
            getFileVerifier(id).verify(new FileVerifier.Context(id, release), downloaded);
            return downloaded;
        } catch (IOException e) {
            throw new PluginRuntimeException(e, "Error during download of plugin {}", id);
        }
    }

    /**
     * Finds the {@link FileDownloader} to use for this repository.
     *
     * @param pluginId the plugin we wish to download
     * @return FileDownloader instance
     */
    protected FileDownloader getFileDownloader(String pluginId) {
        for (UpdateRepository ur : repositories) {
            if (ur.getPlugin(pluginId) != null && ur.getFileDownloader() != null) {
                return ur.getFileDownloader();
            }
        }

        return new SimpleFileDownloader();
    }

    /**
     * Gets a file verifier to use for this plugin. First tries to use custom verifier
     * configured for the repository, then fallback to the default {@link CompoundVerifier}
     *
     * @param pluginId the plugin we wish to download
     * @return FileVerifier instance
     */
    protected FileVerifier getFileVerifier(String pluginId) {
        for (UpdateRepository ur : repositories) {
            if (ur.getPlugin(pluginId) != null && ur.getFileVerifier() != null) {
                return ur.getFileVerifier();
            }
        }

        return new CompoundVerifier();
    }

    /**
     * Resolves Release from id and version.
     *
     * @param id of plugin
     * @param version of plugin or null to locate latest version
     * @return PluginRelease for downloading
     * @throws PluginRuntimeException if id or version does not exist
     */
    protected PluginRelease findReleaseForPlugin(String id, String version) {
        PluginInfo pluginInfo = getPluginsMap().get(id);
        if (pluginInfo == null) {
            log.info("Plugin with id {} does not exist in any repository", id);
            throw new PluginRuntimeException("Plugin with id {} not found in any repository", id);
        }

        if (version == null) {
            return getLastPluginRelease(id);
        }

        for (PluginRelease release : pluginInfo.releases) {
            if (versionManager.compareVersions(version, release.version) == 0 && release.url != null) {
                return release;
            }
        }

        throw new PluginRuntimeException("Plugin {} with version @{} does not exist in the repository", id, version);
    }

    /**
     * Updates a plugin id to given version or to latest version if {@code version == null}.
     *
     * @param id the id of plugin to update
     * @param version the version to update to, on SemVer format, or null for latest
     * @return true if update successful
     * @exception PluginRuntimeException in case the given version is not available, plugin id not already installed etc
    */
    public boolean updatePlugin(String id, String version) {
        if (pluginManager.getPlugin(id) == null) {
            throw new PluginRuntimeException("Plugin {} cannot be updated since it is not installed", id);
        }

        PluginInfo pluginInfo = getPluginsMap().get(id);
        if (pluginInfo == null) {
            throw new PluginRuntimeException("Plugin {} does not exist in any repository", id);
        }

        if (!hasPluginUpdate(id)) {
            log.warn("Plugin {} does not have an update available which is compatible with system version {}", id, systemVersion);
            return false;
        }

        // Download to temp folder
        Path downloaded = downloadPlugin(id, version);

        if (!pluginManager.deletePlugin(id)) {
            return false;
        }

        Path pluginsRoot = pluginManager.getPluginsRoot();
        Path file = pluginsRoot.resolve(downloaded.getFileName());
        try {
            Files.move(downloaded, file, REPLACE_EXISTING);
        } catch (IOException e) {
            throw new PluginRuntimeException("Failed to write plugin file {} to plugin folder", file);
        }

        String newPluginId = pluginManager.loadPlugin(file);
        PluginState state = pluginManager.startPlugin(newPluginId);

        return PluginState.STARTED.equals(state);
    }

    public boolean uninstallPlugin(String id) {
        return pluginManager.deletePlugin(id);
    }

    /**
     * Returns the last release version of this plugin for given system version, regardless of release date.
     *
     * @return PluginRelease which has the highest version number
     */
    public PluginRelease getLastPluginRelease(String id) {
        PluginInfo pluginInfo = getPluginsMap().get(id);
        if (pluginInfo == null) {
            return null;
        }

        if (!lastPluginRelease.containsKey(id)) {
            for (PluginRelease release : pluginInfo.releases) {
                if (systemVersion.equals("0.0.0") || versionManager.checkVersionConstraint(systemVersion, release.requires)) {
                    if (lastPluginRelease.get(id) == null) {
                        lastPluginRelease.put(id, release);
                    } else if (versionManager.compareVersions(release.version, lastPluginRelease.get(id).version) > 0) {
                        lastPluginRelease.put(id, release);
                    }
                }
            }
        }

        return lastPluginRelease.get(id);
    }

    /**
     * Finds whether the newer version of the plugin.
     *
     * @return true if there is a newer version available which is compatible with system
     */
    public boolean hasPluginUpdate(String id) {
        PluginInfo pluginInfo = getPluginsMap().get(id);
        if (pluginInfo == null) {
            return false;
        }

        String installedVersion = pluginManager.getPlugin(id).getDescriptor().getVersion();
        PluginRelease last = getLastPluginRelease(id);

        return last != null && versionManager.compareVersions(last.version, installedVersion) > 0;
    }

    protected synchronized void initRepositoriesFromJson() {
        log.debug("Read repositories from '{}'", repositoriesJson);
        try (FileReader reader = new FileReader(repositoriesJson.toFile())) {
            Gson gson = new GsonBuilder().create();
            UpdateRepository[] items = gson.fromJson(reader, DefaultUpdateRepository[].class);
            repositories = Arrays.asList(items);
        } catch (IOException e) {
            e.printStackTrace();
            repositories = Collections.emptyList();
        }
    }

}
