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

import com.sun.javafx.PlatformUtil;
import fr.cenra.rhomeo.api.InternationalResource;
import fr.cenra.rhomeo.api.data.Protocol;
import fr.cenra.rhomeo.api.data.Statement;
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.util.ExportUtils;
import fr.cenra.rhomeo.fx.FXInitialDialogPane;
import java.awt.Color;
import java.awt.Desktop;
import java.awt.Font;
import java.beans.BeanInfo;
import java.beans.IntrospectionException;
import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URI;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Locale;
import java.util.MissingResourceException;
import java.util.Optional;
import java.util.ResourceBundle;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.logging.Level;
import java.util.prefs.Preferences;
import java.util.zip.ZipOutputStream;

import javafx.application.Platform;
import javafx.beans.property.ReadOnlyProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.ObservableList;
import javafx.concurrent.Task;
import javafx.embed.swing.SwingFXUtils;
import javafx.fxml.FXMLLoader;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Spinner;
import javafx.scene.control.SpinnerValueFactory;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TablePosition;
import javafx.scene.control.TableView;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.stage.FileChooser;
import javafx.stage.Modality;
import javafx.stage.Stage;
import javafx.util.StringConverter;
import org.apache.sis.util.ArgumentChecks;
import org.geotoolkit.font.FontAwesomeIcons;
import org.geotoolkit.font.IconBuilder;
import org.geotoolkit.gui.javafx.util.ComboBoxCompletion;
import org.geotoolkit.gui.javafx.util.TaskManager;
import org.geotoolkit.nio.ZipUtilities;

/**
 * Regroups methods used by the application for handling JavaFX features.
 *
 * @author Cédric Briançon (Geomatys)
 * @see RhomeoCore
 */
public final class Rhomeo extends RhomeoCore {
    /**
     * Application icon.
     */
    public static final Image ICON = new Image(Rhomeo.class.getResource("/fr/cenra/rhomeo/images/logo.png").toString(), 16, 16, true, false);

    public static final Image ICON_NOT_PUBLISHED = new Image(Rhomeo.class.getResource("/fr/cenra/rhomeo/images/not_published.png").toString(),
            16, 16, true, true);

    public static final Color GRAY_SITE = Color.decode("#898989");
    public static final Color GREEN_SITE = Color.decode("#9CB35B");
    public static final Color BLUE_SITE = Color.decode("#5B8AA4");
    public static final Color RED_SITE = Color.decode("#D85F70");
    public static final Color ORANGE_SITE = Color.decode("#FFA500");

    public static final Font PROGRESS_MONITOR_ICON_FONT = IconBuilder.FONT.deriveFont(16f);

    public static final Image ICON_BARS_WHITE       = SwingFXUtils.toFXImage(IconBuilder.createImage(FontAwesomeIcons.ICON_BARS,24, Color.WHITE),null);
    public static final Image ICON_CARET_UP         = SwingFXUtils.toFXImage(IconBuilder.createImage(FontAwesomeIcons.ICON_CARET_UP,16, GRAY_SITE),null);
    public static final Image ICON_CARET_DOWN       = SwingFXUtils.toFXImage(IconBuilder.createImage(FontAwesomeIcons.ICON_CARET_DOWN,16, GRAY_SITE),null);
    public static final Image ICON_CHECK_GRAY       = SwingFXUtils.toFXImage(IconBuilder.createImage(FontAwesomeIcons.ICON_CHECK,16, GRAY_SITE),null);
    public static final Image ICON_CHECK_GREEN      = SwingFXUtils.toFXImage(IconBuilder.createImage(FontAwesomeIcons.ICON_CHECK,16, GREEN_SITE),null);
    public static final Image ICON_CIRCLE_GRAY      = SwingFXUtils.toFXImage(IconBuilder.createImage(FontAwesomeIcons.ICON_CIRCLE,8, GRAY_SITE),null);
    public static final Image ICON_COG              = SwingFXUtils.toFXImage(IconBuilder.createImage(FontAwesomeIcons.ICON_COG,24, GRAY_SITE),null);
    public static final Image ICON_DOWNLOAD_BLUE    = SwingFXUtils.toFXImage(IconBuilder.createImage(FontAwesomeIcons.ICON_DOWNLOAD,24, BLUE_SITE),null);
    public static final Image ICON_EXCHANGE_GRAY    = SwingFXUtils.toFXImage(IconBuilder.createImage(FontAwesomeIcons.ICON_EXCHANGE,16, GRAY_SITE),null);
    public static final Image ICON_EYE_GRAY         = SwingFXUtils.toFXImage(IconBuilder.createImage(FontAwesomeIcons.ICON_EYE,16, GRAY_SITE),null);
    public static final Image ICON_FILE_PDF         = SwingFXUtils.toFXImage(IconBuilder.createImage(FontAwesomeIcons.ICON_FILE_PDF_O,24, RED_SITE),null);
    public static final Image ICON_FILE_TEXT        = SwingFXUtils.toFXImage(IconBuilder.createImage(FontAwesomeIcons.ICON_FILE_TEXT_O,24, BLUE_SITE),null);
    public static final Image ICON_INFO_BLUE        = SwingFXUtils.toFXImage(IconBuilder.createImage(FontAwesomeIcons.ICON_INFO_CIRCLE,24, BLUE_SITE),null);
    public static final Image ICON_INFO_GREY        = SwingFXUtils.toFXImage(IconBuilder.createImage(FontAwesomeIcons.ICON_INFO_CIRCLE,24, GRAY_SITE),null);
    public static final Image ICON_LONG_ARROW_RIGHT = SwingFXUtils.toFXImage(IconBuilder.createImage(FontAwesomeIcons.ICON_LONG_ARROW_RIGHT,48, GRAY_SITE),null);
    public static final Image ICON_PENCIL_BLUE      = SwingFXUtils.toFXImage(IconBuilder.createImage(FontAwesomeIcons.ICON_PENCIL,24, BLUE_SITE),null);
    public static final Image ICON_PENCIL_WHITE     = SwingFXUtils.toFXImage(IconBuilder.createImage(FontAwesomeIcons.ICON_PENCIL,24, Color.WHITE),null);
    public static final Image ICON_STOP_RED         = SwingFXUtils.toFXImage(IconBuilder.createImage(FontAwesomeIcons.ICON_STOP,16, RED_SITE),null);
    public static final Image ICON_TIMES_RED        = SwingFXUtils.toFXImage(IconBuilder.createImage(FontAwesomeIcons.ICON_TIMES,16, RED_SITE),null);
    public static final Image ICON_TRASH_BLUE       = SwingFXUtils.toFXImage(IconBuilder.createImage(FontAwesomeIcons.ICON_TRASH_O,24, BLUE_SITE),null);
    public static final Image ICON_WARNING_ORANGE   = SwingFXUtils.toFXImage(IconBuilder.createImage(FontAwesomeIcons.ICON_WARNING_ALIAS,24, ORANGE_SITE),null);

    public static final String CSS_FONT_AWESOME = "rhomeo-font-awesome";
    public static final String CSS_ERROR_FIELD = "error-field";
    public static final String CSS_WARNING_FIELD = "warning-field";
    public static final String CSS_INDICATOR_LABEL = "indicator-label";
    public static final String CSS_CURRENT_STEP = "current-step";
    public static final String CSS_PROTOCOL_BUTTON = "protocol-button";
    public static final String CSS_PROTOCOL_POPUP_BODY = "protocol-popup-body";

    /**
     * Main theme path
     */
    public static final String CSS_THEME_PATH = "/fr/cenra/rhomeo/theme.css";

    private static final ChangeListener<String> SPINNER_LISTENER = Rhomeo::spinnerTextChanged;
    /**
     * Generates a stage with {@linkplain RhomeoCore#APPLICATION_TITLE application title} and
     * {@linkplain #ICON application icon}.
     *
     * @return a new Rhomeo stage, never {@code null}
     */
    public static Stage newStage() {
        final Stage rhomeoStage = new Stage();
        rhomeoStage.getIcons().add(ICON);
        final String version = RhomeoCore.getVersion();
        final String title = (version == null || version.isEmpty()) ? APPLICATION_TITLE : APPLICATION_TITLE +" ("+ version +")";
        rhomeoStage.setTitle(title);
        return rhomeoStage;
    }

    /**
     * Generates the initial dialog. Will be shown when launching the application.
     *
     * @return The dialog stage, never {@code null}
     */
    public static Stage createWelcomePopup() {
        final Stage stage = Rhomeo.newStage();
        stage.initModality(Modality.APPLICATION_MODAL);
        stage.setResizable(true);
        stage.setTitle("Accueil");
        final FXInitialDialogPane dialog = new FXInitialDialogPane();
        final Scene scene = new Scene(dialog);
        scene.getStylesheets().add(Rhomeo.CSS_THEME_PATH);
        stage.setScene(scene);
        stage.sizeToScene();
        return stage;
    }

    public static void loadFXML(Parent candidate) {
        final Class modelClass = null;
        loadFXML(candidate, modelClass);
    }

    /**
     * Load FXML document matching input controller. If a model class is given,
     * we'll try to load a bundle for text internationalization.
     * @param candidate The controller object to get FXMl for.
     * @param bundleClass A class which will be used for bundle loading.
     */
    public static void loadFXML(final Parent candidate, final Class bundleClass) {
        ResourceBundle bundle = null;
        if (bundleClass != null) {
            try{
                bundle = ResourceBundle.getBundle(bundleClass.getName(), Locale.FRENCH,
                        Thread.currentThread().getContextClassLoader());
            }catch(MissingResourceException ex){
                LOGGER.log(Level.INFO, "Missing bundle for : {0}", bundleClass.getName());
            }
        }
        loadFXML(candidate, bundle);
    }

    public static void loadFXML(final Parent candidate, final ResourceBundle bundle) {
        loadFXML(candidate, candidate.getClass(), bundle);
    }

    public static void loadFXML(final Parent candidate, final Class fxmlClass, final ResourceBundle bundle) {
        ArgumentChecks.ensureNonNull("JavaFX controller object", candidate);
        final String fxmlpath = "/"+fxmlClass.getName().replace('.', '/')+".fxml";
        final URL resource = fxmlClass.getResource(fxmlpath);
        if (resource == null) {
            throw new RuntimeException("No FXML document can be found for path : "+fxmlpath);
        }
        final FXMLLoader loader = new FXMLLoader(resource);
        loader.setController(candidate);
        loader.setRoot(candidate);
        //in special environement like osgi or other, we must use the proper class loaders
        //not necessarly the one which loaded the FXMLLoader class
        loader.setClassLoader(fxmlClass.getClassLoader());

        if(bundle!=null) loader.setResources(bundle);

        fxRunAndWait(() -> {
            try {
                loader.load();
            } catch (IOException ex) {
                throw new IllegalArgumentException(ex.getMessage(), ex);
            }
        });
    }

    /**
     * Run given task in FX application thread (immediately if we're already in it),
     * and wait for its result before returning.
     *
     * @param <T> Return TYPE of the input task.
     * @param toRun Task to run in JavaFX thread.
     * @return Result of the input task.
     */
    public static <T> T fxRunAndWait(final Callable<T> toRun) {
        return fxRunAndWait(new TaskManager.MockTask<>(toRun));
    }

    /**
     *
     * @param toRun The task to run.
     */
    public static void fxRunAndWait(final Runnable toRun) {
        fxRunAndWait(new TaskManager.MockTask(toRun));
    }

    /**
     * Run given task in FX application thread (immediately if we're already in it),
     * and wait for its result before returning.
     *
     * @param <T> Return TYPE of the input task.
     * @param toRun Task to run in JavaFX thread.
     * @return Result of the input task.
     */
    public static <T> T fxRunAndWait(final Task<T> toRun) {
        return fxRun(true, toRun);
    }

    /**
     * Run given task in FX application thread (immediately if we're already in it).
     * According to input boolean, we will return immediately or wait for the task to
     * be over.
     * @param wait True if we must wait for the task to end before returning, false
     * to return immediately after submission.
     * @param toRun The task to run into JavaFX application thread.
     */
    public static void fxRun(final boolean wait, final Runnable toRun) {
        fxRun(wait, new TaskManager.MockTask(toRun));
    }

    /**
     * Run given task in FX application thread (immediately if we're already in it).
     * According to input boolean, we will return immediately or wait for the task to
     * be over.
     * @param <T> Return TYPE of input task.
     * @param wait True if we must wait for the task to end before returning, false
     * to return immediately after submission.
     * @return The task return value if we must wait, or we're in platform thread. Otherwise null.
     * @param toRun The task to run into JavaFX application thread.
     */
    public static <T> T fxRun(final boolean wait, final Callable<T> toRun) {
        return fxRun(wait, new TaskManager.MockTask<>(toRun));
    }

    /**
     * Run given task in FX application thread (immediately if we're already in it).
     * According to input boolean, we will return immediately or wait for the task to
     * be over.
     * @param <T> Return TYPE of input task.
     * @param wait True if we must wait for the task to end before returning, false
     * to return immediately after submission.
     * @return The task return value if we must wait, or we're in platform thread. Otherwise null.
     * @param toRun The task to run into JavaFX application thread.
     */
    public static <T> T fxRun(final boolean wait, final Task<T> toRun) {
        if (Platform.isFxApplicationThread())
            toRun.run();
        else
            Platform.runLater(toRun);

        if (wait || Platform.isFxApplicationThread()) {
            try {
                return toRun.get();
            } catch (RuntimeException ex) {
                throw ex;
            } catch (ExecutionException ex) {
                if (ex.getCause() instanceof RuntimeException) {
                    throw (RuntimeException) ex.getCause();
                } else {
                    throw new RhomeoRuntimeException(ex.getCause());
                }
            } catch (Exception e) {
                throw new RhomeoRuntimeException(e);
            }
        } else
            return null;
    }

    /**
     * Try to open given file on system.
     * @param toOpen The file open on underlying system.
     * @return True if we succeeded opening file on system, false otherwise.
     */
    public static Task<Boolean> openFile(final Path toOpen) {
        return openFile(toOpen.toAbsolutePath().toFile());
    }

    /**
     * Try to open given file on system.
     * @param toOpen The file open on underlying system.
     * @return True if we succeeded opening file on system, false otherwise.
     */
    public static Task<Boolean> openFile(final File toOpen) {
        return TaskManager.INSTANCE.submit("Ouverture d'un fichier", () -> {
            try {
                final Desktop desktop = Desktop.getDesktop();
                if (desktop.isSupported(Desktop.Action.OPEN)) {
                    desktop.open(toOpen);
                } else if (desktop.isSupported(Desktop.Action.EDIT)) {
                    desktop.edit(toOpen);
                } else {
                    throw new IOException("Impossible de communiquer avec l'OS.");
                }
            } catch (IOException e) {
                /*
                 * HACK : on Windows, it seems that some file associations cannot be found
                 * using above method (Ex : .odt), so we try running following command as
                 * a fallback.
                 */
                if (PlatformUtil.isWindows()) {
                    Runtime.getRuntime().exec(new String[]{"cmd /c start", toOpen.toURI().toString()});
                } else {
                    throw e;
                }
            }
            return true;
        });
    }

    public static Task<Boolean> browse(final URL href) {
        return TaskManager.INSTANCE.submit("Ouverture d'un lien", () -> {
            final Desktop desktop = Desktop.getDesktop();
            if (desktop.isSupported(Desktop.Action.BROWSE))
                desktop.browse(href.toURI());
            else
                throw new IOException("Impossible de communiquer avec l'OS.");

            return true;
        });
    }

    public static Task<Boolean> mail(final String mailAdress) {
        return TaskManager.INSTANCE.submit("Ouverture d'un lien e-mail", () -> {
            final Desktop desktop = Desktop.getDesktop();
            if (desktop.isSupported(Desktop.Action.MAIL))
                desktop.mail(new URI(mailAdress.startsWith("mailto")? mailAdress : "mailto:".concat(mailAdress)));
            else
                throw new IOException("Impossible de communiquer avec l'OS.");

            return true;
        });
    }

    /**
     * Initializes an auto completable combo box, without selecting one.
     *
     * @param <I>
     * @param comboBox
     * @param items
     * @see #initComboBox(ComboBox, ObservableList, Object)
     */
    public static <I> void initComboBox(final ComboBox<I> comboBox, final ObservableList<I> items) {
        initComboBox(comboBox, items, null);
    }

    /**
     * Add items in the combo box and make it auto completable.
     *
     * @param comboBox A new combo box, not {@code null}.
     * @param items List of items to add.
     * @param itemSelected If not null, select this item in the list.
     * @param <I> item class
     */
    public static <I> void initComboBox(final ComboBox<I> comboBox, final ObservableList<I> items, final I itemSelected) {
        comboBox.setItems(items);
        try {
            comboBox.setEditable(true);
        } catch (RuntimeException re) {
            // A runtime exception is launched if the editable property is already bound
            // Just log and let the property bound and handled by something else.
            Rhomeo.LOGGER.log(Level.FINE, re.getLocalizedMessage(), re);
        }
        if (itemSelected != null) {
            comboBox.getSelectionModel().select(itemSelected);
        }
        ComboBoxCompletion.autocomplete(comboBox);
    }


    /**
     * Generates an alert of the given type with the given text and just an OK button.
     *
     * @param type {@linkplain Alert.AlertType alert type}.
     * @param text Text to display.
     */
    public static void showAlert(final Alert.AlertType type, final String text) {
        final Alert alert = new Alert(type, text, ButtonType.OK);
        alert.setResizable(true);
        alert.show();
    }

    /**
     * Get the previous path stored for reopening a file chooser in it.
     *
     * @param packageClass Class in a specific java package on which we want to attach
     *                     the path preference
     * @return the previous path stored with the last opening of the file chooser.
     */
    public static File getPreviousPath(final Class packageClass) {
        final Preferences prefs = Preferences.userNodeForPackage(packageClass);
        final String str = prefs.get("path", null);
        if (str != null) {
            final File file = new File(str);
            if (file.isDirectory()) {
                return file;
            }
        }
        return null;
    }

    /**
     * Set the path of the previously selected file into the file chooser.
     *
     * @param packageClass Class in a specific java package on which we want to attach
     *                     the path preference
     * @param path the path to store. Should point on a folder.
     */
    public static void setPreviousPath(final Class packageClass, final File path) {
        final Preferences prefs = Preferences.userNodeForPackage(packageClass);
        prefs.put("path", path.getAbsolutePath());
    }

    /**
     * Open modal popup asking for a file on the local path.
     *
     * @param initialFileName Initial file name to use.
     * @param owner The owner of this popup.
     * @param filters Filters to use. Possibly empty or null. The first item will be used as the default one.
     * @return The file wished, or {@code null} if the user cancelled the popup.
     */
    public static File askOutputFilePopup(final String initialFileName, final Node owner, final FileChooser.ExtensionFilter... filters) {
        final FileChooser fileChooser = new FileChooser();

        final File previousPath = getPreviousPath(ExportUtils.class);
        if(previousPath!=null && previousPath.exists()){
            fileChooser.setInitialDirectory(previousPath);
        }

        // Set some usefull filters
        if (filters != null && filters.length > 0) {
            fileChooser.getExtensionFilters().addAll(filters);
            fileChooser.setSelectedExtensionFilter(filters[0]);
        }

        if (initialFileName != null && !initialFileName.isEmpty()) {
            fileChooser.setInitialFileName(initialFileName);
        }

        final File outputFile = fileChooser.showSaveDialog(owner.getScene().getWindow());

        if(outputFile==null) return null;
        setPreviousPath(ExportUtils.class, outputFile.getParentFile());

        return outputFile;
    }

    /**
     * Try to find a combobox under given node scene tree.
     * @param source The node to search into.
     * @return The first found combobox, or nothing.
     */
    public static Optional<ComboBox> findComboBox(final Node source) {
        if (source instanceof ComboBox) {
            return Optional.of((ComboBox)source);
        } else if (source instanceof Parent) {
            final ObservableList<Node> nodes = ((Parent)source).getChildrenUnmodifiable();
            Optional<ComboBox> opt;
            for (final Node n : nodes) {
                opt = findComboBox(n);
                if (opt.isPresent())
                    return opt;
            }
        }

        return Optional.empty();
    }

    public static void prepareSpinners(final Node source) {
        if (source instanceof Spinner) {
            final Spinner spinner = (Spinner) source;
            spinner.getEditor().textProperty().removeListener(SPINNER_LISTENER);
            spinner.getEditor().textProperty().addListener(SPINNER_LISTENER);
            final SpinnerValueFactory valueFactory = spinner.getValueFactory();
            if (valueFactory instanceof SpinnerValueFactory.DoubleSpinnerValueFactory) {
                final SpinnerValueFactory.DoubleSpinnerValueFactory dvf = (SpinnerValueFactory.DoubleSpinnerValueFactory) valueFactory;
                dvf.setAmountToStepBy(0.01);
                dvf.setConverter(new SpinnerConverter(spinner));
                // HACK : To force display refresh immediately, we introduce temporary value change.
                final Double oldValue = dvf.getValue();
                if (oldValue == null || oldValue.isNaN() || oldValue.isInfinite())
                    dvf.setValue(0.0);
                else dvf.setValue(oldValue + 1);
                dvf.setValue(oldValue);
            }

        } else if (source instanceof Parent) {
            final ObservableList<Node> nodes = ((Parent)source).getChildrenUnmodifiable();
            for (final Node n : nodes) {
                prepareSpinners(n);
            }
        }
    }

    private static void spinnerTextChanged(final ObservableValue<? extends String> obs, final String oldValue, final String newValue) {
        if (obs instanceof ReadOnlyProperty) {
            final Object bean = ((ReadOnlyProperty)obs).getBean();
            if (bean instanceof Node) {
                Parent parent = ((Node) bean).getParent();
                if (parent instanceof Spinner) {
                    final Spinner spinner = (Spinner) parent;
                    try {
                        spinner.getValueFactory().setValue(spinner.getValueFactory().getConverter().fromString(newValue));
                    } catch (Exception e) {
                        Rhomeo.LOGGER.log(Level.FINE, "Cannot convert seized text to numeric value", e);
                    }
                }
            }
        }
    }

    /**
     * Check column and row indices to determine if we must edit next column or
     * next row. If next cell is not editable, we test the next one, etc.
     *
     * @param editingCell Cell currently edited. If null, this method does nothing.
     */
    public static void editNextCell(final TablePosition editingCell) {
        if (editingCell == null)
            return;
        final TableView<Object> tableView = editingCell.getTableView();
        TableColumn targetCol;
        int rowIndex = editingCell.getRow();
        int colIndex = editingCell.getColumn();
        final int maxRow = tableView.getItems().size() - 1;
        final int maxCol = tableView.getColumns().size() - 1;
        do {
            if (colIndex >= maxCol) {
                rowIndex++;
                colIndex = 0;
            } else {
                colIndex++;
            }

            if (rowIndex < 0 || rowIndex > maxRow) {
                targetCol = null;
            } else {
                targetCol = tableView.getColumns().get(colIndex);
            }

        } while (targetCol != null && !targetCol.isEditable());

        tableView.edit(rowIndex, targetCol);
    }

    public static Button createProtocolDocumentButton(final Protocol protocol) {
        final Button button = new Button("Fiche protocole "+ protocol.getName());
        button.setGraphic(new ImageView(ICON_FILE_PDF));
        button.getStyleClass().add("transparent");
        button.setOnAction(event -> {
            final String fileName = protocol.getName() + ".pdf";
            Session.getInstance().getBean(DocManager.class).openDocument(protocol.getClass().getResource(fileName));
        });
        return button;
    }

    public static Button createModelButton(final Protocol protocol) {
        final Button button = new Button("Format protocole "+ protocol.getRemarks() +" ("+ protocol.getName() +")");
        button.setGraphic(new ImageView(ICON_FILE_TEXT));
        button.getStyleClass().add("transparent");
        button.setOnAction(event -> generateModelZip(protocol, button));
        return button;
    }

    public static void generateModelZip(final Protocol protocol, final Node owner) {
        final FileChooser.ExtensionFilter zipFilter = new FileChooser.ExtensionFilter("Archive zip", "*.zip");
        final File outputFile = Rhomeo.askOutputFilePopup(protocol.getName() +".zip", owner, zipFilter);
        if (outputFile == null) {
            return;
        }

        final Path parentPath = Paths.get(outputFile.getParentFile().getAbsolutePath());
        final UUID uuid = UUID.randomUUID();
        final Path folderPath = parentPath.resolve(uuid.toString());

        final Class<? extends Statement> statementClass = protocol.getDataType();
        try {
            Files.createDirectory(folderPath);

            final Path formatPath = folderPath.resolve("format.csv");
            final CSVEncoder<? extends Statement> encoder = new CSVEncoder<>(formatPath, statementClass);
            encoder.encode(new ArrayList<>(), true);

            final Path readmePath = createReadMeFile(statementClass, folderPath);
            ZipUtilities.zip(outputFile.toPath(), ZipOutputStream.DEFLATED, 9, null, formatPath, readmePath);

            Files.deleteIfExists(formatPath);
            Files.deleteIfExists(readmePath);
            Files.deleteIfExists(folderPath);
        } catch (IOException | IntrospectionException ex) {
            Rhomeo.LOGGER.log(Level.INFO, "Cannot generate format file", ex);
        }
    }

    private static Path createReadMeFile(final Class<? extends Statement> statementClass, final Path outputFolderPath) throws IOException, IntrospectionException {
        final boolean bundleAvailable = InternationalResource.class.isAssignableFrom(statementClass);

        final Path formatPath = outputFolderPath.resolve("readme.txt");
        Files.createFile(formatPath);
        String description = null;
        try (final BufferedWriter bw = Files.newBufferedWriter(formatPath, StandardOpenOption.APPEND)) {
            final BeanInfo beanInfo = Introspector.getBeanInfo(statementClass, Object.class);
            for (final PropertyDescriptor desc : beanInfo.getPropertyDescriptors()) {
                if (bundleAvailable) {
                    try {
                        description = InternationalResource.getResourceString(statementClass, desc.getName(), "tooltip");
                    } catch (MissingResourceException ex) {
                        description = null;
                    }
                    if (description == null || description.isEmpty()) {
                        try {
                            description = InternationalResource.getResourceString(statementClass, desc.getName(), "label");
                        } catch (MissingResourceException ex) {
                            description = null;
                        }
                    }
                    bw.write(desc.getName());
                    bw.write("=");
                    if (description == null || description.isEmpty()) {
                        Rhomeo.LOGGER.log(Level.WARNING, "No resources for key " + desc.getName() + ".label or " + desc.getName() + ".tooltip in bundle for class "
                                + statementClass.getCanonicalName());
                    } else {
                        bw.write(description);
                    }
                    bw.newLine();
                }
            }

            // Check if additional content is available for the format readme.
            try (final InputStream in = statementClass.getResourceAsStream("readme.additional");
                    final InputStreamReader tmpReader = new InputStreamReader(in, StandardCharsets.UTF_8);
                    final BufferedReader reader = new BufferedReader(tmpReader)) {

                String line;
                while ((line = reader.readLine()) != null) {
                    bw.newLine();
                    bw.write(line);
                }

            } catch (NullPointerException e) {
                LOGGER.log(Level.FINE, "No additional text defined for data description.", e);
            }
        }
        return formatPath;
    }

    /**
     * A special string converter for Spinners. The aim is to avoid changes in
     * spinner's editor when value is updated, but matches editor text. Because
     * without any check, spinner will force editor text, which could bother
     * user seizing.
     */
    private static class SpinnerConverter extends StringConverter<Double> {

        private final Spinner target;

        public SpinnerConverter(Spinner target) {
            this.target = target;
        }

        @Override
        public String toString(Double object) {
            final String text = target.getEditor().getText();
            Object fromString = DOUBLE_CONVERTER.fromString(text);
            if ((fromString instanceof Number) && (target.getValue() instanceof Number) && ((Number)fromString).doubleValue() == ((Number)target.getValue()).doubleValue())
                return text;
            else return DOUBLE_CONVERTER.toString(object);
        }

        @Override
        public Double fromString(String string) {
            return (Double) DOUBLE_CONVERTER.fromString(string);
        }
    }
}
