/*******************************************************************************
 * 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
 *     Dirk Fauth <dirk.fauth@googlemail.com> - Bug 460052
 ******************************************************************************/
package org.eclipse.nebula.widgets.nattable.group;

import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Properties;
import java.util.StringTokenizer;

import org.eclipse.nebula.widgets.nattable.persistence.IPersistable;
import org.eclipse.nebula.widgets.nattable.util.ObjectUtils;

/**
 * Tracks: Columns (by index) in a column Group. Does not keep the column
 * indexes in any defined order. Expand/collapse state of the Group. Name of the
 * Column Group (CG)
 */
public class ColumnGroupModel implements IPersistable {

    private static final String PERSISTENCE_KEY_COLUMN_GROUPS = ".columnGroups"; //$NON-NLS-1$

    /** Column group header name to column indexes */

    private final List<ColumnGroup> columnGroups = new LinkedList<>();

    private final Collection<IColumnGroupModelListener> listeners = new HashSet<>();

    private boolean defaultCollapseable = true;

    public void registerColumnGroupModelListener(IColumnGroupModelListener listener) {
        this.listeners.add(listener);
    }

    public void unregisterColumnGroupModelListener(IColumnGroupModelListener listener) {
        this.listeners.remove(listener);
    }

    public void notifyListeners() {
        for (IColumnGroupModelListener listener : this.listeners) {
            listener.columnGroupModelChanged();
        }
    }

    @Override
    public void saveState(String prefix, Properties properties) {
        StringBuilder strBuilder = new StringBuilder();

        for (ColumnGroup columnGroup : this.columnGroups) {
            String columnGroupName = columnGroup.getName();

            // if this columnGroup has members, continue without saving state.
            // A group can haven no members if groups are used to organize
            // columns on a higher abstraction level ...
            if (columnGroup.members.isEmpty()) {
                continue;
            }

            strBuilder.append(columnGroupName);
            strBuilder.append('=');

            strBuilder.append(columnGroup.collapsed ? "collapsed" : "expanded"); //$NON-NLS-1$ //$NON-NLS-2$
            strBuilder.append(':');

            strBuilder.append(columnGroup.collapseable ? "collapseable" : "uncollapseable"); //$NON-NLS-1$ //$NON-NLS-2$
            strBuilder.append(':');

            strBuilder.append(columnGroup.unbreakable ? "unbreakable" : "breakable"); //$NON-NLS-1$ //$NON-NLS-2$
            strBuilder.append(':');

            for (Integer member : columnGroup.members) {
                strBuilder.append(member);
                strBuilder.append(',');
            }

            if (!columnGroup.staticColumnIndexes.isEmpty()) {
                strBuilder.append(':');
                for (Integer member : columnGroup.staticColumnIndexes) {
                    strBuilder.append(member);
                    strBuilder.append(',');
                }
            }

            strBuilder.append('|');
        }

        properties.setProperty(prefix + PERSISTENCE_KEY_COLUMN_GROUPS, strBuilder.toString());
    }

    @Override
    public void loadState(String prefix, Properties properties) {
        String property = properties.getProperty(prefix + PERSISTENCE_KEY_COLUMN_GROUPS);
        if (property != null) {
            clear();

            StringTokenizer columnGroupTokenizer = new StringTokenizer(property, "|"); //$NON-NLS-1$
            while (columnGroupTokenizer.hasMoreTokens()) {
                String columnGroupToken = columnGroupTokenizer.nextToken();

                int separatorIndex = columnGroupToken.indexOf('=');

                // Column group name
                String columnGroupName = columnGroupToken.substring(0, separatorIndex);
                ColumnGroup columnGroup = new ColumnGroup(columnGroupName);
                this.columnGroups.add(columnGroup);

                String[] columnGroupProperties = columnGroupToken.substring(separatorIndex + 1).split(":"); //$NON-NLS-1$

                // Expanded/collapsed
                String state = columnGroupProperties[0];
                if ("collapsed".equals(state)) { //$NON-NLS-1$
                    columnGroup.collapsed = true;
                } else if ("expanded".equals(state)) { //$NON-NLS-1$
                    columnGroup.collapsed = false;
                } else {
                    throw new IllegalArgumentException(state + " not one of 'expanded' or 'collapsed'"); //$NON-NLS-1$
                }

                // collapseble / uncollapseable
                state = columnGroupProperties[1];
                if ("collapseable".equals(state)) { //$NON-NLS-1$
                    columnGroup.collapseable = true;
                } else if ("uncollapseable".equals(state)) { //$NON-NLS-1$
                    columnGroup.collapseable = false;
                } else {
                    throw new IllegalArgumentException(state + " not one of 'uncollapseable' or 'collapseable'"); //$NON-NLS-1$
                }

                // breakable / unbreakable
                state = columnGroupProperties[2];
                if ("breakable".equals(state)) { //$NON-NLS-1$
                    columnGroup.unbreakable = false;
                } else if ("unbreakable".equals(state)) { //$NON-NLS-1$
                    columnGroup.unbreakable = true;
                } else {
                    throw new IllegalArgumentException(state + " not one of 'breakable' or 'unbreakable'"); //$NON-NLS-1$
                }

                // Indexes
                String indexes = columnGroupProperties[3];
                StringTokenizer indexTokenizer = new StringTokenizer(indexes, ","); //$NON-NLS-1$
                while (indexTokenizer.hasMoreTokens()) {
                    Integer index = Integer.valueOf(indexTokenizer.nextToken());
                    columnGroup.members.add(index);
                }

                if (columnGroupProperties.length == 5) {
                    String statics = columnGroupProperties[4];
                    StringTokenizer staticTokenizer = new StringTokenizer(statics, ","); //$NON-NLS-1$
                    while (staticTokenizer.hasMoreTokens()) {
                        Integer index = Integer.valueOf(staticTokenizer.nextToken());
                        columnGroup.staticColumnIndexes.add(index);
                    }
                }
            }
        }
    }

    /**
     * Creates the column group if one does not exist with the given name and
     * adds the column indexes to it.
     *
     * @param colGroupName
     *            The name of the column group.
     * @param bodyColumnIndexs
     *            The column indexes that should be added to the group.
     * @see #insertColumnIndexes(String, int[])
     */
    public void addColumnsIndexesToGroup(String colGroupName, int... bodyColumnIndexs) {
        if (getColumnGroupByName(colGroupName) == null) {
            ColumnGroup group = new ColumnGroup(colGroupName);
            group.setCollapseable(isDefaultCollapseable());
            this.columnGroups.add(group);
        }
        insertColumnIndexes(colGroupName, bodyColumnIndexs);
        notifyListeners();
    }

    /**
     * This method will add column indexes to an existing group.
     *
     * @param colGroupName
     *            The name of the column group to which the column indexes
     *            should be added to
     * @param columnIndexesToInsert
     *            The column indexes to insert.
     * @return FALSE if: The column group is frozen Index is already s part of a
     *         column group
     */
    public boolean insertColumnIndexes(String colGroupName, int... columnIndexesToInsert) {
        LinkedList<Integer> members = new LinkedList<>();

        ColumnGroup columnGroup = getColumnGroupByName(colGroupName);
        if (columnGroup.unbreakable) {
            return false;
        }

        // Check if any of the indexes belong to existing groups
        boolean memberAdded = false;
        for (int columnIndexToInsert : columnIndexesToInsert) {
            final Integer index = Integer.valueOf(columnIndexToInsert);
            ColumnGroup group = getColumnGroupByIndex(columnIndexToInsert);
            if (group != null && !group.getName().equals(colGroupName)) {
                return false;
            } else if (group != null && group.getName().equals(colGroupName)) {
                // column is already part of the group
                continue;
            }
            members.add(index);
        }

        if (!members.isEmpty()) {
            columnGroup.members.addAll(members);
            memberAdded = true;
            notifyListeners();
        }
        return memberAdded;
    }

    /**
     * This method will remove column indexes from an existing group.
     *
     * @param colGroupName
     *            The name of the column group from which the column indexes
     *            should be removed.
     * @param columnIndexesToRemove
     *            The column indexes to remove.
     * @return <code>true</code> if at least one column was removed from the
     *         column group.
     * @since 1.3
     */
    public boolean removeColumnIndexes(String colGroupName, int... columnIndexesToRemove) {
        ColumnGroup columnGroup = getColumnGroupByName(colGroupName);
        if (columnGroup.unbreakable) {
            return false;
        }

        // only remove members that belong to the given group
        boolean removed = false;
        for (int colIdx : columnIndexesToRemove) {
            if (columnGroup.members.contains(colIdx)) {
                if (columnGroup.removeColumn(colIdx) && !removed) {
                    removed = true;
                }
            }
        }

        notifyListeners();
        return removed;
    }

    /**
     * Add static columns identified by <code>staticColumnIndexes</code> to the
     * given columnGroup <code>colGroupName</code>. Static columns remains
     * visible when a column group is collapsed.
     *
     * @param colGroupName
     *            to add the indexes to
     * @param staticColumnIndexes
     *            the column indexes that should be treated as static columns
     */
    public void setStaticColumnIndexesByGroup(String colGroupName, int[] staticColumnIndexes) {
        if (getColumnGroupByName(colGroupName) == null) {
            ColumnGroup group = new ColumnGroup(colGroupName);
            group.setCollapseable(isDefaultCollapseable());
            this.columnGroups.add(group);
        }

        insertStaticColumnIndexes(colGroupName, staticColumnIndexes);

        notifyListeners();
    }

    /**
     * This method will add static column index(s) to an existing group
     *
     * @param colGroupName
     *            to add the indexes to
     * @param columnIndexesToInsert
     *            the column indexes that should be treated as static columns
     */
    public void insertStaticColumnIndexes(String colGroupName, int... columnIndexesToInsert) {

        LinkedList<Integer> staticColumnIndexes = new LinkedList<>();
        ColumnGroup columnGroup = getColumnGroupByName(colGroupName);

        // Check if any of the indexes belong to existing groups
        for (int columnIndexToInsert : columnIndexesToInsert) {
            final Integer index = Integer.valueOf(columnIndexToInsert);
            staticColumnIndexes.add(index);
        }

        columnGroup.staticColumnIndexes.addAll(staticColumnIndexes);

        notifyListeners();
    }

    // Getters

    public ColumnGroup getColumnGroupByName(String groupName) {
        for (ColumnGroup columnGroup : this.columnGroups) {
            if (columnGroup.getName().equals(groupName)) {
                return columnGroup;
            }
        }

        return null;
    }

    public ColumnGroup getColumnGroupByIndex(int columnIndex) {
        for (ColumnGroup columnGroup : this.columnGroups) {
            if (columnGroup.getMembers().contains(Integer.valueOf(columnIndex))) {
                return columnGroup;
            }
        }

        return null;
    }

    public void addColumnGroup(ColumnGroup columnGroup) {
        this.columnGroups.add(columnGroup);
        notifyListeners();
    }

    public void removeColumnGroup(ColumnGroup columnGroup) {
        this.columnGroups.remove(columnGroup);
        notifyListeners();
    }

    public boolean isPartOfAGroup(int bodyColumnIndex) {
        for (ColumnGroup columnGroup : this.columnGroups) {
            if (columnGroup.members.contains(bodyColumnIndex)) {
                return true;
            }
        }
        return false;
    }

    /**
     * @param cellValue
     *            the name that should be checked
     * @return <code>true</code> if a group by this name exists
     */
    public boolean isAGroup(String cellValue) {
        for (ColumnGroup columnGroup : this.columnGroups) {
            if (columnGroup.getName().equals(cellValue)) {
                return true;
            }
        }
        return false;
    }

    public void clear() {
        this.columnGroups.clear();
    }

    /**
     * @return Number of column Groups in the model.
     */
    public int size() {
        return this.columnGroups.size();
    }

    /**
     * @return TRUE if no column groups exist
     */
    public boolean isEmpty() {
        return this.columnGroups.isEmpty();
    }

    /**
     * @return all the indexes which belong to groups
     */
    public List<Integer> getAllIndexesInGroups() {
        List<Integer> indexes = new LinkedList<>();
        for (ColumnGroup columnGroup : this.columnGroups) {
            indexes.addAll(columnGroup.members);
        }
        return indexes;
    }

    /**
     * @param bodyColumnIndex
     *            the column index to check
     * @return <code>true</code> if <code>bodyColumnIndex</code> is contained in
     *         the list of static columns of the column group this index belongs
     *         to
     */
    public boolean isStaticColumn(int bodyColumnIndex) {
        if (isPartOfAGroup(bodyColumnIndex)) {
            return getColumnGroupByIndex(bodyColumnIndex).staticColumnIndexes.contains(bodyColumnIndex);
        }
        return false;
    }

    /**
     * @return Total number of columns hidden for all the collapsed columns.
     */
    public int getCollapsedColumnCount() {
        int count = 0;

        for (ColumnGroup columnGroup : this.columnGroups) {
            if (columnGroup.collapsed) {
                int staticColumnIndexesCount = columnGroup.getStaticColumnIndexes().size();
                count = count + columnGroup.getMembers().size() - Math.max(staticColumnIndexesCount, 1);
            }
        }
        return count;
    }

    /**
     * @param bodyColumnIndex
     *            the column index to check
     * @return The position of the index within the column group
     */
    public int getColumnGroupPositionFromIndex(int bodyColumnIndex) {
        if (isPartOfAGroup(bodyColumnIndex)) {
            ColumnGroup columnGroup = getColumnGroupByIndex(bodyColumnIndex);
            return columnGroup.members.indexOf(Integer.valueOf(bodyColumnIndex));
        }
        return -1;
    }

    /**
     * Check if the column at the specified column index belongs to a
     * {@link ColumnGroup} and if this {@link ColumnGroup} is collabseable.
     *
     * @param columnIndex
     *            The column index used to retrieve the corresponding column
     *            group
     * @return <code>true</code> if the column at the specified column index
     *         belongs to a {@link ColumnGroup} and this {@link ColumnGroup} is
     *         collabseable, <code>false</code> if not.
     */
    public boolean isPartOfACollapseableGroup(int columnIndex) {
        if (isPartOfAGroup(columnIndex)) {
            return getColumnGroupByIndex(columnIndex).isCollapseable();
        }
        return false;
    }

    /**
     * Set the {@link ColumnGroup} to which the column and the specified column
     * index belongs to, to be collapseable or not.
     *
     * @param columnIndex
     *            The column index used to retrieve the corresponding column
     *            group
     * @param collabseable
     *            <code>true</code> to set the column group collapseable,
     *            <code>false</code> to set it not to be collapseable.
     */
    public void setColumnGroupCollapseable(int columnIndex, boolean collabseable) {
        if (isPartOfAGroup(columnIndex)) {
            getColumnGroupByIndex(columnIndex).setCollapseable(collabseable);
        }
    }

    /**
     * Check if the column at the specified column index belongs to a
     * {@link ColumnGroup} and if this {@link ColumnGroup} is unbreakable.
     *
     * @param columnIndex
     *            The column index used to retrieve the corresponding column
     *            group
     * @return <code>true</code> if the column at the specified column index
     *         belongs to a {@link ColumnGroup} and this {@link ColumnGroup} is
     *         unbreakable, <code>false</code> if not.
     */
    public boolean isPartOfAnUnbreakableGroup(int columnIndex) {
        if (isPartOfAGroup(columnIndex)) {
            return getColumnGroupByIndex(columnIndex).isUnbreakable();
        }
        return false;
    }

    /**
     * Set the {@link ColumnGroup} to which the column and the specified column
     * index belongs to, to be unbreakable/breakable.
     *
     * @param columnIndex
     *            The column index used to retrieve the corresponding column
     *            group
     * @param unbreakable
     *            <code>true</code> to set the column group unbreakable,
     *            <code>false</code> to remove the unbreakable state.
     */
    public void setColumnGroupUnbreakable(int columnIndex, boolean unbreakable) {
        if (isPartOfAGroup(columnIndex)) {
            getColumnGroupByIndex(columnIndex).setUnbreakable(unbreakable);
        }
    }

    /**
     *
     * @return The default value for the collapseable flag of newly created
     *         {@link ColumnGroup} objects.
     *
     * @since 1.6
     */
    public boolean isDefaultCollapseable() {
        return this.defaultCollapseable;
    }

    /**
     * Sets the default value for the collapseable flag when creating
     * {@link ColumnGroup} objects.
     *
     * @param defaultCollapseable
     *            the default value for {@link ColumnGroup#collapseable} that
     *            should be set on creating {@link ColumnGroup}.
     *
     * @since 1.6
     */
    public void setDefaultCollapseable(boolean defaultCollapseable) {
        this.defaultCollapseable = defaultCollapseable;
    }

    // *** Column Group ***

    public class ColumnGroup {

        /** Body column indexes */
        private final LinkedList<Integer> members = new LinkedList<>();

        /** column indexes which remain visible when collapsing this group */
        private LinkedList<Integer> staticColumnIndexes = new LinkedList<>();

        private String name;

        private boolean collapsed = false;
        private boolean collapseable = true;

        /**
         * If a group is marked as unbreakable, the composition of the group
         * cannot be changed. Columns cannot be added or removed from the group.
         * Columns may be reorder within the group.
         *
         * @see NTBL 393
         */
        private boolean unbreakable = false;

        ColumnGroup(String groupName) {
            this.name = groupName;
        }

        public String getName() {
            return this.name;
        }

        public void setName(String name) {
            this.name = name;
            notifyListeners();
        }

        public boolean isCollapsed() {
            return this.collapsed;
        }

        public void setCollapsed(boolean collapsed) {
            this.collapsed = collapsed;
            notifyListeners();
        }

        public void toggleCollapsed() {
            setCollapsed(!this.collapsed);
        }

        public boolean isCollapseable() {
            return this.collapseable;
        }

        public void setCollapseable(boolean collapseable) {
            this.collapseable = collapseable;
            notifyListeners();
        }

        public boolean isUnbreakable() {
            return this.unbreakable;
        }

        public void setUnbreakable(boolean unbreakable) {
            this.unbreakable = unbreakable;
            notifyListeners();
        }

        public List<Integer> getMembers() {
            return this.members;
        }

        public List<Integer> getMembersSorted() {

            List<Integer> sortedMembers = new LinkedList<>(this.members);
            Collections.sort(sortedMembers);

            return sortedMembers;
        }

        /**
         * @return the column indexes which remains visible when collapsing this
         *         group
         */
        public LinkedList<Integer> getStaticColumnIndexes() {
            return this.staticColumnIndexes;
        }

        public int getSize() {
            return this.members.size();
        }

        /**
         * @param bodyColumnIndex
         *            the body column index that should be removed from its
         *            group.
         * @return <code>true</code> if index successfully removed from its
         *         group.
         */
        public boolean removeColumn(int bodyColumnIndex) {
            if (this.members.contains(bodyColumnIndex) && !this.unbreakable) {
                this.members.remove(Integer.valueOf(bodyColumnIndex));
                if (this.members.isEmpty()) {
                    ColumnGroupModel.this.columnGroups.remove(this);
                }
                notifyListeners();
                return true;
            }
            return false;
        }

        @Override
        public String toString() {
            return "Column Group:\n\t name: " + this.name //$NON-NLS-1$
                    + "\n\t collapsed: " + this.collapsed //$NON-NLS-1$
                    + "\n\t unbreakable: " + this.unbreakable //$NON-NLS-1$
                    + "\n\t members: " + ObjectUtils.toString(this.members) + "\n" //$NON-NLS-1$ //$NON-NLS-2$
                    + "\n\t staticColumns: " + ObjectUtils.toString(this.staticColumnIndexes) + "\n"; //$NON-NLS-1$ //$NON-NLS-2$
        }
    }

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

        for (ColumnGroup columnGroup : this.columnGroups) {
            buffer.append(columnGroup);
        }
        return buffer.toString();
    }

}
