/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.sis.gui.dataset;

import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.LinkedHashMap;
import java.util.Collection;
import javafx.util.Callback;
import javafx.event.EventHandler;
import javafx.scene.input.MouseEvent;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.text.TextAlignment;
import javafx.collections.ListChangeListener;
import javafx.collections.transformation.TransformationList;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import org.apache.sis.util.internal.shared.UnmodifiableArrayList;
import org.apache.sis.gui.internal.Styles;

// Specific to the main branch:
import org.apache.sis.feature.AbstractFeature;


/**
 * Wraps a {@link FeatureList} with the capability to expand the multi-valued properties of
 * a selected {@code Feature}. The expansion appears as additional rows below the feature.
 * This view is used only if the feature type contains at least one property type with a
 * maximum number of occurrence greater than 1.
 *
 * @author  Martin Desruisseaux (Geomatys)
 */
final class ExpandableList extends TransformationList<AbstractFeature,AbstractFeature>
        implements Callback<TableColumn<AbstractFeature,AbstractFeature>, TableCell<AbstractFeature,AbstractFeature>>,
                   EventHandler<MouseEvent>
{
    /**
     * The background of {@linkplain #expansion} rows header.
     */
    private static final Background EXPANSION_HEADER =
            new Background(new BackgroundFill(Styles.EXPANDED_ROW, null, null));

    /**
     * The icon for rows that can be expanded.
     * Set to the Unicode supplementary character U+1F5C7 "Empty Note Pad".
     */
    private static final String EXPANDABLE = "\uD83D\uDDC7";

    /**
     * The icon for rows that are expanded.
     * Set to the Unicode supplementary character U+1F5CA "Note Pad".
     */
    private static final String EXPANDED = "\uD83D\uDDCA";

    /**
     * Mapping from property names to index in the {@link ExpandedFeature#values} array.
     * This map is shared by all {@link ExpandedFeature} instances contained in this list.
     * It shall be modified only if this list has been cleared first.
     */
    private final Map<String,Integer> nameToIndex;

    /**
     * View index of the first row of the currently expanded feature, {@link Integer#MAX_VALUE} if none.
     * Expansion rows will be added or removed <em>below</em> this row; this row itself will not be removed.
     */
    private int indexOfExpanded;

    /**
     * The rows of the expanded feature, or {@code null} if none. If non-null, this array shall never be empty.
     * The reason why we do not allow empty arrays is because we will insert {@code expansion.length - 1} rows
     * below the expanded feature. Consequently, empty arrays cause negative indices that are more difficult to
     * debug than {@link NullPointerException}, because they happen later.
     *
     * <p>If non-null, they array should have at least 2 elements. An array of only 1 element is not wrong,
     * but is useless since it has no additional rows to show below the first row.</p>
     */
    private ExpandedFeature[] expansion;

    /**
     * Creates a new expandable list for the given features.
     *
     * @param  features  the {@link FeatureList} list to wrap.
     */
    ExpandableList(final FeatureList features) {
        super(features);
        nameToIndex     = new LinkedHashMap<>();
        indexOfExpanded = Integer.MAX_VALUE;
    }

    /**
     * Specifies the names of properties that may be multi-valued. This method needs to be invoked
     * only if the {@code FeatureType} changed. This method shall not be invoked if there is any
     * {@link #expansion} rows. Normally this list will be empty at invocation time.
     *
     * @param  columnNames  names of properties that may contain multi-values.
     */
    final void setMultivaluedColumns(final List<String> columnNames) {
        assert expansion == null : indexOfExpanded;
        nameToIndex.clear();
        final int size = columnNames.size();
        for (int i=0; i<size; i++) {
            nameToIndex.putIfAbsent(columnNames.get(i), i);
        }
    }

    /**
     * Removes the expanded rows. This method does not fire change event;
     * it is caller's responsibility to perform those tasks.
     *
     * <h4>Design note</h4>
     * We return {@code null} instead of an empty list if
     * there are no removed elements because we want to force callers to perform a null check.
     * The reason is that if there was no expansion rows, then {@link #indexOfExpanded} has an
     * invalid value and using that value in {@link #nextRemove(int, List)} may be dangerous.
     * A {@link NullPointerException} would intercept that error sooner.
     *
     * @return the removed rows, or {@code null} if none.
     */
    private List<AbstractFeature> shrink() {
        final List<AbstractFeature> removed = (expansion == null) ? null
                                    : UnmodifiableArrayList.wrap(expansion, 1, expansion.length);
        expansion       = null;
        indexOfExpanded = Integer.MAX_VALUE;
        return removed;
    }

    /**
     * Clears all elements from this list. This method removes the expanded rows before to
     * remove the rest of the list because otherwise, the {@code sourceChanged(…)} method in
     * this class would have to expand the whole feature list for inserting removed elements.
     */
    @Override
    public void clear() {
        final int removeAfter = indexOfExpanded;
        final List<AbstractFeature> removed = shrink();
        if (removed != null) {
            beginChange();
            nextUpdate(removeAfter);
            nextRemove(removeAfter + 1, removed);
            endChange();
        }
        getSource().clear();
    }

    /**
     * Invoked when user clicked on the icon on the left of a row.
     * The method sets the expanded rows to the ones containing the clicked cell.
     * If that row is the currently expanded one, then it will be reduced to a single row.
     */
    @Override
    public void handle(final MouseEvent event) {
        /*
         * Remove the additional rows from this list. Before doing so, we need to remember
         * what we are removing from this list view in order to send notification later.
         */
        final IconCell cell = (IconCell) event.getSource();
        final int index = getSourceIndex(cell.getIndex());      // Must be invoked before `shrink()`.
        final int removeAfter = indexOfExpanded;
        final List<AbstractFeature> removed = shrink();
//      index = getViewIndex(index);                // Not needed for current single-selection model.
        /*
         * If a new row is selected, extract now all properties. We need at least the number
         * of properties anyway for determining the number of additional rows.  But we store
         * also the property values in arrays for convenience because we cannot use indices
         * on arbitrary collections (they may not be lists).  This is okay on the assumption
         * that the number of elements is not large.
         */
        if (index != indexOfExpanded) {
            expansion = ExpandedFeature.create(cell.getItem(), nameToIndex);
            if (expansion != null) {
                indexOfExpanded = index;
                final int limit = Integer.MAX_VALUE - getSource().size();
                if (expansion.length > limit) {
                    if (limit > 1) {
                        expansion = Arrays.copyOf(expansion, limit);    // Drop last rows for avoiding integer overflow.
                    } else {
                        expansion = null;                               // Drop completely for avoiding integer overflow.
                        indexOfExpanded = Integer.MAX_VALUE;
                    }
                }
            }
        }
        /*
         * Send change notifications only after all states have been updated.
         */
        beginChange();
        if (removed != null) {
            nextUpdate(removeAfter);
            nextRemove(removeAfter + 1, removed);
        }
        if (expansion != null) {
            // An ArithmeticException below would be a bug in above limit adjustment.
            nextAdd(indexOfExpanded + 1, Math.addExact(indexOfExpanded, expansion.length));
        }
        endChange();
    }

    /**
     * Returns {@code true} if the given feature contains more than one row.
     */
    private boolean isExpandable(final AbstractFeature feature) {
        if (feature != null) {
            for (final String name : nameToIndex.keySet()) {
                final Object value = feature.getPropertyValue(name);
                if (value instanceof Collection<?>) {
                    final int size = ((Collection<?>) value).size();
                    if (size >= 2) {
                        return true;
                    }
                }
            }
        }
        return false;
    }

    /**
     * Returns the number of elements in this list.
     */
    @Override
    public int size() {
        int size = getSource().size();
        if (size != 0 && expansion != null) {
            size += expansion.length - 1;
        }
        return size;
    }

    /**
     * Returns the feature at the given index. This method forwards the request to the source,
     * except if the given index is for an expanded row.
     */
    @Override
    public AbstractFeature get(int index) {
        final int i = index - indexOfExpanded;
        if (i >= 0) {
            final int n = expansion.length;     // A NullPointerException here would be an ExpandableList bug.
            if (i < n) return expansion[i];
            index -= n;
        }
        return getSource().get(index);
    }

    /**
     * Given an index in this expanded list, returns the index of corresponding element in the feature list.
     * All indices from {@link #indexOfExpanded} inclusive to <code>{@linkplain #indexOfExpanded} +
     * {@linkplain #expansion}.length</code> exclusive map to the same {@link AbstractFeature} instance.
     *
     * @param  index  index in this expandable list.
     * @return index of the corresponding element in {@link FeatureList}.
     */
    @Override
    public int getSourceIndex(int index) {
        if (index > indexOfExpanded) {
            index = Math.max(indexOfExpanded, index - (expansion.length - 1));
            // A NullPointerException above would be an ExpandableList bug.
        }
        return index;
    }

    /**
     * Given an index in the feature list, returns the index in this expandable list.
     * If the given index maps the expanded feature, then the returned index will be
     * for the first row. This is okay if the lower index inclusive or for upper index
     * <em>exclusive</em> (it would not be okay for upper index inclusive).
     *
     * @param  index  index in the wrapped {@link FeatureList}.
     * @return index of the corresponding element in this list.
     */
    @Override
    public int getViewIndex(int index) {
        if (index > indexOfExpanded) {
            index += expansion.length - 1;
            // A NullPointerException above would be an ExpandableList bug.
        }
        return index;
    }

    /**
     * Notifies all listeners that the list changed. This method expects an event from the wrapped
     * {@link FeatureList} and converts source indices to indices of this expandable list.
     */
    @Override
    protected void sourceChanged(final ListChangeListener.Change<? extends AbstractFeature> c) {
        fireChange(new ListChangeListener.Change<AbstractFeature>(this) {
            @Override public void     reset()               {c.reset();}
            @Override public boolean  next()                {return c.next();}
            @Override public boolean  wasAdded()            {return c.wasAdded();}
            @Override public boolean  wasRemoved()          {return c.wasRemoved();}
            @Override public boolean  wasReplaced()         {return c.wasReplaced();}
            @Override public boolean  wasUpdated()          {return c.wasUpdated();}
            @Override public boolean  wasPermutated()       {return c.wasPermutated();}
            @Override protected int[] getPermutation()      {return null;}  // Not invoked since we override the method below.
            @Override public    int   getPermutation(int i) {return getViewIndex(c.getPermutation(getSourceIndex(i)));}
            @Override public    int   getFrom()             {return getViewIndex(c.getFrom());}
            @Override public    int   getTo() {
                // If remove only, must be where removed elements were positioned in the list.
                return (wasAdded() || !wasRemoved()) ? getViewIndex(c.getTo()) : getFrom();
            }

            @Override
            public int getRemovedSize() {
                int removedSize = c.getRemovedSize();
                if (overlapExpanded(c.getFrom(), removedSize)) {
                    removedSize += expansion.length - 1;
                }
                return removedSize;
            }

            @Override
            @SuppressWarnings("unchecked")
            public List<AbstractFeature> getRemoved() {
                return (List<AbstractFeature>) expandRemoved(c.getFrom(), c.getRemoved());
            }
        });
    }

    /**
     * Returns {@code true} if the given range of removed rows overlaps the expanded rows.
     */
    private boolean overlapExpanded(final int sourceFrom, final int removedSize) {
        return (sourceFrom <= indexOfExpanded && sourceFrom > indexOfExpanded - removedSize);   // Use - for avoiding overflow.
    }

    /**
     * If the range of removed elements overlaps the range of expanded rows, inserts values in the
     * {@code removed} list for the expanded rows. Actually this insertion should never happens in
     * the way we use {@link ExpandableList}, but we check as a safety.
     *
     * @param  sourceFrom  index of the first removed element in the source list.
     * @param  removed     the removed elements provided by the {@link FeatureList}.
     * @return the removed elements as seen by this {@code ExpandableList}.
     */
    private List<? extends AbstractFeature> expandRemoved(final int sourceFrom, final List<? extends AbstractFeature> removed) {
        if (!overlapExpanded(sourceFrom, removed.size())) {
            return removed;
        }
        final int s = indexOfExpanded;
        final int n = expansion.length;         // A NullPointerException here would be an ExpandableList bug.
        final AbstractFeature[] features = removed.toArray(new AbstractFeature[removed.size() + (n - 1)]);
        System.arraycopy(features,  s+1, features, s + n, features.length - (s+1));
        System.arraycopy(expansion, 0,   features, s,  n);
        return Arrays.asList(features);
    }

    /**
     * Creates a new cell for an icon to show at the beginning of a row.
     * This method is provided for allowing {@code ExpandableList} to be
     * given to {@link TableColumn#setCellFactory(Callback)}.
     *
     * @param  column  the column where the cell will be shown.
     */
    @Override
    public TableCell<AbstractFeature,AbstractFeature> call(final TableColumn<AbstractFeature,AbstractFeature> column) {
        return new IconCell();
    }

    /**
     * The cell which represents whether a row is expandable or expanded.
     * If visible, this is the first column in the table.
     */
    private final class IconCell extends TableCell<AbstractFeature,AbstractFeature>  {
        /**
         * Whether this cell is listening to mouse click events.
         */
        private boolean isListening;

        /**
         * Creates a new cell for feature property value.
         */
        IconCell() {
            setTextAlignment(TextAlignment.CENTER);
        }

        /**
         * Invoked when a new feature needs to be show. This method sets an icon depending on
         * whether there is multi-valued properties, and whether the current row is expanded.
         * The call will have a listener only if it has an icon.
         */
        @Override
        protected void updateItem(final AbstractFeature value, final boolean empty) {
            super.updateItem(value, empty);
            Background b = null;
            String  text = null;
            if (value instanceof ExpandedFeature) {
                /*
                 * If this is the selected row, put an icon only on the first row,
                 * not on additional rows showing the other collection elements.
                 */
                if (((ExpandedFeature) value).index == 0) {
                    text = EXPANDED;
                } else {
                    b = EXPANSION_HEADER;
                }
            } else if (isExpandable(value)) {
                text = EXPANDABLE;
            }
            setBackground(b);
            setText(text);
            if (isListening != (isListening = (text != null))) {        // Check whether `isListening` changed.
                if (isListening) {
                    addEventFilter(MouseEvent.MOUSE_CLICKED, ExpandableList.this);
                } else {
                    removeEventFilter(MouseEvent.MOUSE_CLICKED, ExpandableList.this);
                }
            }
        }
    }
}
