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

import fr.cenra.rhomeo.Rhomeo;
import fr.cenra.rhomeo.api.data.Dataset;
import fr.cenra.rhomeo.api.data.ObjectWrapper;
import fr.cenra.rhomeo.api.data.Protocol;
import fr.cenra.rhomeo.api.data.Statement;
import fr.cenra.rhomeo.api.process.ProcessContext;
import fr.cenra.rhomeo.api.ui.FilterTable;
import fr.cenra.rhomeo.api.ui.StatementEditor;
import fr.cenra.rhomeo.core.CSVDecoder;
import fr.cenra.rhomeo.core.CSVEncoder;
import fr.cenra.rhomeo.core.CSVMapper;
import fr.cenra.rhomeo.core.Session;
import fr.cenra.rhomeo.core.WorkflowStep;
import fr.cenra.rhomeo.fx.validation.FXValidationPane;
import fr.cenra.rhomeo.preferences.csv.CSVPreferences;
import java.io.File;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Optional;
import java.util.ResourceBundle;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.binding.IntegerBinding;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.concurrent.Task;
import javafx.concurrent.Worker;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.fxml.FXML;
import javafx.scene.Node;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.control.SelectionMode;
import javafx.scene.image.ImageView;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import javafx.stage.FileChooser;
import javax.annotation.PostConstruct;
import javax.validation.ConstraintViolation;
import javax.validation.Path;
import org.apache.sis.util.ArgumentChecks;
import org.geotoolkit.gui.javafx.util.TaskManager;
import org.geotoolkit.internal.GeotkFX;
import org.geotoolkit.util.collection.CloseableIterator;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;
import fr.cenra.rhomeo.api.ui.FilterTableSpi;
import fr.cenra.rhomeo.core.state.StateManager;
import java.util.concurrent.atomic.AtomicBoolean;
import javafx.beans.Observable;

/**
 * Default editor for {@link Datasset} edition. For {@link Statement} edition,
 * we try to inject a {@link StatementEditor} specific to the input {@link Protocol#getDataType()
 * }.
 *
 * IMPORTANT : Input {@link StatementEditor} does not use directly {@link Dataset#getItems()
 * }, to avoid sudden changes in the editor caused by an event sourced from
 * dataset. However, to keep a responsive dataset, we mirror editor data in
 * dataset on the fly. It is needed for state saving (see {@link StateManager) and other mechanisms as validation.
 *
 * @author Alexis Manin (Geomatys)
 */
@Component
@Scope("prototype")
@Lazy
public class FXDatasetPane extends VBox {

    private static final Pattern CSV_SUFFIX = Pattern.compile("(?i)\\.csv$");

    @Autowired
    private CSVPreferences csvPrefs;

    @Autowired
    private Session session;

    @Autowired
    private List<FilterTableSpi> pointTableSpis;

    private FilterTable<Predicate> uiGroupTable;

    private StatementEditor<Statement> uiStatementEditor;

    @Autowired
    private FXValidationPane uiValidator;

    @FXML
    private Button uiDisplayDocumentation;
    @FXML
    private Button uiDeleteDataset;

    @FXML
    private BorderPane uiPointListContainer;

    @FXML
    private Button uiAddPoint;
    @FXML
    private Button uiDeletePoints;

    @FXML
    private BorderPane uiPointEditorContainer;

    @FXML
    private BorderPane uiValidationContainer;

    @FXML
    private Button uiExportDataset;

    @FXML
    private Button uiProceed;

    protected final ResourceBundle bundle;

    private final ListChangeListener itemListener = this::itemsChanged;

    private ObservableList<Statement> dataList;

    private final AtomicBoolean changeDetected = new AtomicBoolean(false);

    public FXDatasetPane() {
        bundle = ResourceBundle.getBundle(FXDatasetPane.class.getCanonicalName());
        Rhomeo.loadFXML(this, FXDatasetPane.class);
    }

    @PostConstruct
    private void init() {
        ArgumentChecks.ensureNonNull("Session containing data", session);
        ArgumentChecks.ensureNonNull("Data context", session.getDataContext());
        ArgumentChecks.ensureNonNull("Target protocol", session.getDataContext().getProtocol());
        ArgumentChecks.ensureNonNull("Target dataset", session.getDataset());

        for (final FilterTableSpi spi : pointTableSpis) {
            if (spi.getProtocol() == session.getDataContext().getProtocol()) {
                uiGroupTable = spi.createEmptyTable();
                break;
            }
        }

        if (uiGroupTable == null) {
            uiGroupTable = (FilterTable) new FXTrackingPointTable(session);
        }

        uiGroupTable.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
        uiGroupTable.setMinHeight(50);
        uiGroupTable.setFixedCellSize(25);
        final IntegerBinding size = Bindings.size(uiGroupTable.getItems());
        uiGroupTable.prefHeightProperty().bind(
                Bindings.createDoubleBinding(() -> {
                    if (size.get() < 1) {
                        return 50d;
                    }
                    return size.get() * uiGroupTable.getFixedCellSize() + 30;
                }, size, uiGroupTable.fixedCellSizeProperty())
        );

        Optional<StatementEditor> optEditor = session.findEditor(session.getDataContext().getProtocol().getDataType());
        if (optEditor.isPresent()) {
            uiStatementEditor = optEditor.get();
            if (uiStatementEditor instanceof Region) {
                ((Region)uiStatementEditor).setMinHeight(50);
            }
            uiStatementEditor.itemsProperty().addListener(this::itemsPropertyChanged);
            uiGroupTable.getSelectionModel().selectedItemProperty().addListener((obs, oldGroup, newGroup) -> {
                if (newGroup == null) {
                    uiStatementEditor.setItems(FXCollections.observableArrayList());
                } else {
                    uiStatementEditor.setItems(FXCollections.observableArrayList(session.getDataset().getItems().filtered(newGroup)));
                }
            });

            uiStatementEditor.setItems(FXCollections.observableArrayList());
            uiPointEditorContainer.setCenter((Node) uiStatementEditor);
        } else {
            uiAddPoint.setVisible(false);
            uiAddPoint.setManaged(false);
        }

        uiPointListContainer.setCenter(uiGroupTable);
        uiPointEditorContainer.managedProperty().bind(uiPointEditorContainer.visibleProperty());

        uiValidationContainer.setCenter(uiValidator);
        uiValidator.getWarningList().getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
        uiValidator.getWarningList().getSelectionModel().selectedItemProperty().addListener(this::selectedErrorChanged);
        uiValidator.getErrorList().getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
        uiValidator.getErrorList().getSelectionModel().selectedItemProperty().addListener(this::selectedErrorChanged);
        /*
         * HACK : Error list force focus on itself after click. So we
         * have to repeat the selectedErrorChanged on mouse released.
         */
        final EventHandler<MouseEvent> handler = evt -> {
            final ConstraintViolation violation;
            if (evt.getSource() == uiValidator.getWarningList()) {
                violation = uiValidator.getWarningList().getSelectionModel().getSelectedItem();
            } else if (evt.getSource() == uiValidator.getErrorList()) {
                violation = uiValidator.getErrorList().getSelectionModel().getSelectedItem();
            } else {
                violation = null;
            }

            if (violation != null)
                selectedErrorChanged(null, null, violation);
        };
        uiValidator.getErrorList().addEventHandler(MouseEvent.MOUSE_RELEASED, handler);
        uiValidator.getWarningList().addEventHandler(MouseEvent.MOUSE_RELEASED, handler);

        /*
         * Disability
         */
        BooleanBinding emptyDataset = Bindings.isEmpty(session.getDataset().getItems());
        uiDeleteDataset.disableProperty().bind(emptyDataset);
        uiExportDataset.disableProperty().bind(emptyDataset);
        uiProceed.disableProperty().bind(emptyDataset);

        uiDeletePoints.disableProperty().bind(uiGroupTable.getSelectionModel().selectedItemProperty().isNull());

        // Icons
        uiDeleteDataset.setGraphic(new ImageView(Rhomeo.ICON_TRASH_BLUE));
        uiDeletePoints.setGraphic(new ImageView(Rhomeo.ICON_TRASH_BLUE));
        uiDisplayDocumentation.setGraphic(new ImageView(Rhomeo.ICON_FILE_TEXT));

        session.getDataset().getItems().addListener(this::changeDetected);
    }

    @FXML
    void addPoint(ActionEvent event) {
        // No tracking point selected. However, a null value will allow creation
        // of a fresh one. We force editor visibility.
        uiGroupTable.getSelectionModel().clearSelection();
        uiStatementEditor.setItems(FXCollections.observableArrayList());
    }

    @FXML
    void deleteDataset(ActionEvent event) {
        final Alert alert = new Alert(Alert.AlertType.CONFIRMATION, bundle.getString("dataset.delete.confirm"), ButtonType.NO, ButtonType.YES);
        alert.setResizable(true);
        if (ButtonType.YES.equals(alert.showAndWait().orElse(ButtonType.NO))) {
            // issue #79 : focused filter is not suppressed.
            uiGroupTable.getSelectionModel().clearSelection();
            session.getDataset().getItems().clear();
            if (uiStatementEditor != null)
                uiStatementEditor.setItems(FXCollections.observableArrayList());
            uiValidator.reset();
        }
    }

    @FXML
    void deletePoints(ActionEvent event) {
        final Alert alert = new Alert(Alert.AlertType.CONFIRMATION, bundle.getString("point.delete.confirm"), ButtonType.NO, ButtonType.YES);
        alert.setResizable(true);
        if (ButtonType.YES.equals(alert.showAndWait().orElse(ButtonType.NO))) {
            // Defensive copy
            final ArrayList<Predicate> selected = new ArrayList<>(uiGroupTable.getSelectionModel().getSelectedItems());
            // HACK : filter table won't delete entries if they're focused.
            uiGroupTable.getSelectionModel().clearSelection();
            if (!selected.isEmpty()) {
                Predicate filter = selected.get(0);
                for (int i = 1; i < selected.size(); i++) {
                    filter = filter.or(selected.get(i));
                }
                session.getDataset().getItems().removeIf(filter);
            }
        }
    }

    @FXML
    void displayDocumentation(ActionEvent event) {
        final Protocol protocol = session.getDataContext().getProtocol();
        Rhomeo.generateModelZip(protocol, uiDisplayDocumentation);
    }

    @FXML
    Task<Boolean> exportDataset(ActionEvent event) {
        final File chosen = chooseCSV(uiExportDataset.getText(), true);
        if (chosen != null) {
            final TaskManager.MockTask export = new TaskManager.MockTask(bundle.getString("dataset.export.task"), () -> {
                final char sep;
                final String separator = csvPrefs.getPreference(CSVPreferences.SEPARATOR_KEY);
                if (separator == null || separator.isEmpty())
                    sep = CSVMapper.DEFAULT_SEPARATOR;
                else
                    sep = separator.charAt(separator.length() - 1);

                // Check if we must transform data when encoding.
                Class dataType = session.getDataContext().getProtocol().getDataType();
                Iterator toEncode = session.getDataset().getItems().iterator();
                Optional<ObjectWrapper> opt = session.getWrapper(dataType);
                if (opt.isPresent()) {
                    dataType = opt.get().getOutputType();
                    toEncode = new Transformer(opt.get(), toEncode);
                }

                final CSVEncoder encoder = new CSVEncoder(
                        chosen.toPath(), dataType, sep, Charset.forName(csvPrefs.getPreference(CSVPreferences.CHARSET_KEY)), null);
                encoder.encode(toEncode, true);
                return true;
            });

            export.setOnSucceeded(evt -> changeDetected.set(false));

            export.setOnFailed(evt -> Platform.runLater(() -> {
                GeotkFX.newExceptionDialog(bundle.getString("dataset.export.error"), export.getException());
            }));

            export.runningProperty().addListener((obs, oldValue, newValue) -> {
                setDisable(newValue);
                // TODO : show a loading popup
            });

            return TaskManager.INSTANCE.submit(export);
        }

        return null;
    }

    @FXML
    void importDataset(ActionEvent event) {
        Task previousTask = null;
        if (!session.getDataset().getItems().isEmpty()) {
            final String contentText = bundle.getString("save.before.import");
            final Alert saveCurrent = new Alert(Alert.AlertType.WARNING, contentText, ButtonType.CANCEL, ButtonType.NO, ButtonType.YES);
            saveCurrent.setResizable(true);
            ButtonType choice = saveCurrent.showAndWait().orElse(ButtonType.CANCEL);
            if (ButtonType.CANCEL.equals(choice)) {
                return;
            } else if (ButtonType.YES.equals(choice)) {
                previousTask = exportDataset(event);
                if (previousTask == null) {
                    return; // user cancelled saving. Stop the entire process.
                }
            }
        }

        final File input = chooseCSV(bundle.getString("dataset.import.choice"), false);
        if (input == null)
            return;

        /* On dataset change, the trigger serving to notify user on possible
         * unsaved entries. But, as we are just importing data, we must keep
         * the same state as before the task.
         */
        final boolean previousStatus = changeDetected.get();
        final TaskManager.MockTask<String> importTask = new TaskManager.MockTask(bundle.getString("dataset.import.task"), () -> {
            final HashSet<String> ignorable = new HashSet<>(2);
            ignorable.add("trackingPoint");
            ignorable.add("remarks");
            final char sep;
            final String separator = csvPrefs.getPreference(CSVPreferences.SEPARATOR_KEY);
            if (separator == null || separator.isEmpty())
                sep = CSVMapper.DEFAULT_SEPARATOR;
            else
                sep = separator.charAt(separator.length() - 1);
            final CSVDecoder decoder = new CSVDecoder(
                    input.toPath(),
                    session.getDataContext().getProtocol().getDataType(),
                    sep,
                    Charset.forName(csvPrefs.getPreference(CSVPreferences.CHARSET_KEY)),
                    ignorable
            );

            // Check doublons.
            int integrated = 0;
            int doublons = 0;
            final ObservableList dataset = session.getDataset().getItems();
            final HashSet doublonDetector = new HashSet(dataset);
            try (final CloseableIterator it = decoder.decodeLazy()) {
                Object read;
                while (it.hasNext()) {
                    read = it.next();
                    if (doublonDetector.add(read)) {
                        integrated++;
                    } else {
                        doublons++;
                    }
                }
            }

            // Update dataset with new elements.
            doublonDetector.removeAll(dataset);
            final TaskManager.MockTask t = new TaskManager.MockTask(() -> dataset.addAll(doublonDetector));
            Platform.runLater(t);
            t.get();

            if (doublons <= 0)
                return String.format(bundle.getString("dataset.import.result.no-doublon"), integrated);
            else
                return String.format(bundle.getString("dataset.import.result"), integrated, doublons);
        });

        importTask.setOnFailed(evt -> Platform.runLater(() -> {
            GeotkFX.newExceptionDialog(bundle.getString("dataset.import.error"), importTask.getException());
        }));

        importTask.setOnSucceeded(evt -> Platform.runLater(() -> {
            changeDetected.set(previousStatus);
            Rhomeo.showAlert(Alert.AlertType.INFORMATION, importTask.getValue());
        }));

        if (previousTask != null) {
            final SimpleObjectProperty<Worker.State> previousState = new SimpleObjectProperty<>();
            previousState.addListener((obs, oldState, newState) -> {
                if (Worker.State.SUCCEEDED.equals(newState)) {
                    TaskManager.INSTANCE.submit(importTask);
                }
            });
            previousState.bind(previousTask.stateProperty());
        } else {
            TaskManager.INSTANCE.submit(importTask);
        }
    }

    @FXML
    void nextStep(ActionEvent event) {
        Task<Boolean> previousTask = checkSaveChange().orElse(null);

        final Runnable proceed = () -> {
            setProcessContext(session);

            /*
             * Check if we're able to jump to the next step. If not, we try to guess
             * why, and inform user of what is wrong.
             */
            if (!session.requestWorkflowStep(WorkflowStep.PROCESS)) {
                if (session.getDataContext().getReferences().size() < session.getDataContext().getProtocol().getReferenceTypes().size()) {
                    Rhomeo.showAlert(Alert.AlertType.WARNING, "Les versions des référentiels associés au protocole ne sont pas définies !");
                } else {
                    Rhomeo.showAlert(Alert.AlertType.WARNING, "Impossible de passer à l'étape suivante. Veuillez vérifier votre lot de données.");
                }
            }
        };

        if (previousTask != null) {
            previousTask.setOnSucceeded(evt -> {
                if (Boolean.TRUE.equals(evt.getSource().getValue()))
                    Platform.runLater(proceed);
            });
        } else {
            proceed.run();
        }
    }

    /**
     * If changes have been detected in currently edited dataset, ask to user if
     * he wants to save them, and launch export process if he agrees.
     *
     * /!\ You must call this method from FX thread.
     *
     * @return An empty object if there's no change to save. Otherwise, a task
     * is returned, which returns false if user cancelled saving, or true if
     * data has been successfully written.
     */
    public Optional<Task<Boolean>> checkSaveChange() {
        if (!changeDetected.get()) {
            return Optional.empty();
        }

        final String contentText = bundle.getString("save.before.next");
        final Alert saveCurrent = new Alert(Alert.AlertType.CONFIRMATION, contentText, ButtonType.CANCEL, ButtonType.NO, ButtonType.YES);
        saveCurrent.setResizable(true);
        ButtonType choice = saveCurrent.showAndWait().orElse(ButtonType.CANCEL);

        if (ButtonType.YES.equals(choice)) {
            final Task exportTask = exportDataset(null);
            if (exportTask != null)
                return Optional.of(exportTask);
        } else if (ButtonType.NO.equals(choice)) {
            return Optional.empty();
        }

        final TaskManager.MockTask t = new TaskManager.MockTask(() -> false);
        t.run();
        return Optional.of(t);
    }

    /**
     * When a change is detected on input dataset, we notify user that changes
     * occurred and some data may not have been saved.
     *
     * @param obs
     */
    private void changeDetected(final Observable obs) {
        /* If dataset has been emptied, we clear change state. We check it here,
         * so it is valid either if user deleted it manually or via "clear dataset"
         * button.
         */
        if (session.getDataset().getItems().isEmpty())
            changeDetected.set(false);
        else
            changeDetected.set(true);
    }

    /**
     * When we change items of inner {@link StatementEditor}, we update
     * listeners registered on it.
     *
     * @param obs
     */
    private void itemsPropertyChanged(final Observable obs) {
        if (dataList != null)
            dataList.removeListener(itemListener);

        dataList = uiStatementEditor.getItems();
        if (dataList != null) {
            dataList.addListener(itemListener);
        }
    }

    /**
     * Report changes from editor list to dataset.
     * @param c
     */
    private void itemsChanged(final ListChangeListener.Change<Statement> c) {
        ObservableList dataset = session.getDataset().getItems();
        while (c.next()) {
            if (c.wasRemoved()) {
                // Delete strict reference, because if the user has added multiple new
                // objects in the table, they could be equal.
                final ArrayList removedCopy = new ArrayList(c.getRemoved());
                dataset.removeIf(item -> {
                    final Iterator it = removedCopy.iterator();
                    while (it.hasNext()) {
                        if (item == it.next()) {
                            it.remove();
                            return true;
                        }
                    }
                    return false;
                });
            }
            if (c.wasAdded()) {
                dataset.addAll(c.getAddedSubList());
            }
        }
    }

    /**
     * Utility method to set {@link Session}'s {@link ProcessContext}.
     *
     * @param session
     */
    public static void setProcessContext(final Session session){
        final ProcessContext processContext;
        if (session.getProcessContext() != null) {
            processContext = session.getProcessContext();
            // We keep previous selection, but clean tracking points which does
            // not exist anymore. After this operation, if we've got no more
            // point selected, we reset selection.
//            processContext.getTrackingPoints().retainAll(session.getDataset().getTrackingPoints());
//            if (processContext.getTrackingPoints().isEmpty()) {
//                processContext.getTrackingPoints().addAll(session.getDataset().getTrackingPoints());
//            }
        } else {
            // Create a process context. By default, we activate all available indicators for the edited protocol.
            processContext = new ProcessContext(session.getDataset(), session.getDataContext());
            session.getIndicators().stream()
                    .filter(i -> i.getProtocol() == session.getDataContext().getProtocol())
                    .forEach(i -> processContext.getIndicators().add(i));
            //processContext.getTrackingPoints().addAll(session.getDataset().getTrackingPoints());
        }

        session.setProcessContext(processContext);
    }

    /**
     * On error selection, we try to retrieve the origin statement, then focus
     * on it in the statement editor.
     *
     * @param obs
     * @param oldValue
     * @param newValue
     */
    private void selectedErrorChanged(final ObservableValue<? extends ConstraintViolation> obs, final ConstraintViolation oldValue, final ConstraintViolation newValue) {
        if (newValue != null) {
            if (newValue.getLeafBean() instanceof Statement) {
                final Statement st = (Statement) newValue.getLeafBean();
                /*
                 * Find group related to the target statement. We
                 * need it to initialize statement editor.
                 */
                for (final Predicate p : uiGroupTable.getItems()) {
                    if (p.test(st)) {
                        uiGroupTable.getSelectionModel().select(p);
                        break;
                    }
                }

                final Iterator<Path.Node> it = newValue.getPropertyPath().iterator();
                String pName = null;
                while (it.hasNext()) {
                    pName = it.next().getName();
                }
                uiStatementEditor.focusOn(st, pName);
            }
        }
    }

    /**
     * Display a file chooser to allow user to choose a CSV file to open or save.
     * @param title The title to display on the file chooser header.
     * @param saveMode True to show file chooser in save mode (see {@link FileChooser#showSaveDialog(javafx.stage.Window) }).
     * False to use opening mode (See {@link FileChooser#showOpenDialog(javafx.stage.Window) }.
     *
     * @return The file selected by user. Can be null if user cancelled.
     */
    private File chooseCSV(final String title, final boolean saveMode) {
        final FileChooser chooser = new FileChooser();
        chooser.setTitle(title);
        chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Fichier CSV", "*.csv"));
        final File previousPath = Rhomeo.getPreviousPath(FXDatasetPane.class);
        if (previousPath != null) {
            chooser.setInitialDirectory(previousPath);
        }

        File result = saveMode?
                chooser.showSaveDialog(getScene().getWindow()):
                chooser.showOpenDialog(getScene().getWindow());

        if (result != null) {
            Rhomeo.setPreviousPath(FXDatasetPane.class, result.getParentFile());
            if (!CSV_SUFFIX.matcher(result.getName()).find()) {
                result = new File(result.getParentFile(), result.getName().concat(".csv"));
            }
        }

        return result;
    }

    private static class Transformer<I, O> implements Iterator<O> {

        final ObjectWrapper<I, O> wrapper;
        final Iterator<I> source;

        public Transformer(ObjectWrapper<I, O> wrapper, Iterator<I> source) {
            this.wrapper = wrapper;
            this.source = source;
        }

        @Override
        public boolean hasNext() {
            return source.hasNext();
        }

        @Override
        public O next() {
            return wrapper.apply(source.next());
        }
    }
}
