/*****************************************************************************
 * Copyright (c) 2018, 2020 Dirk Fauth.
 *
 * 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
 *****************************************************************************/
package org.eclipse.nebula.widgets.nattable.hierarchical;

import java.beans.IntrospectionException;
import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;

/**
 * Helper class to de-normalize a collection of objects with hierarchical object
 * relations into a simple collection.
 *
 * @see HierarchicalWrapper
 *
 * @since 1.6
 */
public final class HierarchicalHelper {

    private static final Object lockObj = new Object();

    private HierarchicalHelper() {
        // empty default constructor for helper class
    }

    /**
     * The regular expression that is used to separate properties for accessing
     * fields on different levels of a {@link HierarchicalWrapper}.
     */
    public static final String PROPERTY_SEPARATOR_REGEX = "\\."; //$NON-NLS-1$

    /**
     * De-normalizes a given list of objects with a hierarchical object graph to
     * a flattened list. The flattened list therefore contains redundant data
     * for each entry that has the same parent objects. It will created
     * {@link HierarchicalWrapper} objects for this, which contain references to
     * the original objects. Therefore property changes will be directly
     * reflected in the hierarchical object graph.
     * <p>
     * To identify the nested objects, the property names are inspected if they
     * contain dots for hierarchical object paths. For example
     * <code>parent.children.name</code> says that the parent has a property
     * that is a collection (children) where each entry in the children
     * collection is an object that has a property name. In this example there
     * will be an entry in the flattened list for each child, where the parent
     * is repeated in each wrapper object.
     * </p>
     *
     * @param input
     *            The collection of objects with a hierarchical object graph.
     * @param addParentObject
     *            <code>true</code> to add row objects for parent objects that
     *            do no have object references for deeper levels,
     *            <code>false</code> to have no dedicated row object for parent
     *            objects.
     * @param propertyNames
     *            The property names to access the properties in the object
     *            graph.
     * @return The flattened list of the hierarchical object graph.
     */
    public static List<HierarchicalWrapper> deNormalize(List<?> input, boolean addParentObject, String... propertyNames) {
        return deNormalize(input, addParentObject, Arrays.asList(propertyNames));
    }

    /**
     * De-normalizes a given list of objects with a hierarchical object graph to
     * a flattened list. The flattened list therefore contains redundant data
     * for each entry that has the same parent objects. It will created
     * {@link HierarchicalWrapper} objects for this, which contain references to
     * the original objects. Therefore property changes will be directly
     * reflected in the hierarchical object graph.
     * <p>
     * To identify the nested objects, the property names are inspected if they
     * contain dots for hierarchical object paths. For example
     * <code>parent.children.name</code> says that the parent has a property
     * that is a collection (children) where each entry in the children
     * collection is an object that has a property name. In this example there
     * will be an entry in the flattened list for each child, where the parent
     * is repeated in each wrapper object.
     * </p>
     *
     * @param input
     *            The collection of objects with a hierarchical object graph.
     * @param addParentObject
     *            <code>true</code> to add row objects for parent objects that
     *            do no have object references for deeper levels,
     *            <code>false</code> to have no dedicated row object for parent
     *            objects.
     * @param propertyNames
     *            The property names to access the properties in the object
     *            graph.
     * @return The flattened list of the hierarchical object graph.
     */
    public static List<HierarchicalWrapper> deNormalize(List<?> input, boolean addParentObject, List<String> propertyNames) {
        ArrayList<HierarchicalWrapper> result = new ArrayList<>();

        if (input != null) {
            LinkedHashSet<String> nested = new LinkedHashSet<>();
            for (String name : propertyNames) {
                String[] prop = name.split(PROPERTY_SEPARATOR_REGEX);
                if (prop.length > 1) {
                    nested.add(prop[prop.length - 2]);
                }
            }

            String[] nestedArray = nested.toArray(new String[] {});

            for (Object root : input) {
                if (root != null) {
                    // length + 1 because of root
                    HierarchicalWrapper rootWrapper = new HierarchicalWrapper(nestedArray.length + 1);
                    rootWrapper.setObject(0, root);
                    result.addAll(deNormalizeWithDirectChildren(rootWrapper, 0, nestedArray,
                            new HashMap<>(), addParentObject));
                }
            }
        }

        return result;
    }

    /**
     * De-normalizes the next level in the object graph.
     *
     * @param parent
     *            The already available {@link HierarchicalWrapper} with the
     *            parent levels resolved. Will be cloned for de-normalizing
     *            deeper levels.
     * @param level
     *            The current level of de-normalization.
     * @param nested
     *            The properties of the collections that specify the object
     *            graph without the parent properties.
     * @param propertyDescriptorMap
     *            The map of already identified {@link PropertyDescriptor}s, to
     *            avoid multiple lookups via reflection.
     * @param addParentObject
     *            <code>true</code> if the already available
     *            {@link HierarchicalWrapper} with the resolved parent levels
     *            should be added to the result, <code>false</code> if not.
     * @return The flattened list of the object at the given level with regards
     *         to the nested objects, or a collection with only the given parent
     *         object if that object has not children.
     */
    private static List<HierarchicalWrapper> deNormalizeWithDirectChildren(HierarchicalWrapper parent, int level,
            String[] nested, Map<Class<?>, Map<String, PropertyDescriptor>> propertyDescriptorMap, boolean addParentObject) {

        if (level < nested.length) {
            Object child = getDataValue(parent.getObject(level), nested[level], propertyDescriptorMap);
            if (child instanceof Collection<?>) {
                ArrayList<HierarchicalWrapper> result = new ArrayList<>();
                Collection<?> children = (Collection<?>) child;

                if (addParentObject) {
                    result.add(parent);
                }

                for (Object root : children) {
                    HierarchicalWrapper rootWrapper = new HierarchicalWrapper(parent);
                    rootWrapper.setObject(level + 1, root);
                    result.addAll(deNormalizeWithDirectChildren(rootWrapper, level + 1, nested, propertyDescriptorMap, addParentObject));
                }
                return result;
            }
        }

        return Arrays.asList(parent);
    }

    /**
     * Get the data value for the given property name out of the given object
     * via reflection.
     *
     * @param rowObj
     *            The row object from which the data value should be read.
     * @param propertyName
     *            The name of the property that should be read.
     * @param propertyDescriptorMap
     *            The map of already identified {@link PropertyDescriptor}s, to
     *            avoid multiple lookups via reflection.
     * @return The data value for the given property name out of the given
     *         object.
     */
    private static Object getDataValue(Object rowObj, String propertyName,
            Map<Class<?>, Map<String, PropertyDescriptor>> propertyDescriptorMap) {

        try {
            PropertyDescriptor propertyDesc = getPropertyDescriptor(rowObj, propertyName, propertyDescriptorMap);
            Method readMethod = propertyDesc.getReadMethod();
            return readMethod.invoke(rowObj);
        } catch (Exception e) {
            throw new IllegalStateException(e);
        }
    }

    /**
     * Retrieves the {@link PropertyDescriptor} for the given object and
     * property name.
     *
     * @param rowObj
     *            The object for which the {@link PropertyDescriptor} is
     *            requested.
     * @param propertyName
     *            The name of the property for which the
     *            {@link PropertyDescriptor} is requested.
     * @param propertyDescriptorMap
     *            The map of already identified {@link PropertyDescriptor}s, to
     *            avoid multiple lookups via reflection.
     * @return The {@link PropertyDescriptor} for the property with the given
     *         name in the given object.
     * @throws IntrospectionException
     *             if an exception occurs during introspection
     */
    private static PropertyDescriptor getPropertyDescriptor(Object rowObj, String propertyName,
            Map<Class<?>, Map<String, PropertyDescriptor>> propertyDescriptorMap) throws IntrospectionException {

        synchronized (lockObj) {
            Map<String, PropertyDescriptor> descriptorMap = propertyDescriptorMap.get(rowObj.getClass());

            if (descriptorMap == null) {
                PropertyDescriptor[] propertyDescriptors = Introspector.getBeanInfo(rowObj.getClass())
                        .getPropertyDescriptors();

                Map<String, PropertyDescriptor> propertiesByAttribute = new HashMap<>();
                for (PropertyDescriptor propertyDescriptor : propertyDescriptors) {
                    propertiesByAttribute.put(propertyDescriptor.getName(), propertyDescriptor);
                }
                descriptorMap = propertiesByAttribute;
                propertyDescriptorMap.put(rowObj.getClass(), propertiesByAttribute);
            }

            return descriptorMap.get(propertyName);
        }
    }

    /**
     * Returns the level object from the given {@link HierarchicalWrapper}
     * object by the given property name.
     *
     * @param wrapper
     *            The {@link HierarchicalWrapper} from which the level object
     *            should be retrieved.
     * @param propertyName
     *            The name of the property to identify the level object from.
     * @return The level object from the {@link HierarchicalWrapper} according
     *         to the given property name.
     */
    public static Object getLevelObjectByProperty(HierarchicalWrapper wrapper, String propertyName) {
        String[] split = propertyName.split(PROPERTY_SEPARATOR_REGEX);
        return wrapper.getObject(split.length - 1);
    }

    /**
     * Calculates the mapping of hierarchy levels to column indexes belonging to
     * the level.
     *
     * @param propertyNames
     *            The property names to access the properties in the object
     *            graph.
     * @return The mapping of the level to the list of all column indexes
     *         belonging to a level.
     */
    public static Map<Integer, List<Integer>> getLevelIndexMapping(String[] propertyNames) {
        LinkedHashMap<Integer, List<Integer>> levelIndexMapping = new LinkedHashMap<>();
        if (propertyNames.length > 0) {
            int currentLevel = 1;
            ArrayList<Integer> columns = new ArrayList<>();
            columns.add(0);
            levelIndexMapping.put(0, columns);
            for (int col = 1; col < propertyNames.length; col++) {
                String[] split = propertyNames[col].split(PROPERTY_SEPARATOR_REGEX);
                if (split.length == currentLevel) {
                    columns.add(col);
                } else if (split.length > currentLevel) {
                    columns = new ArrayList<>();
                    columns.add(col);
                    levelIndexMapping.put(currentLevel, columns);
                    currentLevel++;
                }
            }
        }
        return levelIndexMapping;
    }
}
