/*
 * Copyright 2016 the original author or authors.
 *
 * Licensed 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.gradle.plugins.ide.internal.configurer;

import com.google.common.base.Joiner;
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.LinkedHashMultimap;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Multimap;
import com.google.common.collect.Sets;
import com.google.common.primitives.Ints;

import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;

/**
 * A generic name de-duplicator for hierarchical elements.
 * <p>
 * Conflicting sub-elements are de-duplicated by prepending their parent element names, separated by a dash.
 * Conflicting root elements are rejected with an {@link IllegalArgumentException}
 * <p>
 * If a child's simple name already contains the name of its parent, the two prefixes are collapsed to keep names short.
 * For example, an elements with the name segments <code>root:impl:impl-simple</code> would initially get the name
 * <code>root-impl-impl-simple</code> and would then be shortened to <code>root-impl-simple</code>
 * This shortening is of course only applied if it does not introduce a new name conflict.
 *
 * @param <T> the type of element to de-duplicate
 */
public class HierarchicalElementDeduplicator<T> {

    private final HierarchicalElementAdapter<T> adapter;

    public HierarchicalElementDeduplicator(HierarchicalElementAdapter<T> adapter) {
        this.adapter = adapter;
    }

    /**
     * Calculates a set of renamings for each duplicate name in the given set of elements.
     *
     * @param elements the elements with possibly duplicated names
     * @return a Map containing the new name for each element that has to be renamed
     */
    public Map<T, String> deduplicate(Iterable<? extends T> elements) {
        return new StatefulDeduplicator(elements).getNewNames();
    }

    /*
     * This inner class hides the fact that the actual de-duplication algorithm is stateful.
     */
    private class StatefulDeduplicator {

        private final List<T> elements;
        private final Multimap<String, T> elementsByName;
        private final Map<T, String> originalNames;
        private final Map<T, String> newNames;
        private final Map<T, T> prefixes;

        private StatefulDeduplicator(Iterable<? extends T> elements) {
            this.elements = Lists.newArrayList(elements);
            this.elementsByName = LinkedHashMultimap.create();
            this.originalNames = Maps.newHashMap();
            this.newNames = Maps.newHashMap();
            this.prefixes = Maps.newHashMap();
        }

        private Map<T, String> getNewNames() {
            if (!elements.isEmpty() && newNames.isEmpty()) {
                calculateNewNames();
            }

            return ImmutableMap.copyOf(newNames);
        }

        private void calculateNewNames() {
            sortElementsByDepth();
            for (T element : elements) {
                elementsByName.put(getOriginalName(element), element);
                prefixes.put(element, getParent(element));
            }
            while (!getDuplicateNames().isEmpty()) {
                deduplicate();
            }
            simplifyNames();
        }

        private void deduplicate() {
            for (String duplicateName : getDuplicateNames()) {
                Collection<T> duplicatedElements = elementsByName.get(duplicateName);
                Set<String> reservedNames = ImmutableSet.copyOf(elementsByName.keySet());
                Set<T> notYetRenamed = getNotYetRenamedElements(duplicatedElements);
                boolean deduplicationSuccessful = false;
                Set<T> elementsToRename = notYetRenamed.isEmpty() ? ImmutableSet.copyOf(duplicatedElements) : notYetRenamed;
                for (T element : elementsToRename) {
                    boolean elementRenamed = true;
                    while (elementRenamed && reservedNames.contains(getCurrentlyAssignedName(element))) {
                        elementRenamed = renameUsingParentPrefix(element);
                        deduplicationSuccessful |= elementRenamed;
                    }
                }
                if (!deduplicationSuccessful) {
                    // there was no more success in deduplication by path only
                    for (T element : elementsToRename) {
                        boolean elementRenamed = true;
                        while (elementRenamed && reservedNames.contains(getCurrentlyAssignedName(element))) {
                            elementRenamed = renameUsingIdentityName(element);
                            deduplicationSuccessful |= elementRenamed;
                        }
                    }
                }

                if (!deduplicationSuccessful) {
                    throw new IllegalArgumentException("Duplicate root element " + duplicateName);
                }
            }
        }

        private boolean renameUsingIdentityName(T element) {
            String currentlyAssignedName = getCurrentlyAssignedName(element);
            String originalName = getOriginalName(element);
            String identityName = getIdentityName(element);
            if (!originalName.equals(identityName) && (currentlyAssignedName.equals(originalName) || currentlyAssignedName.endsWith("-" + originalName))) {
                String newName = currentlyAssignedName.substring(0, currentlyAssignedName.length() - originalName.length()) + identityName;
                renameTo(element, newName);
                originalNames.put(element, identityName);
                return true;
            }
            return false;
        }

        private boolean renameUsingParentPrefix(T element) {
            T prefixElement = prefixes.get(element);
            if (prefixElement != null) {
                renameTo(element, getOriginalName(prefixElement) + "-" + getCurrentlyAssignedName(element));
                prefixes.put(element, getParent(prefixElement));
                return true;
            }
            return false;
        }

        private void renameTo(T element, String newName) {
            elementsByName.remove(getCurrentlyAssignedName(element), element);
            elementsByName.put(newName, element);
            newNames.put(element, newName);
        }

        private void simplifyNames() {
            Set<String> deduplicatedNames = elementsByName.keySet();
            for (T element : elements) {
                String simplifiedName = removeDuplicateWordsFromPrefix(getCurrentlyAssignedName(element), getOriginalName(element));
                if (!deduplicatedNames.contains(simplifiedName)) {
                    renameTo(element, simplifiedName);
                }
            }
        }

        private String removeDuplicateWordsFromPrefix(String deduplicatedName, String originalName) {
            String prefix = deduplicatedName.substring(0, deduplicatedName.lastIndexOf(originalName));
            if (prefix.isEmpty()) {
                return deduplicatedName;
            }

            Splitter splitter = Splitter.on('-').omitEmptyStrings();
            List<String> prefixParts = Lists.newArrayList(splitter.split(prefix));
            List<String> postfixParts = Lists.newArrayList(splitter.split(originalName));
            List<String> words = Lists.newArrayList();

            if (postfixParts.size() > 1) {
                String postfixHead = postfixParts.get(0);
                prefixParts.add(postfixHead);
                postfixParts.remove(postfixHead);
            }

            for (String prefixPart : prefixParts) {
                if (!prefixPart.equals(Iterables.getLast(words, null))) {
                    words.add(prefixPart);
                }
            }

            words.addAll(postfixParts);

            return Joiner.on('-').join(words);
        }

        private Set<String> getDuplicateNames() {
            Set<String> duplicates = Sets.newLinkedHashSet();
            for (Entry<String, Collection<T>> entry : elementsByName.asMap().entrySet()) {
                if (entry.getValue().size() > 1) {
                    duplicates.add(entry.getKey());
                }
            }
            return duplicates;
        }

        private Set<T> getNotYetRenamedElements(Collection<T> elementsToRename) {
            Set<T> notYetRenamed = Sets.newLinkedHashSet();
            for (T element : elementsToRename) {
                if (!hasBeenRenamed(element)) {
                    notYetRenamed.add(element);
                }
            }
            return notYetRenamed;
        }

        private String getOriginalName(T element) {
            if (originalNames.containsKey(element)) {
                return originalNames.get(element);
            }
            return adapter.getName(element);
        }

        private String getIdentityName(T element) {
            return adapter.getIdentityName(element);
        }

        private String getCurrentlyAssignedName(T element) {
            if (hasBeenRenamed(element)) {
                return newNames.get(element);
            } else {
                return getOriginalName(element);
            }
        }

        private T getParent(T parent) {
            return adapter.getParent(parent);
        }

        private boolean hasBeenRenamed(T element) {
            return newNames.containsKey(element);
        }

        private void sortElementsByDepth() {
            Collections.sort(elements, new Comparator<T>() {

                @Override
                public int compare(T left, T right) {
                    return Ints.compare(getDepth(left), getDepth(right));
                }

                private int getDepth(T element) {
                    int depth = 0;
                    T parent = element;
                    while (parent != null) {
                        depth++;
                        parent = getParent(parent);
                    }
                    return depth;
                }
            });
        }
    }

}
