/**
 * Copyright © CENRA (2016)
 *
 * remi.clement@espaces-naturels.fr
 * 
 * This software is a multi-platform and portable application based on 
 * the scientifical toolbox to monitor wetlands. With this toolbox it’s 
 * possible to calculate indicators «RhoMéO» to monitor quality, 
 * fonctionalities and urban and agricultural pressures of wetlands. 
 * Indicators are calculated using naturalist data entered and/or imported,
 * and geographic data downloaded from a geographic server.
 * 
 * This software is governed by the CeCILL license under French law and
 * abiding by the rules of distribution of free software.  You can  use, 
 * modify and/ or redistribute the software under the terms of the CeCILL
 * license as circulated by CEA, CNRS and INRIA at the following URL
 * "http://www.cecill.info". 
 * 
 * As a counterpart to the access to the source code and  rights to copy,
 * modify and redistribute granted by the license, users are provided only
 * with a limited warranty  and the software's author,  the holder of the
 * economic rights,  and the successive licensors  have only  limited
 * liability. 
 * 
 * In this respect, the user's attention is drawn to the risks associated
 * with loading,  using,  modifying and/or developing or reproducing the
 * software by the user in light of its specific status of free software,
 * that may mean  that it is complicated to manipulate,  and  that  also
 * therefore means  that it is reserved for developers  and  experienced
 * professionals having in-depth computer knowledge. Users are therefore
 * encouraged to load and test the software's suitability as regards their
 * requirements in conditions enabling the security of their systems and/or 
 * data to be ensured and,  more generally, to use and operate it in the 
 * same conditions as regards security. 
 * 
 * The fact that you are presently reading this means that you have had
 * knowledge of the CeCILL license and that you accept its terms.
*/
package fr.cenra.rhomeo.core.data;

import fr.cenra.rhomeo.api.Version;
import fr.cenra.rhomeo.api.data.Reference;
import fr.cenra.rhomeo.api.data.ReferenceDescription;
import fr.cenra.rhomeo.core.CSVDecoder;
import fr.cenra.rhomeo.core.CSVMapper;
import fr.cenra.rhomeo.core.RhomeoCore;
import fr.cenra.rhomeo.core.RhomeoRuntimeException;
import fr.cenra.rhomeo.core.Session;
import java.beans.BeanInfo;
import java.beans.IntrospectionException;
import java.beans.Introspector;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.lang.ref.WeakReference;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.function.Supplier;
import java.util.logging.Level;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javafx.collections.FXCollections;
import javafx.collections.ObservableMap;
import javafx.concurrent.Task;
import javax.validation.constraints.NotNull;
import org.apache.commons.net.ftp.FTPClient;
import org.apache.commons.net.ftp.FTPFile;
import org.apache.sis.util.ArgumentChecks;
import org.apache.sis.util.collection.Cache;

/**
 * Contains the list of available and installed version of a specific reference
 * type. Also allow to get data contained in each installed version.
 *
 * @author Alexis Manin (Geomatys)
 * @param <T> Type of reference managed.
 */
public class ReferenceManager<T extends Reference> {

    /**
     * A regex to detect CSV files containing a version number composed of dot separated digits .
     */
    private static final Pattern REF_FILE_PATTERN = Pattern.compile("(?i)(\\d+(?:\\.\\d+)*)(.csv)?$");

    /**
     * We register created managers here.
     */
    private static final Cache<String, ReferenceManager> INSTANCES = new Cache<>(13, 13, true);

    /**
     *
     * @param <R> The type of reference to get a manager for.
     * @param descriptor A descriptor for the reference list to check.
     * @return The manager corresponding to the given reference description.
     * @throws java.beans.IntrospectionException If we cannot analyze given reference type.
     */
    public static <R extends Reference> ReferenceManager<R> getOrCreate(final ReferenceDescription<R> descriptor) throws IntrospectionException {
        try {
            return INSTANCES.getOrCreate(descriptor.getName(), () -> new ReferenceManager<>(descriptor));
        } catch (IntrospectionException | RuntimeException ex) {
            throw ex;
        } catch (Exception e) {
            throw new RhomeoRuntimeException(e);
        }
    }

    /**
     * Target (managed) reference type.
     */
    private final ReferenceDescription<T> refType;
    /**
     * Information about reference pojo (properties, methods, etc.).
     */
    private final BeanInfo refInfo;

    /**
     * Directory where installed versions should be located.
     */
    private final Path refDir;

    /**
     * A map containing installed versions we found on {@link #refresh() }.
     */
    private final ObservableMap<Version, Path> installed;
    /**
     * A map containing versions found in FTP server on {@link #refresh() }.
     */
    private final ObservableMap<Version, FTPFile> distant;

    private final Cache<Version, List<T>> loadedVersions = new Cache(2, 0, false);

    private WeakReference<List<T>> lastLoaded;

    private final Set<Version> operations;

    private Task refreshTask;

    private ReferenceManager(@NotNull ReferenceDescription<T> refType) throws IntrospectionException {
        this.refType = refType;
        final String refName = refType.getName();
        ArgumentChecks.ensureNonEmpty("Reference type name", refName);

        refInfo = Introspector.getBeanInfo(refType.getReferenceType(), Object.class);
        refDir = RhomeoCore.REFERENCE_PATH.resolve(refName);

        installed = FXCollections.observableMap(new HashMap<>());
        distant = FXCollections.observableMap(new HashMap<>());

        operations = new HashSet<>();
    }

    /**
     * Force this manager to rebuild its data from scratch. The local files and
     * FTP service will be scanned anew to find available and installed reference
     * lists.
     *
     * Note : local data is checked first, so if the distant service does not
     * provide correct answers and the task fails, we can still access installed
     * versions.
     * @return A task which refresh data. If a refresh is ready to run or
     * already running, the corresponding task is returned. Otherwise, a new
     * task is returned which must be launched by the caller.
     */
    public synchronized Task<ReferenceManager> refresh() {
        if (refreshTask == null || refreshTask.isDone()) {
            refreshTask = new Task() {

                @Override
                protected Object call() throws Exception {
                    updateTitle("Recherche de versions : ".concat(refType.getTitle()));

                    updateMessage("Vérification des versions installées");
                    installed.clear();
                    Files.createDirectories(refDir);

                    Files.walkFileTree(refDir, new SimpleFileVisitor<Path>() {

                        @Override
                        public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
                            final Matcher matcher = REF_FILE_PATTERN.matcher(file.getFileName().toString());
                            if (matcher.find()) {
                                if (isValid(file)) {
                                    installed.put(new Version(matcher.group(1)), file);
                                }
                            }
                            return super.visitFile(file, attrs);
                        }
                    });

                    updateMessage("Vérification des versions disponibles sur le FTP");
                    distant.clear();
                    final FTPClient ftp = Session.getInstance().connectReferenceFTP();
                    try {
                        if (ftp.changeWorkingDirectory(refType.getName())) {
                            ftp.listFiles(".", file -> {
                                if (file.isFile() && file.getSize() > 0) {
                                    final Matcher matcher = REF_FILE_PATTERN.matcher(file.getName());
                                    if (matcher.find()) {
                                        try (final InputStream retStream = ftp.retrieveFileStream(file.getName())) {
                                            if (isValid(retStream)) {
                                                distant.put(new Version(matcher.group(1)), file);
                                                return true;
                                            }
                                        } catch (IOException e) {
                                            RhomeoCore.LOGGER.log(Level.WARNING, "An error occurred while reading an FTP file : ".concat(file.getName()), e);
                                        } finally {
                                            try {
                                                // Ensures pending command are finished
                                                if (!ftp.completePendingCommand()) {
                                                    final int replyCode = ftp.getReplyCode();
                                                    final String replyMessage = ftp.getReplyString();
                                                    final Supplier<String> msgSupplier = () -> {
                                                        final StringBuilder builder = new StringBuilder("Error on pending command completion.");
                                                        builder.append(System.lineSeparator()).append("File : ").append(file.getName());
                                                        builder.append(System.lineSeparator()).append("Reply code : ").append(replyCode);
                                                        builder.append(System.lineSeparator()).append("Reply message : ").append(replyMessage);
                                                        return builder.toString();
                                                    };
                                                    RhomeoCore.LOGGER.log(Level.INFO, msgSupplier);
                                                }
                                            } catch (IOException ex) {
                                                RhomeoCore.LOGGER.log(Level.WARNING, "An error occurred while trying to complete pending command for FTP file : ".concat(file.getName()), ex);
                                            }
                                        }
                                    }
                                }
                                return false;
                            });
                        }
                    } finally {
                        try {
                            ftp.disconnect();
                        } catch (IOException e) {
                            RhomeoCore.LOGGER.log(Level.WARNING, "An FTP client cannot be disconnected : References.", e);
                        }
                    }

                    return this;
                }
            };
        }

        return refreshTask;
    }

    /**
     *
     * @return Set of versions available on FTP server. Unmodifiable. The Set is
     * automatically updated when the manager is refreshed.
     */
    public Set<Version> getDistantVersions() {
        return distant.keySet();
    }

    /**
     *
     * @return Set of versions installed locally. Unmodifiable. The Set is
     * automatically updated when the manager is refreshed.
     */
    public Set<Version> getInstalledVersions() {
        return installed.keySet();
    }

    /**
     * Create a task in charge of the installation of a new reference version.
     * @param toInstall Version of the reference list to install.
     * @return A task (not submitted yet) to run to install given version.
     * @throws IllegalStateException If an installation or uninstallation procedure
     * is already running for the version.
     */
    public Task install(@NotNull final Version toInstall) throws IllegalStateException {
        synchronized (operations) {
            if (operations.contains(toInstall)) {
                throw new IllegalStateException(
                        new StringBuilder("An operation is already running for version ")
                        .append(toInstall.toString())
                        .append(" of list ")
                        .append(refType.getName())
                        .toString()
                );
            }
        }

        return new Task() {

            @Override
            protected Object call() throws Exception {
                final boolean alreadyRunning;
                synchronized (operations) {
                    alreadyRunning = !operations.add(toInstall);
                }
                try {
                    if (alreadyRunning) {
                        updateMessage(
                                new StringBuilder("An operation is already running for version ")
                                .append(toInstall.toString())
                                .append(" of list ")
                                .append(refType.getName())
                                .toString()
                        );
                        cancel();

                    } else if (installed.containsKey(toInstall)) {
                        updateMessage("Queried version is already installed : ".concat(toInstall.toString()));
                        cancel();

                    } else {
                        updateTitle("Téléchargement ".concat(toInstall.toString()));
                        FTPFile ftpFile = distant.get(toInstall);
                        if (distant == null) {
                            throw new IllegalArgumentException("Queried version is not available : ".concat(toInstall.toString()));
                        }

                        final Path newRef = refDir.resolve(toInstall.toString().concat(".csv"));
                        final FTPClient ftp = Session.getInstance().connectReferenceFTP();
                        ftp.changeWorkingDirectory(refType.getName());
                        try (final OutputStream out = Files.newOutputStream(newRef)) {
                            ftp.retrieveFile(ftpFile.getName(), out);
                        } finally {
                            try {
                                ftp.disconnect();
                            } catch (IOException e) {
                                RhomeoCore.LOGGER.log(Level.WARNING, "An FTP client cannot be disconnected : References.", e);
                            }
                        }

                        installed.put(toInstall, newRef);
                        return newRef;
                    }
                } finally {
                    synchronized (operations) {
                        if (!alreadyRunning) {
                            operations.remove(toInstall);
                        }
                    }
                }

                return false;
            }
        };
    }

    /**
     * Create a task in charge of removing a local reference version.
     * @param toUninstall Version of the reference list to uninstall.
     * @return A task (not submitted yet) to run to uninstall given version.
     * @throws IllegalStateException If an installation or uninstallation procedure
     * is already running for the version.
     */
    public Task uninstall(@NotNull final Version toUninstall) throws IllegalStateException {
        synchronized (operations) {
            if (operations.contains(toUninstall)) {
                throw new IllegalStateException(
                        new StringBuilder("An operation is already running for version ")
                        .append(toUninstall.toString())
                        .append(" of list ")
                        .append(refType.getName())
                        .toString()
                );
            }
        }

        return new Task() {

            @Override
            protected Object call() throws Exception {
                final boolean alreadyRunning;
                synchronized (operations) {
                    alreadyRunning = !operations.add(toUninstall);
                }
                try {
                    if (alreadyRunning) {
                        updateMessage(
                                new StringBuilder("An operation is already running for version ")
                                .append(toUninstall.toString())
                                .append(" of list ")
                                .append(refType.getName())
                                .toString()
                        );
                        cancel();

                    } else if (!installed.containsKey(toUninstall)) {
                        updateMessage("Queried version is already uninstalled : ".concat(toUninstall.toString()));
                        cancel();

                    } else {
                        updateTitle("Suppression ".concat(toUninstall.toString()));
                        final Path toDelete = installed.get(toUninstall);
                        Files.delete(toDelete);
                        return installed.remove(toUninstall);
                    }
                } finally {
                    synchronized (operations) {
                        if (!alreadyRunning) {
                            operations.remove(toUninstall);
                        }
                    }
                }

                return false;
            }
        };
    }

    /**
     * Check if given file header lists all properties defined in reference
     * type.
     *
     * @param file The file to check.
     * @return True if the file header describes the current reference type,
     * false otherwise.
     * @throws IOException If we cannot read the file.
     * @throws IntrospectionException If we cannot extract property description
     * from current reference type.
     */
    private boolean isValid(@NotNull final Path file) throws IOException {
        final String line;
        try (final BufferedReader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
            line = reader.readLine();
        }

        return isValidHeader(line);
    }

    /**
     * Check if given file header lists all properties defined in reference
     * type.
     *
     * @param file The file to check.
     * @return True if the file header describes the current reference type,
     * false otherwise.
     * @throws IOException If we cannot read the file.
     * @throws IntrospectionException If we cannot extract property description
     * from current reference type.
     */
    private boolean isValid(@NotNull final InputStream file) throws IOException {
        final String line;
        try (final InputStreamReader in = new InputStreamReader(file, StandardCharsets.UTF_8);
                final BufferedReader reader = new BufferedReader(in)) {
            line = reader.readLine();
        }

        return isValidHeader(line);
    }

    /**
     * Check if given header lists all properties defined in reference type.
     *
     * @param firstLine The header to check.
     * @return True if the file header describes the current reference type,
     * false otherwise.
     */
    private boolean isValidHeader(@NotNull final String firstLine) {
        return CSVMapper.isValidHeader(firstLine, CSVMapper.DEFAULT_SEPARATOR, refInfo);
    }

    /**
     * Try to load reference values for the given version. Before calling this
     * method, you MUST have called
     * {@link #refresh() } at least once, and checked the queried version is
     * already installed.
     * @param toLoad The version of the list to load.
     * @return A read-only list containing all values for the queried references.
     * @throws IllegalArgumentException If the queried version is not installed,
     * or the manager has not been refreshed yet.
     * @throws RhomeoRuntimeException If an error occurs while reading list file.
     */
    public synchronized List<T> getValues(final Version toLoad) throws IllegalArgumentException, RhomeoRuntimeException {
        try {
            final List<T> result =  loadedVersions.getOrCreate(toLoad, () -> {
                final Path p = installed.get(toLoad);
                if (p == null) {
                    throw new IllegalArgumentException("Queried version is not installed, or the manager has not been refreshed yet !");
                }

                return Collections.unmodifiableList(
                        new CSVDecoder<>(p, refType.getReferenceType(), StandardCharsets.UTF_8).decode());
            });
            lastLoaded = new WeakReference(result);
            return result;
        } catch (Exception ex) {
            throw new RhomeoRuntimeException(ex);
        }
    }

    /**
     *
     * @return The list of references which have been loaded the last time {@link #getValues(fr.cenra.rhomeo.api.Version) }
     * has been called, if any.
     */
    public synchronized Optional<List<T>> getLastLoaded() {
        if (lastLoaded == null)
            return Optional.empty();
        else return Optional.ofNullable(lastLoaded.get());
    }
}
