/*******************************************************************************
 * Copyright (c) 2012, 2020 Original authors 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:
 *     Original authors and others - initial API and implementation
 ******************************************************************************/
package org.eclipse.nebula.widgets.nattable.group.model;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

import org.eclipse.nebula.widgets.nattable.data.IRowDataProvider;

/**
 * A thread-safe implementation of {@link IRowGroupModel} which is optimised for
 * larger data-sets (it should cope with at least 10k rows spread across 2-300
 * groups).
 *
 * @author Stefan Bolton
 *
 * @param <T>
 *            the type of the row objects.
 */
public class RowGroupModel<T> implements IRowGroupModel<T> {

    // A map of top-level group names-to-RowGroups.
    private final Map<String, IRowGroup<T>> namesToGroups;

    // A performance cache of rows-to-row group nodes. Kept directly in sync
    // with the model.
    private final Map<T, IRowGroup<T>> rowToGroups;

    // Row group change listeners.
    private final Set<IRowGroupModelListener> listeners;

    // A convenience cache of row indexes to/from row objects. Items are added
    // on demand
    // from the layers as-and-when they are needed.
    private final RowCache<T> rowCache;

    // For big model changes it can be easier to suppress model change
    // notifications and fire a single one.
    private boolean suppressNoficiations;

    public RowGroupModel() {
        this.rowToGroups = new ConcurrentHashMap<>();
        this.namesToGroups = new ConcurrentHashMap<>();
        this.rowCache = new RowCache<>();
        this.listeners = new HashSet<>();
        this.suppressNoficiations = false;
    }

    @Override
    public T getRowFromIndexCache(final int rowIndex) {
        return this.rowCache.getRowFromIndexCache(rowIndex);
    }

    @Override
    public int getIndexFromRowCache(final T row) {
        return this.rowCache.getIndexFromRowCache(row);
    }

    @Override
    public void invalidateIndexCache() {
        this.rowCache.invalidateIndexCache();
    }

    @Override
    public void setDataProvider(IRowDataProvider<T> dataProvider) {
        this.rowCache.setDataProvider(dataProvider);
    }

    @Override
    public IRowDataProvider<T> getDataProvider() {
        return this.rowCache.getDataProvider();
    }

    /**
     * <p>
     * Notify any {@link IRowGroupModelListener}s that something in the model
     * has changed.
     * </p>
     */
    @Override
    public void notifyListeners() {
        invalidateIndexCache();

        if (!this.suppressNoficiations) {
            for (IRowGroupModelListener listener : this.listeners) {
                listener.rowGroupModelChanged();
            }
        }
    }

    /**
     * Set to true to stop model change notifications.
     *
     * @param suppressNoficiations
     */
    public void setSuppressNoficiations(boolean suppressNoficiations) {
        this.suppressNoficiations = suppressNoficiations;
    }

    public boolean isSuppressNoficiations() {
        return this.suppressNoficiations;
    }

    void addMemberRow(final T row, final RowGroup<T> rowGroup) {
        this.rowToGroups.put(row, rowGroup);
    }

    void removeMemberRow(final T row) {
        this.rowToGroups.remove(row);
    }

    @Override
    public void addRowGroups(final List<IRowGroup<T>> rowGroups) {

        // Add the group into the model now.
        for (IRowGroup<T> rowGroup : rowGroups) {
            this.namesToGroups.put(rowGroup.getGroupName(), rowGroup);
        }

        notifyListeners();
    }

    @Override
    public boolean addRowGroup(final IRowGroup<T> rowGroup) {

        // Only allow unique names.
        if (this.namesToGroups.containsKey(rowGroup.getGroupName())) {
            return false;
        }

        // Add the group into the model now.
        this.namesToGroups.put(rowGroup.getGroupName(), rowGroup);

        notifyListeners();
        return true;
    }

    @Override
    public boolean removeRowGroup(final IRowGroup<T> rowGroup) {

        boolean removed = this.namesToGroups.containsKey(rowGroup.getGroupName());

        if (removed) {
            // Remove the group itself now.
            this.namesToGroups.remove(rowGroup.getGroupName());
            notifyListeners();
        }

        return removed;
    }

    @Override
    public List<IRowGroup<T>> getRowGroups() {
        return Collections.unmodifiableList(new ArrayList<IRowGroup<T>>(
                this.namesToGroups.values()));
    }

    @Override
    public IRowGroup<T> getRowGroupForName(final String groupName) {
        return this.namesToGroups.get(groupName);
    }

    @Override
    public IRowGroup<T> getRowGroupForRow(T row) {

        if (this.rowToGroups.containsKey(row)) {
            return getUltimateParent(this.rowToGroups.get(row));
        }

        return null;
    }

    private IRowGroup<T> getUltimateParent(IRowGroup<T> group) {
        return (group.getParentGroup() == null ? group
                : getUltimateParent(group.getParentGroup()));
    }

    @Override
    public boolean isEmpty() {
        return this.namesToGroups.isEmpty();
    }

    @Override
    public void clear() {
        this.namesToGroups.clear();
        this.rowToGroups.clear();
        this.rowCache.invalidateIndexCache();
        notifyListeners();
    }

    @Override
    public void registerRowGroupModelListener(
            final IRowGroupModelListener listener) {
        this.listeners.add(listener);
    }

    @Override
    public void unregisterRowGroupModelListener(
            final IRowGroupModelListener listener) {
        this.listeners.remove(listener);
    }

    @Override
    public void saveState(String prefix, Properties properties) {
        // no additional states that need to be saved
    }

    @Override
    public void loadState(String prefix, Properties properties) {
        // no additional states that need to be saved
    }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();
        sb.append(" ===== Row Group Model ==== \n"); //$NON-NLS-1$

        synchronized (this.namesToGroups) {
            for (IRowGroup<T> rowGroup : this.namesToGroups.values()) {
                sb.append(((RowGroup<T>) rowGroup).toString());
            }
        }

        return sb.toString();
    }

    private class RowCache<E> {

        private IRowDataProvider<E> dataProvider;

        private final Map<Integer, E> indexesToRows;
        private final Map<E, Integer> rowsToIndexes;

        public RowCache() {
            this.indexesToRows = new LinkedHashMap<>();
            this.rowsToIndexes = new LinkedHashMap<>();
        }

        public IRowDataProvider<E> getDataProvider() {
            return this.dataProvider;
        }

        public void setDataProvider(IRowDataProvider<E> dataProvider) {
            this.dataProvider = dataProvider;
        }

        public E getRowFromIndexCache(final int rowIndex) {
            if (this.indexesToRows.containsKey(rowIndex)) {
                return this.indexesToRows.get(rowIndex);
            }

            final E row = this.dataProvider.getRowObject(rowIndex);

            // If the row we want to use is already in use, then clear the
            // cache.
            if (this.rowsToIndexes.containsKey(row)) {
                invalidateIndexCache();
            }

            this.rowsToIndexes.put(row, rowIndex);
            this.indexesToRows.put(rowIndex, row);
            return row;
        }

        public int getIndexFromRowCache(final E row) {
            if (this.rowsToIndexes.containsKey(row)) {
                return this.rowsToIndexes.get(row);
            }

            final int rowIndex = this.dataProvider.indexOfRowObject(row);

            // If the index we want to use is already in use, then clear the
            // cache.
            if (this.indexesToRows.containsKey(rowIndex)) {
                invalidateIndexCache();
            }

            this.indexesToRows.put(rowIndex, row);
            this.rowsToIndexes.put(row, rowIndex);
            return rowIndex;
        }

        public void invalidateIndexCache() {
            this.indexesToRows.clear();
            this.rowsToIndexes.clear();
        }
    }
}
