/**
 * 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.state;

import com.fasterxml.jackson.databind.ObjectMapper;
import fr.cenra.rhomeo.api.data.DataContext;
import fr.cenra.rhomeo.api.data.Dataset;
import fr.cenra.rhomeo.api.data.Statement;
import fr.cenra.rhomeo.core.CSVDecoder;
import fr.cenra.rhomeo.core.CSVEncoder;
import fr.cenra.rhomeo.core.Session;
import fr.cenra.rhomeo.core.WorkflowStep;
import fr.cenra.rhomeo.core.util.SerializableDataContext;
import java.beans.IntrospectionException;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
import java.util.logging.Level;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javax.annotation.PreDestroy;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

/**
 * Listens given dataset and data context over time, to save them on change.
 *
 * @author Alexis Manin (Geomatys)
 */
@Component
class DatasetManager implements StepStateManager {

    private static final String CONTEXT_FILE = "context.json";
    private static final String DATASET_FILE = "dataset.csv";

    @Autowired
    private Session session;

    @Autowired
    private StateManager manager;

    private Path target;
    private DataContext context;
    private Dataset dataset;

    private final ObjectMapper jsonMapper;

    private final InvalidationListener datasetListener;
    private final InvalidationListener contextListener;

    private StateManager.Trigger contextTrigger;
    private StateManager.Trigger datasetTrigger;


    /**
     *
     */
    public DatasetManager() {
        jsonMapper = new ObjectMapper();
        contextListener = this::contextChanged;
        datasetListener = this::datasetChanged;
    }

    @Override
    public WorkflowStep getStep() {
        return WorkflowStep.DATASET;
    }

    @Override
    public synchronized void setBackupFolder(Path toWriteInto) {
        target = toWriteInto;
    }

    @Override
    public synchronized void startSaving() {
        if (session.getDataContext() == context && session.getDataset() == dataset)
            return; // Already listening on changes.

        if (context != null || dataset != null)
            stopSaving();

        context = session.getDataContext();
        context.getReferences().addListener(contextListener);
        contextTrigger = manager.prepareTask(this::writeContext, "Data context state");
        // If data context does not exists, we create it now
        if (target != null && !Files.exists(target.resolve(CONTEXT_FILE))) {
            contextChanged(null);
        }

        dataset = session.getDataset();
        dataset.getItems().addListener(datasetListener);
        datasetTrigger = manager.prepareTask(this::writeDataset, "Dataset state");
        // If dataset does not exists, we create it now
        if (target != null && !Files.exists(target.resolve(DATASET_FILE))) {
            datasetChanged(null);
        }
    }

    @PreDestroy
    @Override
    public synchronized void stopSaving() {
        if (dataset != null) {
            datasetTrigger.close();
            dataset.getItems().removeListener(datasetListener);
            datasetTrigger = null;
            dataset = null;
        }

        if (context != null) {
            contextTrigger.close();
            context.getReferences().removeListener(contextListener);
            contextTrigger = null;
            context = null;
        }
    }

    @Override
    public boolean restoreStep() {
        if (dataset != null || context != null) {
            throw new IllegalStateException("Cannot restore dataset while backup is running !");
        }

        if (target == null) {
            return false;
        }

        /*
         * We start restoration with data context, as no dataset can be
         * initialized without it. The dataset is read once we're sure we've
         * loaded a valid context.
         */
        try {
            final SerializableDataContext ctx = readContext();
            if (ctx == null || !ctx.restoreDataContext(session))
                return false;

            final List<? extends Statement> data = readDataset();
            if (data != null) {
                session.getDataset().getItems().addAll(data);
            }

            return true;

        } catch (Exception e) {
            StateManager.LOGGER.log(Level.WARNING, "Cannot restore dataset from folder ".concat(target.toString()), e);
            return false;
        }
    }

    /**
     *
     * @return A data context loaded from backup folder. Null if no context is
     * available.
     * @throws IOException If a backup file is present, but something goes wrong
     * at reading.
     */
    private SerializableDataContext readContext() throws IOException {
        final Path ctxFile = target.resolve(CONTEXT_FILE);
        if (Files.isReadable(ctxFile)) {
            try (final BufferedReader reader = Files.newBufferedReader(ctxFile, StandardCharsets.UTF_8)) {
                return jsonMapper.readValue(reader, SerializableDataContext.class);
            }
        }

        return null;
    }

    /**
     *
     * @return A dataset loaded from backup folder. Null if none is available.
     * @throws IntrospectionException If a backup file is present, but something
     * goes wrong at reading.
     * @throws IOException If a backup file is present, but something goes wrong
     * at reading.
     * @throws ReflectiveOperationException If an error happens with the read
     * datatype.
     */
    private List<? extends Statement> readDataset() throws IntrospectionException, IOException, ReflectiveOperationException {
        final Path datafile = target.resolve(DATASET_FILE);
        if (Files.isReadable(datafile)) {
            return new CSVDecoder(datafile, session.getDataContext().getProtocol().getDataType(), StandardCharsets.UTF_8).decode();
        }

        return null;
    }

    /**
     *
     * @return
     * @throws IOException
     * @throws IntrospectionException
     */
    private boolean writeDataset() throws IOException, IntrospectionException {
        final Path tmpFile = Files.createTempFile("tmpDataset", ".csv");
        new CSVEncoder(tmpFile, context.getProtocol().getDataType(), StandardCharsets.UTF_8).encode(dataset.getItems(), true);

        Files.move(tmpFile, target.resolve(DATASET_FILE), StandardCopyOption.REPLACE_EXISTING);
        return true;
    }

    /**
     * Save current data context. First, we write it in a temporary file. We replace
     * the backup only once we're sure the context has been successfully written.
     * @throws IOException
     */
    private boolean writeContext() throws IOException {
        final Path tmpFile = Files.createTempFile("tmpContext", ".json");
        // Prepare a serializable object.
        final SerializableDataContext ctx  = SerializableDataContext.fromDataContext(context);
        try (final BufferedWriter writer = Files.newBufferedWriter(tmpFile, StandardCharsets.UTF_8)) {
            jsonMapper.writeValue(writer, ctx);
        }

        Files.move(tmpFile, target.resolve(CONTEXT_FILE), StandardCopyOption.REPLACE_EXISTING);
        return true;
    }

    /**
     * Notify writer that we want it to store the current context state. We both
     * set trigger value and notify it, so if the writer is waiting, it will
     * be notified, and if it is already running, it will immediately be aware
     * that a new saving is required.
     *
     * @param obs
     */
    private void contextChanged(final Observable obs) {
        contextTrigger.arm();
    }

    /**
     * Notify writer that we want it to store the current dataset state. We both
     * set trigger value and notify it, so if the writer is waiting, it will
     * be notified, and if it is already running, it will immediately be aware
     * that a new saving is required.
     *
     * @param obs
     */
    private void datasetChanged(final Observable obs) {
        datasetTrigger.arm();
    }
}
