/**
 * 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 com.fasterxml.jackson.core.JsonEncoding;
import fr.cenra.rhomeo.api.data.Protocol;
import fr.cenra.rhomeo.api.data.Site;
import fr.cenra.rhomeo.api.process.Indicator;
import fr.cenra.rhomeo.api.result.DashboardResultItem;
import fr.cenra.rhomeo.api.result.Index;
import fr.cenra.rhomeo.api.result.IndicatorValuesByYearItem;
import fr.cenra.rhomeo.core.CSVDecoder;
import fr.cenra.rhomeo.core.CSVEncoder;
import fr.cenra.rhomeo.core.RhomeoCore;
import fr.cenra.rhomeo.core.RhomeoRuntimeException;
import fr.cenra.rhomeo.core.Session;
import fr.cenra.rhomeo.core.result.ResultStorage;
import fr.cenra.rhomeo.core.util.ExportUtils;
import fr.cenra.rhomeo.core.util.GeometryUtils;
import fr.cenra.rhomeo.core.data.site.SiteRepositoryImpl;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import org.apache.commons.net.ftp.FTPClient;
import org.apache.sis.storage.DataStoreException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.beans.IntrospectionException;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.security.GeneralSecurityException;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.StringJoiner;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Level;
import java.util.stream.Collectors;
import org.geotoolkit.data.geojson.GeoJSONStreamWriter;
import org.geotoolkit.nio.IOUtilities;

/**
 * Handle results displayed on the dashboard.
 *
 * @author Cédric Briançon (Geomatys)
 */
@Component
public class DashboardResultsManager {

    protected static final String HISTORY_LOG = "history.log";

    @Autowired
    private Session session;

    @Autowired
    private ResultStorage resultStorage;

    /**
     * Get all previously-published result items in the dashboard.
     *
     * @return A list of results items.
     * @throws IOException
     * @throws ReflectiveOperationException
     * @throws IntrospectionException
     */
    private List<DashboardResultItem> getDashboardResultsItems() throws IOException, ReflectiveOperationException, IntrospectionException {
        if (!Files.exists(RhomeoCore.DASHBOARD_PUBLISHED_RESULTS)) {
            return new ArrayList<>();
        }
        final CSVDecoder<DashboardResultItem> decoder = new CSVDecoder<>(RhomeoCore.DASHBOARD_PUBLISHED_RESULTS, DashboardResultItem.class);
        return decoder.decode();
    }


    /**
     * Regroups results for indicator by year.
     *
     * @param site Site to use. Should not be {@code null}.
     * @return Results, never {@code null}
     * @throws IntrospectionException
     * @throws ReflectiveOperationException
     * @throws IOException
     */
    public ObservableList<IndicatorValuesByYearItem> getDashboardResultsByYear(final Site site) throws IntrospectionException, ReflectiveOperationException, IOException {
        final ObservableList<DashboardResultItem> items = getDashboardResultsForSite(site);
        final Map<Integer, IndicatorValuesByYearItem> indicatorsByYear = new HashMap<>();
        for (final DashboardResultItem item : items) {
            final int year = item.getYear();
            if (indicatorsByYear.containsKey(year)) {
                final IndicatorValuesByYearItem itemToUpdate = indicatorsByYear.get(year);
                itemToUpdate.addValueForIndicator(item.getIndicator(), item.getValue(), item.isPublished());
            } else {
                final IndicatorValuesByYearItem itemToAdd = new IndicatorValuesByYearItem(year);
                itemToAdd.addValueForIndicator(item.getIndicator(), item.getValue(), item.isPublished());
                indicatorsByYear.put(year, itemToAdd);
            }
        }
        return FXCollections.observableArrayList(indicatorsByYear.values());
    }

    /**
     * Get previously-published result items for the specified site.
     *
     * @param site the selected site.
     * @return A list of results items, never {@code null}
     * @throws IOException
     * @throws ReflectiveOperationException
     * @throws IntrospectionException
     */
    public ObservableList<DashboardResultItem> getDashboardResultsForSite(final Site site) throws IOException, ReflectiveOperationException, IntrospectionException {
        final List<DashboardResultItem> allRes = getDashboardResultsItems();
        final ObservableList<DashboardResultItem> resForSite = FXCollections.observableArrayList();
        resForSite.addAll(allRes.stream()
                .filter(item -> site.getName() != null && site.getName().equals(item.getSite()))
                .collect(Collectors.toList()));
        return resForSite;
    }

    /**
     * Add or replace a list of result items.
     *
     * @param itemsToAdd A list of items to serialize.
     * @throws IOException
     * @throws ReflectiveOperationException
     * @throws IntrospectionException
     */
    public void addDashboardResultsItems(final List<DashboardResultItem> itemsToAdd) throws IOException, ReflectiveOperationException, IntrospectionException {
        // Ensures the CSV file to write will exist
        if (!Files.exists(RhomeoCore.DASHBOARD_PUBLISHED_RESULTS)) {
            Files.createFile(RhomeoCore.DASHBOARD_PUBLISHED_RESULTS);
        }

        // First get existing results
        final List<DashboardResultItem> existingResults = getDashboardResultsItems();

        // Merge new / updates results into existing results.
        for (final DashboardResultItem itemToAdd : itemsToAdd) {
            boolean toAdd = true;
            for (final DashboardResultItem existing : existingResults) {
                if (existing.getSite().equals(itemToAdd.getSite()) &&
                        existing.getIndicator().equals(itemToAdd.getIndicator()) &&
                        existing.getYear() == itemToAdd.getYear())
                {
                    // Same site, indicator and year, so just update the value
                    existing.setValue(itemToAdd.getValue());
                    existing.setPublished(itemToAdd.isPublished());
                    toAdd = false;
                    break;
                }
            }

            // Not found for update, so just add it.
            if (toAdd) {
                existingResults.add(itemToAdd);
            }
        }

        // Serialize them
        final CSVEncoder<DashboardResultItem> encoder = new CSVEncoder<>(RhomeoCore.DASHBOARD_PUBLISHED_RESULTS, DashboardResultItem.class);
        encoder.encode(existingResults, true);
    }

    /**
     * Rename site name in dashboard results. Use it when a site is renamed.
     *
     * @param site New site containing new name.
     * @param oldName Old site name.
     * @throws IntrospectionException
     * @throws ReflectiveOperationException
     * @throws IOException
     */
    public void updateDashboardResultsSiteName(final Site site, final String oldName) throws IntrospectionException, ReflectiveOperationException, IOException {
        if (!Files.exists(RhomeoCore.DASHBOARD_PUBLISHED_RESULTS)) {
            return;
        }
        // First get existing results
        final List<DashboardResultItem> existingResults = getDashboardResultsItems();
        existingResults.stream().filter(item -> item.getSite().equals(oldName))
                .forEach(item -> item.setSite(site.getName()));

        // Serialize them
        final CSVEncoder<DashboardResultItem> encoder = new CSVEncoder<>(RhomeoCore.DASHBOARD_PUBLISHED_RESULTS, DashboardResultItem.class);
        encoder.encode(existingResults, true);
    }

    /**
     * Update existing results, for published and value properties.
     *
     * @param itemsToUpdate
     * @throws IntrospectionException
     * @throws ReflectiveOperationException
     * @throws IOException
     */
    public void updateResults(final List<DashboardResultItem> itemsToUpdate) throws IntrospectionException, ReflectiveOperationException, IOException {
        if (itemsToUpdate == null || itemsToUpdate.isEmpty()) {
            return;
        }

        final CSVEncoder<DashboardResultItem> encoder = new CSVEncoder<>(RhomeoCore.DASHBOARD_PUBLISHED_RESULTS, DashboardResultItem.class);
        // Ensures the CSV file to write will exist. If not, create it and directly serialize them
        if (!Files.exists(RhomeoCore.DASHBOARD_PUBLISHED_RESULTS)) {
            Files.createFile(RhomeoCore.DASHBOARD_PUBLISHED_RESULTS);
            // Serialize them
            encoder.encode(itemsToUpdate, true);
            return;
        }

        // First get existing results
        final List<DashboardResultItem> existingResults = getDashboardResultsItems();
        for (final DashboardResultItem itemToUp : itemsToUpdate) {
            final int index = existingResults.indexOf(itemToUp);
            if (index > -1) {
                existingResults.set(index, itemToUp);
            }
        }

        encoder.encode(existingResults, true);
    }

    /**
     * Remove results entries for the specified site name.
     *
     * @param siteName
     * @throws IntrospectionException
     * @throws ReflectiveOperationException
     * @throws IOException
     */
    public void removeDashboardResultsForSite(final String siteName) throws IntrospectionException, ReflectiveOperationException, IOException {
        if (siteName == null) {
            return;
        }
        if (!Files.exists(RhomeoCore.DASHBOARD_PUBLISHED_RESULTS)) {
            return;
        }

        // First get existing results
        final List<DashboardResultItem> existingResults = getDashboardResultsItems();
        final List<DashboardResultItem> filteredList = existingResults.stream()
                .filter(item -> !item.getSite().equals(siteName))
                .collect(Collectors.toList());

        // Delete all result files associated to the site
        resultStorage.deleteIfExists(siteName);

        // Serialize them
        final CSVEncoder<DashboardResultItem> encoder = new CSVEncoder<>(RhomeoCore.DASHBOARD_PUBLISHED_RESULTS, DashboardResultItem.class);
        encoder.encode(filteredList, true);

    }

    /**
     * Send results currently contained in the session to the FTP service for
     * result publication.
     *
     * Structure on the FTP is:
     * <pre>
     * results
     * |- county code
     * |--|- SHA1 geometry site
     * |--|--|- protocol
     * |--|--|--|- UUID
     * |--|--|--|--|- site.geojson
     * |--|--|--|--|- metadata.json
     * |--|--|--|--|- result.csv
     * ...
     * </pre>
     */
    public void publishResults() throws IOException, DataStoreException, IntrospectionException, GeneralSecurityException {
        // Move to the right directory
        final AtomicReference dirRef = new AtomicReference();
        resultStorage.publish(() -> {
            FTPClient ftpClient = null;
            try {
                ftpClient = session.connectResultFTP();
                final String rootDir = ftpClient.printWorkingDirectory();
                prepareSiteDirectory(session.getDataContext().getSite(), ftpClient);
                prepareResultDirectory(session.getDataContext().getProtocol(), ftpClient);
                dirRef.set(ftpClient.printWorkingDirectory().replace(rootDir, ""));
                return ftpClient;
            } catch (Exception e) {
                if (ftpClient != null)
                    try {
                        ftpClient.disconnect();
                    } catch (IOException ex) {
                        e.addSuppressed(ex);
                    }
                throw new RhomeoRuntimeException(e);
            }
        });

        // log publication
        final Object bundlePath = dirRef.get();
        if (bundlePath != null) {
            Optional<Log> log = fromSession(session, bundlePath.toString());
            if (log.isPresent()) {
                log(session.connectResultFTP(), log.get());
            }
        }
    }

    /**
     * Publish site results on the FTP.
     * Structure on the FTP is:
     * <pre>
     * results
     * |- county code
     * |--|- SHA1 geometry site
     * |--|--|- protocol
     * |--|--|--|- UUID
     * |--|--|--|--|- site.geojson
     * |--|--|--|--|- metadata.json
     * |--|--|--|--|- result.csv
     * ...
     * </pre>
     *
     * @param site
     * @param resultsForSite
     * @param ftpClient
     * @throws IOException
     * @throws IntrospectionException
     * @throws ReflectiveOperationException
     */
    public void publishResults(final Site site, final List<DashboardResultItem> resultsForSite, final FTPClient ftpClient)
            throws IOException, IntrospectionException, ReflectiveOperationException, DataStoreException
    {
        final Path tempFolder = Files.createTempDirectory("site");
        final Path siteJsonPath = ExportUtils.writeGeoJson(site, tempFolder);
        final ArrayList<DashboardResultItem> reallyPublished = new ArrayList<>(resultsForSite.size());
        final ArrayList<Log> logs = new ArrayList<>(resultsForSite.size());
        final String rootDir = ftpClient.printWorkingDirectory();
        try {
            prepareSiteDirectory(site, ftpClient);
            for (final DashboardResultItem item : resultsForSite) {
                final Object prototypeIndic = session.getBean(item.getIndicator());
                if (prototypeIndic instanceof Indicator) {
                    final Indicator indicator = (Indicator) prototypeIndic;
                    prepareResultDirectory(indicator.getProtocol(), ftpClient);

                    try (final InputStream in = Files.newInputStream(siteJsonPath, StandardOpenOption.READ)) {
                        ftpClient.storeFile("site.geojson", in);
                    }

                    resultStorage.publish(item, ftpClient);
                    item.setPublished(true);
                    reallyPublished.add(item);

                    logs.add(new Log(site, ftpClient.printWorkingDirectory().replace(rootDir, ""), new int[]{item.getYear()}, indicator));

                    if (!ftpClient.changeToParentDirectory()) {
                        throw new IOException("Unable to change to parent directory");
                    }
                    if (!ftpClient.changeToParentDirectory()) {
                        throw new IOException("Unable to change to parent directory");
                    }
                }
            }

        } finally {
            try {
                IOUtilities.deleteRecursively(tempFolder);
            } catch (Exception e) {
                RhomeoCore.LOGGER.log(Level.WARNING, "Cannot clear temporary files.", e);
            }

            if (!logs.isEmpty() && ftpClient.changeToParentDirectory() && ftpClient.changeToParentDirectory()) {
                log(ftpClient, logs.toArray(new Log[logs.size()]));
            }

            // Update results publication state.
            updateResults(reallyPublished);
        }
    }

    /**
     * Create (if needed) folders in which input site results will be put.
     * Working directory is changed to the created/detected directory.
     * @param site The site we want to post results for.
     * @param ftpClient The FTP connection to work with.
     */
    private static void prepareSiteDirectory(final Site site, final FTPClient ftpClient) throws IOException, DataStoreException {
        // Move to county code ftp folder
        String department = site.getCountyCode();
        if (department == null || department.trim().isEmpty())
            department = "00";
        ftpClient.makeDirectory(department);
        if (!ftpClient.changeWorkingDirectory(department)) {
            throw new IOException("Unable to change of directory to " + department);
        }

        // Move to geom SHA1 ftp folder
        final String sha1geom = GeometryUtils.getSha1(site.getGeometry());
        ftpClient.makeDirectory(sha1geom);
        if (!ftpClient.changeWorkingDirectory(sha1geom)) {
            throw new IOException("Unable to change of directory to " + sha1geom);
        }

        if (ftpClient.listFiles(ExportUtils.SITE_JSON).length < 1) {
            try (final OutputStream out = ftpClient.storeFileStream(ExportUtils.SITE_JSON)) {
                GeoJSONStreamWriter.writeSingleFeature(out, SiteRepositoryImpl.toFeature(site), JsonEncoding.UTF8, 2, true);
            }
            ftpClient.completePendingCommand();
        }
    }

    /**
     * Check directories for result publication for a specific protocol. It will
     * find or create, and then move to a folder named as input protocol, and
     * then create and move to a folder whose name is an UUID.
     *
     * Note : For publication, you should already be in the site folder, i.e
     * have called {@link #prepareSiteDirectory(fr.cenra.rhomeo.api.data.Site, org.apache.commons.net.ftp.FTPClient) }.
     *
     * @param p The protocol to post results for.
     * @param ftpClient The FTP connection to work with.
     */
    public static void prepareResultDirectory(final Protocol p, final FTPClient ftpClient) throws IOException {
        final String protocolName = p.getName();
        ftpClient.makeDirectory(protocolName);
        if (!ftpClient.changeWorkingDirectory(protocolName)) {
            throw new IOException("Unable to change of directory to " + protocolName);
        }

        final String uuid = UUID.randomUUID().toString();
        ftpClient.makeDirectory(uuid);
        if (!ftpClient.changeWorkingDirectory(uuid)) {
            throw new IOException("Unable to change of directory to " + uuid);
        }
    }

    private static void log(final FTPClient ftpClient, Log... toLog) throws IOException {
        try (OutputStream out = ftpClient.appendFileStream(HISTORY_LOG);
                final OutputStreamWriter outWriter = new OutputStreamWriter(out, StandardCharsets.UTF_8);
                final BufferedWriter writer = new BufferedWriter(outWriter)) {
            for (final Log log : toLog) {
                writer.newLine();
                writer.write(log.toString());
            }
        } finally {
            ftpClient.completePendingCommand();
        }
    }

    private static Optional<Log> fromSession(final Session session, final String bundlePath) {
        if (session.getDataContext() == null || session.getResults() == null || session.getResults().isEmpty())
            return Optional.empty();

        final Set<Integer> years = new HashSet<>();
        final Set<Indicator> indicators = new HashSet<>();

        for (final Index i : session.getResults()) {
            years.add(i.getYear());
            indicators.add(i.getSpi().getIndicator());
        }

        if (years.isEmpty() || indicators.isEmpty())
            return Optional.empty();

        final int[] primYears = new int[years.size()];
        int count = 0;
        for (Integer year : years)
            primYears[count++] = year;

        return Optional.of(new Log(session.getDataContext().getSite(), bundlePath, primYears, indicators.toArray(new Indicator[indicators.size()])));
    }

    private static class Log {
        final String siteName;
        final String referent;
        final String organisation;
        final String targetBundle;
        final String indicateurs;
        final String annees;

        public Log(final Site site, final String targetBundle, final int[] years, final Indicator... indicators) {
            this(targetBundle, site.getName(), site.getReferent(), site.getOrganization(), years, indicators);
        }

        public Log(final String target, final String siteName, final String ref, final String org, final int[] years, final Indicator... indicators) {
            this.siteName = siteName;
            referent = ref;
            organisation = org;
            targetBundle = target;

            Arrays.sort(years);
            StringJoiner joiner = new StringJoiner(", ");
            for (final int year : years)
                joiner.add(String.valueOf(year));
            this.annees = joiner.toString();

            Arrays.sort(indicators);
            joiner = new StringJoiner(", ");
            for (final Indicator i : indicators)
                joiner.add(i.getName());
            indicateurs = joiner.toString();
        }

        @Override
        public String toString() {
            return new StringBuilder(256)
                    .append(ZonedDateTime.now().toString()).append(" : ")
                    .append(siteName).append(", ")
                    .append(referent == null || referent.isEmpty()? "referent inconnu" : referent)
                    .append(" (").append(organisation == null || organisation.isEmpty()? "organisation inconnue" : organisation).append(")")
                    .append(" publie ").append(targetBundle)
                    .append(" pour ").append(indicateurs)
                    .append(" en ").append(annees)
                    .toString();
        }
    }
}
