/*******************************************************************************
 * Copyright (c) 2014, 2020 Dirk Fauth and others.
 *
 * This program and the accompanying materials are made
 * available under the terms of the Eclipse Public License 2.0
 * which is available at https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *     Dirk Fauth <dirk.fauth@googlemail.com> - initial API and implementation
 *     Dirk Fauth <dirk.fauth@googlemail.com> - Bug 459422
 *     Evan O'Connell <oconn.e@gmail.com> - Bug 460640
 ******************************************************************************/
package org.eclipse.nebula.widgets.nattable.extension.glazedlists.groupBy;

import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

import org.eclipse.nebula.widgets.nattable.config.DefaultComparator;
import org.eclipse.nebula.widgets.nattable.data.IColumnAccessor;
import org.eclipse.nebula.widgets.nattable.extension.glazedlists.groupBy.summary.IGroupBySummaryProvider;
import org.eclipse.nebula.widgets.nattable.layer.IUniqueIndexLayer;
import org.eclipse.nebula.widgets.nattable.layer.LabelStack;
import org.eclipse.nebula.widgets.nattable.layer.cell.ILayerCell;
import org.eclipse.nebula.widgets.nattable.sort.ISortModel;
import org.eclipse.nebula.widgets.nattable.sort.SortDirectionEnum;
import org.eclipse.nebula.widgets.nattable.tree.TreeLayer;

import ca.odell.glazedlists.TreeList;

/**
 * {@link Comparator} that is used to sort the {@link TreeList} based on the
 * groupBy information. Necessary for building the tree structure correctly.
 *
 * @param <T>
 *            The type of the base objects carried in the TreeList
 *
 * @see GroupByTreeFormat
 */
public class GroupByComparator<T> implements IGroupByComparator<T> {

    protected final GroupByModel groupByModel;
    protected final IColumnAccessor<T> columnAccessor;

    protected ISortModel sortModel;
    protected IUniqueIndexLayer treeLayer;

    private GroupByDataLayer<T> dataLayer;

    /**
     * Cache that is used to increase the performance on sorting. The
     * information whether a column is a summary column is only retrieved once
     * and not calculated everytime.
     */
    protected Map<Integer, Boolean> summaryColumnCache = new HashMap<>();

    /**
     * Cache that is used to increase the performance on sorting by summary
     * values. Necessary because the summary value is not carried by the
     * {@link GroupByObject} but retrieved from the {@link GroupByDataLayer}.
     * Since the retrieval is done via row index rather than the
     * {@link GroupByObject}, the row index needs to be calculated in order to
     * get the correct values. This is done via {@link List#indexOf(Object)}
     * which is quite time consuming. So this cache is used to reduce the amount
     * of indexOf calls drastically.
     * <p>
     * As this cache is only used to increase the performance on sorting, the
     * life time of this cache is reduced to a sort operation. It therefore gets
     * cleared on every structural change.
     * </p>
     */
    protected Map<GroupByObject, GroupByObjectValueCache> groupByObjectComparatorCache = new HashMap<>();

    /**
     *
     * @param groupByModel
     *            The {@link GroupByModel} necessary to retrieve information
     *            about the current groupBy state.
     * @param columnAccessor
     *            The {@link IColumnAccessor} necessary to retrieve the column
     *            values of elements.
     */
    public GroupByComparator(GroupByModel groupByModel, IColumnAccessor<T> columnAccessor) {
        this.groupByModel = groupByModel;
        this.columnAccessor = columnAccessor;
    }

    /**
     *
     * @param groupByModel
     *            The {@link GroupByModel} necessary to retrieve information
     *            about the current groupBy state.
     * @param columnAccessor
     *            The {@link IColumnAccessor} necessary to retrieve the column
     *            values of elements.
     * @param dataLayer
     *            The {@link GroupByDataLayer} that should be used to retrieve
     *            groupBy summary values for sorting the tree structure. Can be
     *            <code>null</code> to avoid retrieving and inspecting summary
     *            values on sorting.
     */
    public GroupByComparator(GroupByModel groupByModel, IColumnAccessor<T> columnAccessor, GroupByDataLayer<T> dataLayer) {
        this.groupByModel = groupByModel;
        this.columnAccessor = columnAccessor;
        this.dataLayer = dataLayer;
    }

    @SuppressWarnings({ "rawtypes", "unchecked" })
    @Override
    public int compare(Object o1, Object o2) {
        if (o1 == null) {
            if (o2 == null) {
                return 0;
            } else {
                return -1;
            }
        } else if (o2 == null) {
            return 1;
        } else {
            int result = 0;
            for (int columnIndex : this.groupByModel.getGroupByColumnIndexes()) {
                if (o1 instanceof GroupByObject && o2 instanceof GroupByObject) {
                    // handle GroupByObject comparison
                    GroupByObject g1 = (GroupByObject) o1;
                    GroupByObject g2 = (GroupByObject) o2;

                    // get column index for groupBy value
                    // we assume the descriptor is an ordered map, therefore we
                    // choose the last element in the map
                    int groupByIndex = columnIndex;
                    for (Map.Entry<Integer, Object> entry : g1.getDescriptor().entrySet()) {
                        groupByIndex = entry.getKey();
                    }

                    Comparator comparator = getComparator(groupByIndex);
                    result = g1.getDescriptor().size() - g2.getDescriptor().size();
                    if (result == 0) {
                        if (this.sortModel != null
                                && this.sortModel.getSortedColumnIndexes() != null
                                && !this.sortModel.getSortedColumnIndexes().isEmpty()) {

                            for (int sortColumnIndex : this.sortModel.getSortedColumnIndexes()) {

                                Boolean summaryColumn = isSummaryColumn(g1, sortColumnIndex);
                                if (summaryColumn == null) {
                                    // in case we were not able to retrieve the
                                    // information for the one GroupByObject we
                                    // try to find the information the other
                                    // GroupByObject
                                    summaryColumn = isSummaryColumn(g2, sortColumnIndex);
                                }
                                if (summaryColumn != null && summaryColumn) {
                                    // compare GroupByObjects by summary
                                    // value
                                    Object sumValue1 = getSummaryValueFromCache(g1, sortColumnIndex);
                                    Object sumValue2 = getSummaryValueFromCache(g2, sortColumnIndex);
                                    result = getComparator(sortColumnIndex).compare(sumValue1, sumValue2);
                                }

                                if (result == 0) {
                                    result = comparator.compare(g1.getValue(), g2.getValue());
                                }

                                if ((isTreeColumn(sortColumnIndex) || (summaryColumn != null && summaryColumn))
                                        && this.sortModel.getSortDirection(sortColumnIndex).equals(SortDirectionEnum.DESC)) {

                                    result = result * -1;
                                }
                            }

                        } else {
                            result = comparator.compare(g1.getValue(), g2.getValue());
                        }

                        return result;
                    }
                } else if (o1 instanceof GroupByObject
                        && !(o2 instanceof GroupByObject)) {
                    result = 1;
                } else if (!(o1 instanceof GroupByObject)
                        && o2 instanceof GroupByObject) {
                    result = -1;
                } else {
                    // both values are not a GroupByObject so we need to sort by
                    // value to ensure the correct ordering for the tree
                    // structure
                    Object value1 = this.columnAccessor.getDataValue((T) o1, columnIndex);
                    Object value2 = this.columnAccessor.getDataValue((T) o2, columnIndex);

                    result = getComparator(columnIndex).compare(value1, value2);
                }

                if (result != 0) {
                    return result;
                }
            }
        }

        return 0;
    }

    /**
     *
     * @param columnIndex
     *            The column index of the column that should be checked.
     * @return <code>true</code> if the column at the given index is the tree
     *         column, <code>false</code> if not or if no treeLayer reference is
     *         set to this {@link GroupByComparator}
     */
    protected boolean isTreeColumn(int columnIndex) {
        if (this.treeLayer != null) {
            int columnPosition = this.treeLayer.getColumnPositionByIndex(columnIndex);
            ILayerCell cell = this.treeLayer.getCellByPosition(columnPosition, 0);
            if (cell != null) {
                return cell.getConfigLabels().hasLabel(TreeLayer.TREE_COLUMN_CELL);
            }
        }
        // there is no layer set, so we can not determine which column is the
        // tree column and therefore no column is treated that way
        return false;
    }

    /**
     *
     * @param columnIndex
     *            The column index of the column for which the
     *            {@link Comparator} is requested.
     * @return The {@link Comparator} that should be used to compare the values
     *         for elements in the given column. Returns the
     *         {@link DefaultComparator} in case there is no {@link ISortModel}
     *         configured for this {@link GroupByComparator} or no
     *         {@link Comparator} found for the given column.
     */
    @SuppressWarnings("rawtypes")
    protected Comparator getComparator(int columnIndex) {
        Comparator result = null;

        if (this.sortModel != null) {
            result = this.sortModel.getColumnComparator(columnIndex);
        }

        if (result == null) {
            result = DefaultComparator.getInstance();
        }

        return result;
    }

    @Override
    public ISortModel getSortModel() {
        return this.sortModel;
    }

    @Override
    public void setSortModel(ISortModel sortModel) {
        this.sortModel = sortModel;
    }

    @Override
    public void setTreeLayer(IUniqueIndexLayer treeLayer) {
        this.treeLayer = treeLayer;
    }

    @Override
    public void setDataLayer(GroupByDataLayer<T> dataLayer) {
        this.dataLayer = dataLayer;
    }

    @Override
    public void clearCache() {
        this.summaryColumnCache.clear();
        this.groupByObjectComparatorCache.clear();
    }

    /**
     *
     * @param groupBy
     *            The {@link GroupByObject} for which the cache information is
     *            requested. Needed to retrieve the information if it is not yet
     *            cached.
     * @param columnIndex
     *            The column for which the cache information is requested.
     * @return <code>true</code> if the given column is a summary column,
     *         <code>false</code> if not
     */
    protected Boolean isSummaryColumn(GroupByObject groupBy, int columnIndex) {
        if (!this.summaryColumnCache.containsKey(columnIndex)) {
            // build cache information
            // calling the get method will retrieve and cache the necessary
            // information
            getSummaryValueFromCache(groupBy, columnIndex);
        }
        return this.summaryColumnCache.get(columnIndex);
    }

    /**
     * Returns the cached summary value for the given {@link GroupByObject} and
     * column index. If there is no cache information yet, it will be build.
     *
     * @param groupBy
     *            The {@link GroupByObject} for which the cache information is
     *            requested.
     * @param columnIndex
     *            The column for which the cache information is requested.
     * @return The summary value for the given {@link GroupByObject} and column
     *         index or <code>null</code> in case there is no
     *         {@link GroupByDataLayer} configured in for this
     *         {@link GroupByComparator}.
     */
    protected Object getSummaryValueFromCache(GroupByObject groupBy, int columnIndex) {
        Object columnCache = null;

        // it is only possible to retrieve the summary values if the
        // GroupByDataLayer is set and therefore we only have a cache if it is
        // set
        if (this.dataLayer != null) {

            // check if a cache object is already there
            GroupByObjectValueCache cache = this.groupByObjectComparatorCache.computeIfAbsent(groupBy, g -> new GroupByObjectValueCache());

            // check if the cache object already contains information about the
            // column
            columnCache = cache.valueCache.get(columnIndex);
            if (columnCache == null) {

                // This is the performance bottleneck that is the reason for the
                // caching!
                int rowIndex = this.dataLayer.getTreeList().indexOf(groupBy);
                if (rowIndex >= 0) {
                    LabelStack labelStack = this.dataLayer.getConfigLabelsByPosition(columnIndex, rowIndex);
                    IGroupBySummaryProvider<T> provider = this.dataLayer.getGroupBySummaryProvider(labelStack);

                    boolean isSummaryColumn = provider != null;
                    this.summaryColumnCache.put(columnIndex, isSummaryColumn);
                    if (isSummaryColumn) {
                        /*
                         * Special Case: If a summary column is grouped, the
                         * summary value is the same as the GroupByObject value.
                         * In that case the roundtrip using the summary provider
                         * is not necessary and we should improve the
                         * performance if the sorting should be applied for that
                         * column.
                         */

                        // the descriptor map needs to be ordered
                        // (LinkedHashMap) and we need to find the last one in
                        // the order to check if a summary column was used for
                        // grouping
                        Entry<Integer, Object> last = null;
                        for (Entry<Integer, Object> entry : groupBy.getDescriptor().entrySet()) {
                            last = entry;
                        }

                        if (last != null && last.getKey() == columnIndex) {
                            columnCache = groupBy.getValue();
                        } else {
                            columnCache = this.dataLayer.getDataValueByPosition(columnIndex, rowIndex, labelStack, false);
                        }

                        cache.valueCache.put(columnIndex, columnCache);
                    }
                }
            }
        } else {
            this.summaryColumnCache.put(columnIndex, false);
        }

        return columnCache;
    }

    /**
     * Cache object that holds the the summary values for all summary columns of
     * a {@link GroupByObject}.
     */
    class GroupByObjectValueCache {
        Map<Integer, Object> valueCache = new HashMap<>();
    }
}
