/**
 * 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 com.vividsolutions.jts.geom.Geometry;
import fr.cenra.rhomeo.Rhomeo;
import fr.cenra.rhomeo.api.Version;
import fr.cenra.rhomeo.api.annotations.RefersTo;
import fr.cenra.rhomeo.api.data.Reference;
import fr.cenra.rhomeo.api.data.Warning;
import fr.cenra.rhomeo.core.RhomeoCore;
import fr.cenra.rhomeo.core.Session;
import fr.cenra.rhomeo.core.data.reference.Taxref;
import fr.cenra.rhomeo.core.util.RelationResolver;
import java.beans.PropertyDescriptor;
import java.lang.ref.WeakReference;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.logging.Level;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.beans.value.WritableValue;
import javafx.collections.MapChangeListener;
import javafx.event.EventHandler;
import javafx.scene.Node;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TablePosition;
import javafx.scene.control.TableView;
import javafx.scene.control.Tooltip;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Region;
import javafx.util.StringConverter;
import javax.validation.ConstraintViolation;
import javax.validation.Validator;
import org.apache.sis.parameter.ParameterBuilder;
import org.apache.sis.util.ArgumentChecks;
import org.apache.sis.util.Numbers;
import org.geotoolkit.font.FontAwesomeIcons;
import org.geotoolkit.gui.javafx.parameter.FXValueEditor;
import org.geotoolkit.gui.javafx.parameter.FXValueEditorSpi;
import org.geotoolkit.gui.javafx.util.ComboBoxCompletion;
import org.opengis.parameter.ParameterDescriptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;

/**
 * A column whose aim is to display and offer edition capabilities on a specific
 * POJO property. The display / edition capabilities are computed using reflection.
 *
 * @author Alexis Manin (Geomatys)
 * @param <T> Type of object found in a row of the parent table.
 */
@Component
@Scope("prototype")
public class PropertyColumn<T> extends TableColumn<T, Object> {

    static final KeyHandler KEY_HANDLER = new KeyHandler();

    static final StringConverter BASIC_CONVERTER = new BasicConverter();

    @Autowired
    private Validator validator;

    @Autowired
    RelationResolver resolver;

    /**
     * Descriptor of the property to manage in the column (property extracted
     * from a row item).
     */
    final PropertyDescriptor descriptor;
    /**
     * A boolean indicating if {@link #observableGetter} implements {@link WritableValue}
     * interface.
     */
    final boolean isWritableValue;
    /**
     * A method extracted from input {@link PropertyDescriptor} to write target
     * property.
     */
    final Method writeMethod;
    /**
     * A method extracted from input {@link PropertyDescriptor} to read target
     * property.
     */
    final Method readMethod;
    /**
     * A method with no parameter, whose aim is to send back an observable
     * object on target property.
     */
    final Method observableGetter;
    /**
     * An annotation extracted from getters to know if the property to manage
     * is referring to another object.
     */
    final RefersTo referent;

    public final SimpleObjectProperty<FXValueEditor> editorProperty = new SimpleObjectProperty<>();

    // Hack for issue #75
    final boolean isSpecies;

    /**
     * A weak bound to the converter used for transformation of column values
     * in text. We use weak reference here mainly because of application
     * {@link Reference} which are loaded asynchronously (user can change used
     * version, etc.). Also, it avoid to keep a (possibly) consequent memory
     * chunk when not needed.
     */
    private WeakReference<StringConverter> textConverterRef;

    public PropertyColumn(final PropertyDescriptor desc, final Method observableGetter) {
        super();
        ArgumentChecks.ensureNonNull("Property descriptor", desc);
        Session.getInstance().injectDependencies(this);
        descriptor = desc;
        writeMethod = desc.getWriteMethod();
        readMethod = desc.getReadMethod();
        this.observableGetter = observableGetter;
        /*
         * Analyze given parameters to determine how to handle cell content.
         */
        boolean tmpIsProperty = false;
        boolean isObservableValue = false;
        if (observableGetter != null) {
            Class<?> observableType = observableGetter.getReturnType();
            if (WritableValue.class.isAssignableFrom(observableType)) {
                tmpIsProperty = true;
            } else if (ObservableValue.class.isAssignableFrom(observableType)) {
                isObservableValue = true;
            }
        }

        isWritableValue = tmpIsProperty;

        if (isWritableValue || isObservableValue) {
            setCellValueFactory((param) -> {
                try {
                    return (ObservableValue) observableGetter.invoke(param.getValue());
                } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException ex) {
                    RhomeoCore.LOGGER.log(Level.WARNING, "Cannot acces observable property ".concat(desc.getName()), ex);
                    return null;
                }
            });

        } else if (readMethod != null) {
            readMethod.setAccessible(true);
            setCellValueFactory((param) -> {
                try {
                    return new SimpleObjectProperty<>(readMethod.invoke(param.getValue()));
                } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException ex) {
                    RhomeoCore.LOGGER.log(Level.WARNING, "Cannot acces property ".concat(desc.getName()), ex);
                    return null;
                }
            });

        } else {
            throw new IllegalArgumentException("Cannot extract values from given property descriptor !");
        }

        RefersTo tmpReferent = null;
        if (readMethod != null) {
            tmpReferent = readMethod.getAnnotation(RefersTo.class);
        }

        if (tmpReferent == null && observableGetter != null) {
            tmpReferent = observableGetter.getAnnotation(RefersTo.class);
        }
        referent = tmpReferent;

        // Hack #75
        isSpecies = referent == null? false : referent.property().equals(Taxref.ATT_CD_NOM);

        /* When editing a property pointing on a reference, we listen on data
         * context changes to update choice list when user change the version
         * used.
         */
        if (referent != null && Reference.class.isAssignableFrom(referent.type())) {
            try {
                Session.getInstance().getDataContext().getReferences().addListener((MapChangeListener.Change<? extends Class<Reference>, ? extends Version> change) -> {
                    if (referent.type().equals(change.getKey())) {
                        initEditor();
                    }
                });
            } catch (Exception e) {
                Rhomeo.LOGGER.log(Level.FINE, "Cannot listen on data context changes.", e);
            }
        }

        initEditor();

        setCellFactory(this::createPropertyCell);

        editableProperty().bind(editorProperty.isNotNull());
        setText(desc.getDisplayName());

        String remarks = desc.getShortDescription();
        if (remarks != null && !remarks.equals(desc.getDisplayName()) && !(remarks = remarks.trim()).isEmpty()) {
            final Label label = new Label(FontAwesomeIcons.ICON_INFO_CIRCLE);
            label.setTooltip(new Tooltip(remarks));
            label.setStyle("-fx-font-family: FontAwesome; -fx-font-size:16;");
            setGraphic(label);
        }

        // # 86 : Compare species using their name
        if (isSpecies) {
            setComparator((o1, o2) -> {
                if (o1 == o2) // both null or same reference
                    return 0;
                else if (o1 == null)
                    return 1;
                else if (o2 == null)
                    return -1;
                else if (o1.equals(o2))
                    return 0;

                final StringConverter converter = getOrCreateTextConverter();
                final String s1 = converter.toString(o1);
                final String s2 = converter.toString(o2);
                if (s1 == s2) // both null or same reference
                    return 0;
                else if (s1 == null)
                    return 1;
                else if (s2 == null)
                    return -1;

                return s1.compareTo(s2);
            });
        }
    }

    private void initEditor() {
        /* If there is writing possibility, we try to setup an editor for the
         * column cells.
         */
        if (isWritableValue || writeMethod != null) {
            Set possibleValues = null;
            if (referent != null) {
                try {
                    possibleValues = resolver.getPossibleValues(referent);
                } catch (Exception ex) {
                    RhomeoCore.LOGGER.log(Level.WARNING, "Cannot load choice list for property ".concat(descriptor.getName()), ex);
                }
            }

            final ParameterDescriptor parameterDescriptor;
            final ParameterBuilder builder = new ParameterBuilder().addName(descriptor.getName()).setRequired(false);
            if (possibleValues != null) {
                parameterDescriptor = builder.createEnumerated((Class) Numbers.primitiveToWrapper(descriptor.getPropertyType()), possibleValues.toArray(), null);
            } else {
                parameterDescriptor = builder.create(descriptor.getPropertyType(), null);
            }

            Optional<FXValueEditor> editor = FXValueEditorSpi.findEditor(parameterDescriptor);
            if (editor.isPresent()) {
                // If given editor is a combo box, we activate auto-completion on it.
                final FXValueEditor tmpEditor = editor.get();
                Optional<ComboBox> optCombo = Rhomeo.findComboBox(tmpEditor.getComponent());
                if (optCombo.isPresent()) {
                    ComboBox cBox = optCombo.get();
                    // See ComboBoxListViewSkin
                    cBox.getProperties().put("comboBoxRowsToMeasureWidth", 7);
                    setConverter(cBox);
                    try {
                        cBox.setEditable(true);
                    } catch (RuntimeException e) {
                        // 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, e.getLocalizedMessage(), e);
                    }
                    ComboBoxCompletion.autocomplete(cBox);
                    // HACK : Force auto-completion to reset when starting edition.
                    cBox.focusedProperty().addListener((obs, oldVal, newVal) -> {
                        if (newVal)
                            cBox.fireEvent(new KeyEvent(KeyEvent.KEY_RELEASED, "", "", KeyCode.UNDEFINED, false, false, false, false));
                    });
                } else {
                    Rhomeo.prepareSpinners(tmpEditor.getComponent());
                }
                editorProperty.set(tmpEditor);
            }
        }
    }

    private PropertyCell createPropertyCell(TableColumn<T, Object> column) {
        return new PropertyCell();
    }

    public PropertyDescriptor getTarget() {
        return descriptor;
    }

    private void setConverter(ComboBox cBox) {
        final StringConverter baseConverter = getOrCreateTextConverter();
        final StringConverter converter = new StringConverter() {
            @Override
            public String toString(Object object) {
                return baseConverter.toString(object);
            }

            @Override
            public Object fromString(String string) {
                if (string == null || string.isEmpty())
                    return null;
                final Object firstGuess = baseConverter.fromString(string);
                if (firstGuess == null) {
                    for (final Object o : cBox.getItems()) {
                        if (Objects.equals(string, o.toString()))
                            return o;
                    }
                }

                return null;
            }
        };

        cBox.setConverter(converter);
    }

    /**
     * Create a converter for the elements of this column.
     * @return A converter to display this column elements as text. Never null.
     */
    private StringConverter getOrCreateTextConverter() {
        StringConverter converter = textConverterRef == null? null : textConverterRef.get();
        if (converter != null)
            return converter;

        if (referent != null) {
            try {
                final Set targets = resolver.getTargetObjects(referent);
                final Function extractor = RelationResolver.acquireExtractor(referent);
                final HashMap<Object, String> refMap = new HashMap<>(targets.size());
                for (final Object o : targets) {
                    refMap.put(extractor.apply(o), o.toString());
                }

                converter = new StringConverter() {
                    @Override
                    public String toString(Object object) {
                        if (object == null || (isSpecies && object.equals(0)))
                            return null;
                        final String str = refMap.get(object);
                        if (str != null)
                            return str;
                        return BASIC_CONVERTER.toString(object);
                    }

                    @Override
                    public Object fromString(String string) {
                        if (string == null || string.isEmpty())
                            return null;

                        for (final Map.Entry<Object, String> entry : refMap.entrySet()) {
                            if (string.equals(entry.getValue())) {
                                return entry.getKey();
                            }
                        }

                        return BASIC_CONVERTER.fromString(string);
                    }
                };
            } catch (Exception e) {
                Rhomeo.LOGGER.log(Level.FINE, "Cannot tune combobox cells.", e);
            }
        }

        if (converter == null) {
            return BASIC_CONVERTER;
        } else {
            textConverterRef = new WeakReference<>(converter);
            return converter;
        }
    }

    /**
     * A cell for rendering and edition of a property.
     */
    private class PropertyCell extends AutoCommitableTableCell<T, Object> {

        /**
         * A boolean to know if we're in edition state or not. We store a local
         * variable here, because when calling {@link TableCell#startEdit() },
         * the edition flag is updated AFTER item update. If we do not want to
         * update the view twice on state change, we have to locally store edition
         * state, then calling super.startEdit().
         */
        boolean edition = false;

        EventHandler<MouseEvent> enteredOnError;

        final ChangeListener<Boolean> focusListener = this::focusChanged;

        @Override
        public void cancelEdit() {
            edition = false;
            super.cancelEdit();
            updateItem(getTableColumn().getCellData(getTableRow().getIndex()), false);
            removeKeyHandling();
        }

        @Override
        public void commitEdit() {
            commitEdit(editorProperty.get().valueProperty().getValue());
        }

        @Override
        public void commitEdit(Object newValue) {
            if (isWritableValue) {
                WritableValue property = (WritableValue) getTableColumn().getCellObservableValue(getTableRow().getIndex());
                property.setValue(newValue);
            } else {
                try {
                    writeMethod.invoke(getTableRow().getItem(), newValue);
                } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException ex) {
                    RhomeoCore.LOGGER.log(Level.WARNING, "Cannot commit value !", ex);
                }
            }

            edition = false;
            super.commitEdit(newValue);
            removeKeyHandling();
            editorProperty.get().getComponent().removeEventHandler(KeyEvent.KEY_RELEASED, KEY_HANDLER);
        }

        @Override
        public void startEdit() {
            edition = true; // Set to true for view update.
            super.startEdit();
            if (!(edition = isEditing()))
                return;

            final FXValueEditor editor = editorProperty.get();
            if (editor == null ||
                    !(isWritableValue || writeMethod != null)) {
                throw new IllegalStateException("Cannot start edition on not editable cell.");
            }

            // Init editor value from cell content.
            editor.valueProperty().setValue(getItem());

            addKeyHandling();
        }

        @Override
        protected void updateItem(Object item, boolean empty) {
            super.updateItem(item, empty);

            if (edition && !empty) {
                setText(null);
                Node node = editorProperty.get().getComponent();
                if (node instanceof Region) {
                    final Region r = (Region) node;
                    r.setMinSize(0, 0);
                    r.prefWidthProperty().bind(prefWidthProperty());
                    r.prefHeightProperty().bind(prefHeightProperty());
                }
                setGraphic(node);
                Optional<ComboBox> opt = Rhomeo.findComboBox(node);
                if (opt.isPresent()) {
                    node = opt.get();
                }

                node.requestFocus();
                node.focusedProperty().addListener(focusListener);

            } else {
                removeGraphic();
                if (empty) {
                    setText(null);
                } else {
                    setText(getOrCreateTextConverter().toString(item));
                }
            }

            checkValue(item, empty);
        }

        private void focusChanged(final ObservableValue<? extends Boolean> obs, final Boolean oldValue, final boolean newValue) {
            if (edition && !newValue) {
                commitEdit();
            }
        }

        /**
         * Remove the graphic attached to the cell, freeing its previously bound
         * properties in the same time.
         */
        private void removeGraphic() {
            Node graphic = getGraphic();
            if (graphic != null) {
                if (graphic instanceof Region) {
                    final Region r = (Region) graphic;
                    r.prefWidthProperty().unbind();
                    r.prefHeightProperty().unbind();
                }

                Optional<ComboBox> opt = Rhomeo.findComboBox(graphic);
                if (opt.isPresent()) {
                    graphic = opt.get();
                }
                graphic.focusedProperty().removeListener(focusListener);
            }
            setGraphic(null);
        }

        public void checkValue(final Object value, final boolean isEmpty) {
            final Object rowItem;
            if (isEmpty || getTableRow() == null) {
                rowItem = null;
            } else {
                rowItem = getTableRow().getItem();
            }

            final Set<ConstraintViolation> result;
            if (rowItem == null) { // Empty cell
                result = Collections.EMPTY_SET;
            } else {
                result = validator.validateValue((Class) rowItem.getClass(),
                        PropertyColumn.this.descriptor.getName(),
                        value
                );
            }

            if (!result.isEmpty()) {
                int warningNumber = 0;
                for (final ConstraintViolation cv : result) {
                    if (cv.getConstraintDescriptor().getPayload().contains(Warning.class))
                        warningNumber++;
                }

                if (warningNumber >= result.size()) {
                    if (!getStyleClass().contains(Rhomeo.CSS_WARNING_FIELD)) {
                        getStyleClass().add(Rhomeo.CSS_WARNING_FIELD);
                    }
                } else if (!getStyleClass().contains(Rhomeo.CSS_ERROR_FIELD)) {
                    getStyleClass().add(Rhomeo.CSS_ERROR_FIELD);
                }

                // Activate a tootltip to give user information about what happens.
                createErrorTooltip(result).ifPresent(tip -> setTooltip(tip));
            } else {
                setTooltip(null);
                getStyleClass().removeAll(Rhomeo.CSS_ERROR_FIELD, Rhomeo.CSS_WARNING_FIELD);
            }
        }

        /**
         * Create a tooltip listing error message of input violations.
         * @param errors Errors to display.
         * @return An empty optional if input list was null or empty. A tooltip
         * with input error messages otherwise.
         */
        private Optional<Tooltip> createErrorTooltip(final Collection<ConstraintViolation> errors) {
            if (errors == null || errors.isEmpty())
                return Optional.empty();
            final String text;
            if (errors.size() == 1) {
                text = errors.iterator().next().getMessage();
            } else {
                final Iterator<ConstraintViolation> it = errors.iterator();
                final StringBuilder builder = new StringBuilder("∙ ");
                if (it.hasNext())
                    builder.append(it.next().getMessage());
                while (it.hasNext()) {
                    builder.append(System.lineSeparator()).append("∙ ").append(it.next().getMessage());
                }
                text = builder.toString();
            }

            final Tooltip tip = new Tooltip(text);
            tip.getStyleClass().add("info-popup");

            return Optional.of(tip);
        }

        /**
         * Set the {@link #KEY_HANDLER} to listen on key events on the given
         * cell. The event listener is meant to listen for actions on a cell
         * being in edition mode.
         *
         * Note : If the handler was already listening on another cell, its
         * unregistered before setting input cell.
         *
         * @param target The table cell to activate Key event handling on.
         */
        private void addKeyHandling() {
            if (KEY_HANDLER.target != null) {
                removeKeyHandling();
            }

            KEY_HANDLER.target = this;
            addEventHandler(KeyEvent.KEY_RELEASED, KEY_HANDLER);
            addEventFilter(KeyEvent.ANY, KEY_HANDLER);
            editorProperty.get().getComponent().addEventHandler(KeyEvent.KEY_RELEASED, KEY_HANDLER);
        }

        /**
         * Set the {@link #KEY_HANDLER} to stop listening on given cell.
         *
         * @param target The cell to deactivate key event handling for.
         */
        private void removeKeyHandling() {
            if (KEY_HANDLER != null && KEY_HANDLER.target != null) {
                KEY_HANDLER.target.removeEventHandler(KeyEvent.KEY_RELEASED, KEY_HANDLER);
                KEY_HANDLER.target.removeEventFilter(KeyEvent.ANY, KEY_HANDLER);
                editorProperty.get().getComponent().removeEventHandler(KeyEvent.KEY_RELEASED, KEY_HANDLER);
                KEY_HANDLER.target = null;
            }
        }
    }

    /**
     * Listens on keyboard key release on a given table cell. Default listeners are :
     * - ENTER : commit edit
     * - TAB : commit edit then edit next column. If no more column is available,
     * start editing first column of the next row.
     * - ESC : cancel edit.
     */
    static class KeyHandler implements EventHandler<KeyEvent> {

        AutoCommitableTableCell target;

        @Override
        public void handle(KeyEvent evt) {
            if (target == null)
                return;

            if (KeyEvent.KEY_RELEASED.equals(evt.getEventType())) {
                switch (evt.getCode()) {
                    case ENTER:
                        target.commitEdit();
                        evt.consume();
                        break;
                    case TAB:
                        final TableView tableView = target.getTableView();
                        TablePosition currentCell = tableView.getEditingCell();
                        if (currentCell == null)
                            currentCell = new TablePosition(tableView, 0, null);
                        target.commitEdit();
                        Rhomeo.editNextCell(currentCell);
                        evt.consume();
                        break;
                    case ESCAPE:
                        target.cancelEdit();
                        evt.consume();
                }
            } else {
                // Filter events
                switch (evt.getCode()) {
                    case TAB:
                        evt.consume();
                }
            }
        }
    }

    private static class BasicConverter extends StringConverter<Object> {

        @Override
        public String toString(Object object) {
            if (object instanceof Geometry) {
                return "Objet trop complexe pour affichage";
            } else if (object != null && Numbers.isFloat(object.getClass())) {
                return RhomeoCore.SECOND_DECIMAL_FORMAT.format(object);
            } else {
                return String.valueOf(object);
            }
        }

        @Override
        public Object fromString(String string) {
            return null;
        }
    }
}
