/*******************************************************************************
 * Copyright (c) 2006, 2020 IBM Corporation and others.
 *
 * This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License 2.0
 * which accompanies this distribution, and is available at
 * https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *     IBM Corporation - initial API and implementation
 *     IBM Corporation - add support for filtering of the index view
 *     George Suaridze <suag@1c.ru> (1C-Soft LLC) - Bug 560168
 *******************************************************************************/
package org.eclipse.help.internal.index;

import java.text.Collator;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.eclipse.core.runtime.Platform;
import org.eclipse.help.IIndexEntry;
import org.eclipse.help.ITopic;
import org.eclipse.help.IUAElement;
import org.eclipse.help.internal.HelpPlugin;
import org.eclipse.help.internal.Topic;
import org.eclipse.help.internal.UAElement;
import org.eclipse.help.internal.dynamic.DocumentProcessor;
import org.eclipse.help.internal.dynamic.DocumentReader;
import org.eclipse.help.internal.dynamic.ExtensionHandler;
import org.eclipse.help.internal.dynamic.IncludeHandler;
import org.eclipse.help.internal.dynamic.ProcessorHandler;
import org.eclipse.help.internal.toc.HrefUtil;


/*
 * Assembles individual keyword index contributions into a complete, fully
 * sorted master index.
 */
public class IndexAssembler {

	private DocumentProcessor processor;
	private IndexComparator comparator;
	private String locale;

	/*
	 * Assembles the given index contributions into a complete, sorted index.
	 * The originals are not modified.
	 */
	public Index assemble(List<IndexContribution> contributions, String locale) {
		this.locale = locale;
		process(contributions);
		Index index = merge(contributions);
		sortAndPrune(index);
		return index;
	}

	/*
	 * Merge all index contributions into one large index, not sorted.
	 */
	private Index merge(List<IndexContribution> contributions) {
		Index index = new Index();
		Iterator<IndexContribution> iter = contributions.iterator();
		while (iter.hasNext()) {
			IndexContribution contribution = iter.next();
			mergeChildren(index, (Index)contribution.getIndex());
			contribution.setIndex(null);
		}
		return index;
	}

	/*
	 * Merges the children of nodes a and b, and stores them into a. If the two
	 * contain the same keyword, only one is kept but its children are merged,
	 * recursively. If multiple topics exist with the same href, only the
	 * first one found is kept. If multiple see elements are found with the same target
	 * only one is retained
	 */
	private void mergeChildren(UAElement a, UAElement b) {
		// create data structures for fast lookup
		Map<String, UAElement> entriesByKeyword = new HashMap<>();
		Set<String> topicHrefs = new HashSet<>();
		Set<IndexSee> seeTargets = new HashSet<>();
		IUAElement[] childrenA = a.getChildren();
		for (int i=0;i<childrenA.length;++i) {
			UAElement childA = (UAElement)childrenA[i];
			if (childA instanceof IndexEntry) {
				entriesByKeyword.put(childA.getAttribute(IndexEntry.ATTRIBUTE_KEYWORD), childA);
			}
			else if (childA instanceof Topic) {
				topicHrefs.add(childA.getAttribute(Topic.ATTRIBUTE_HREF));
			} else if (childA instanceof IndexSee) {
				seeTargets.add((IndexSee) childA);
			}
		}

		// now do the merge
		IUAElement[] childrenB = b.getChildren();
		for (int i=0;i<childrenB.length;++i) {
			UAElement childB = (UAElement)childrenB[i];
			if (childB instanceof IndexEntry) {
				String keyword = childB.getAttribute(IndexEntry.ATTRIBUTE_KEYWORD);
				if (entriesByKeyword.containsKey(keyword)) {
					// duplicate keyword; merge children
					mergeChildren(entriesByKeyword.get(keyword), childB);
				}
				else {
					// wasn't a duplicate
					a.appendChild(childB);
					entriesByKeyword.put(keyword, childB);
				}
			}
			else if (childB instanceof Topic) {
				String href = childB.getAttribute(Topic.ATTRIBUTE_HREF);
				if (!topicHrefs.contains(href)) {
					// add topic only if href doesn't exist yet
					a.appendChild(childB);
					topicHrefs.add(href);
				}
			} else if (childB instanceof IndexSee) {
				if (!seeTargets.contains((childB))) {
					// add see only if it doesn't exist yet
					a.appendChild(childB);
					seeTargets.add((IndexSee) childB);
				}
			}
		}
	}

	private void process(List<IndexContribution> contributions) {
		if (processor == null) {
			DocumentReader reader = new DocumentReader();
			processor = new DocumentProcessor(new ProcessorHandler[] {
				new NormalizeHandler(),
				new IncludeHandler(reader, locale),
				new ExtensionHandler(reader, locale),
			});
		}
		Iterator<IndexContribution> iter = contributions.iterator();
		while (iter.hasNext()) {
			IndexContribution contribution = iter.next();
			processor.process((Index)contribution.getIndex(), contribution.getId());
		}
	}

	/*
	 * Sort the given node's descendants recursively.
	 */
	private void sortAndPrune(UAElement element) {
		if (comparator == null) {
			comparator = new IndexComparator();
		}
		sortAndPrune(element, comparator);
	}

	/*
	 * Sort the given node's descendants recursively using the given
	 * Comparator. Prune out any empty entry elements. Return true if this node was
	 * not pruned
	 */
	private boolean sortAndPrune(UAElement element, IndexComparator comparator) {
		// sort children
		IUAElement[] children = element.getChildren();
		if (children.length > 1) {
			for (int i=0;i<children.length;++i) {
				element.removeChild((UAElement)children[i]);
			}
			Arrays.sort(children, comparator);
			for (int i=0;i<children.length;++i) {
				element.appendChild((UAElement)children[i]);
			}
		}
		// sort children's children
		boolean hasChildren = false;
		for (int i=0;i<children.length;++i) {
			hasChildren = hasChildren | sortAndPrune((UAElement)children[i], comparator);
		}
		if (element instanceof IIndexEntry && !hasChildren) {
			element.getParentElement().removeChild(element);
			return false;
		}
		if (element instanceof IndexSee && !isValidSeeReference((IndexSee) element)) {
			element.getParentElement().removeChild(element);
			return false;
		}
		return true;
	}

	boolean isValidSeeReference(IndexSee see) {
		UAElement ancestor = see.getParentElement();
		while (!(ancestor instanceof Index)) {
			if (ancestor == null) {
				return true;
			}
			ancestor = ancestor.getParentElement();
		}
		return ((Index)ancestor).getSeeTarget(see) != null;
	}

	/*
	 * Normalizes topic hrefs, by prepending the plug-in id to form an href.
	 * e.g. "path/myfile.html" -> "/my.plugin/path/myfile.html"
	 */
	private static class NormalizeHandler extends ProcessorHandler {
		@Override
		public short handle(UAElement element, String id) {
			if (element instanceof Topic) {
				Topic topic = (Topic)element;
				String href = topic.getHref();
				if (href != null) {
					int index = id.indexOf('/', 1);
					if (index != -1) {
						String pluginId = id.substring(1, index);
						topic.setHref(HrefUtil.normalizeHref(pluginId, href));
					}
				}
				String title = element.getAttribute("title"); //$NON-NLS-1$
				if (title != null) {
					topic.setLabel(title);
				}
			}
			return UNHANDLED;
		}
	}

	private class IndexComparator implements Comparator<IUAElement> {
		Collator collator = Collator.getInstance();
		@Override
		public int compare(IUAElement o1, IUAElement o2) {
			/*
			 * First separate the objects into different groups by type;
			 * topics first, then entries, etc. Then within each
			 * group, sort alphabetically.
			 */
			int c1 = getCategory(o1);
			int c2 = getCategory(o2);
			if (c1 == c2) {
				if (o1 instanceof IndexSee) {
					return ((IndexSee)o1).compareTo(o2);
				}
				// same type of object; compare alphabetically
				String s1 = getLabel(o1);
				String s2 = getLabel(o2);
				//return s1.compareTo(s2);
				return collator.compare(s1, s2);
			}
			else {
				// different types; compare by type
				return c1 - c2;
			}
		}

		/*
		 * Returns the category of the node. The order is:
		 * 1. topics
		 * 2. entries starting with non-alphanumeric
		 * 3. entries starting with digit
		 * 4. entries starting with alpha
		 * 5. other
		 */
		private int getCategory(IUAElement element) {
			if (element instanceof Topic) {
				return 0;
			}
			else if (element instanceof IndexEntry) {
				String keyword = ((IndexEntry)element).getKeyword();
				if (keyword != null && keyword.length() > 0) {
					char c = keyword.charAt(0);
					if (Character.isDigit(c)) {
						return 2;
					}
					else if (Character.isLetter(c)) {
						return 3;
					}
					return 1;
				}
				return 4;
			} else if (element instanceof IndexSee) {
				return 5;
			}
			else {
				return 6;
			}
		}

		/*
		 * Returns the string that will be displayed for the given object,
		 * used for sorting.
		 */
		private String getLabel(IUAElement element) {
			if (element instanceof Topic) {
				Topic topic = (Topic)element;
				if (topic.getLabel() == null) {
					ITopic topic2 = HelpPlugin.getTocManager().getTopic(topic.getHref(), locale);
					if (topic2 != null) {
						topic.setLabel(topic2.getLabel());
					}
					else {
						String msg = "Unable to look up label for help keyword index topic \"" + topic.getHref() + "\" with missing \"" + Topic.ATTRIBUTE_LABEL + "\" attribute (topic does not exist in table of contents; using href as label)"; //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
						Platform.getLog(getClass()).error(msg);
						topic.setLabel(topic.getHref());
					}
				}
				return topic.getLabel();
			}
			else if (element instanceof IndexEntry) {
				return ((IndexEntry)element).getKeyword();
			}
			return null;
		}
	}
}
