/*
 * 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.atteo.xmlcombiner;

import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map.Entry;
import java.util.Set;

import javax.annotation.Nullable;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.Result;
import javax.xml.transform.Source;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;

import org.w3c.dom.Attr;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;

import com.google.common.base.Splitter;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.Lists;

/**
 * Combines two or more XML DOM trees.
 *
 * <p>
 * The merging algorithm is as follows:<br/>
 * First direct subelements of selected node are examined.
 * The elements from both trees with matching keys are paired.
 * Based on selected behavior the content of the paired elements is then merged.
 * Finally the paired elements are recursively combined. Any not paired elements are appended.
 * </p>
 * <p>
 * You can control merging behavior using {@link CombineSelf 'combine.self'}
 * and {@link CombineChildren 'combine.children'} attributes.
 * </p>
 * <p>
 * The merging algorithm was inspired by similar functionality in Plexus Utils.
 * </p>
 *
 * @see <a href="http://www.sonatype.com/people/2011/01/maven-how-to-merging-plugin-configuration-in-complex-projects/">merging in Maven</a>
 * @see <a href="http://plexus.codehaus.org/plexus-utils/apidocs/org/codehaus/plexus/util/xml/Xpp3DomUtils.html">Plexus utils implementation of merging</a>
 */
public class XmlCombiner {
	/**
	 * Allows to filter the result of the merging.
	 */
	public interface Filter {
		/**
		 * Post process the matching elements after merging.
		 * @param recessive recessive element, can be null, should not be modified
		 * @param dominant dominant element, can be null, should not be modified
		 * @param result result element, will not be null, it can be freely modified
		 */
		void postProcess(Element recessive, Element dominant, Element result);
	}

	private final DocumentBuilder documentBuilder;
	private final Document document;
	private final List<String> defaultAttributeNames;
	private static final Filter NULL_FILTER = new Filter() {
		@Override
		public void postProcess(Element recessive, Element dominant, Element result) {
		}
	};
	private Filter filter = NULL_FILTER;
	private final ChildContextsMapper childContextMapper = new KeyAttributesChildContextsMapper();

	public static void main(String[] args) throws ParserConfigurationException, SAXException, IOException,
			TransformerException {
		List<Path> files = new ArrayList<>();
		List<String> ids = new ArrayList<>();

		boolean onlyFiles = false;

		for (int i = 0; i < args.length; i++) {
			if (!onlyFiles) {
				switch (args[i]) {
					case "--key":
						ids.add(args[i+1]);
						i++;
						break;
					case "--":
						onlyFiles = true;
						break;
					default:
						files.add(Paths.get(args[i]));
				}
			} else {
				files.add(Paths.get(args[i]));
			}
		}

		XmlCombiner xmlCombiner = new XmlCombiner(ids);

		for (Path file : files) {
			xmlCombiner.combine(file);
		}

		xmlCombiner.buildDocument(System.out);
	}

	/**
	 * Creates XML combiner using default {@link DocumentBuilder}.
	 * @throws ParserConfigurationException when {@link DocumentBuilder} creation fails
	 */
	public XmlCombiner() throws ParserConfigurationException {
		this(DocumentBuilderFactory.newInstance().newDocumentBuilder());
	}

	public XmlCombiner(DocumentBuilder documentBuilder) {
		this(documentBuilder, Lists.<String>newArrayList());
	}

	/**
	 * Creates XML combiner using given attribute as an id.
	 */
	public XmlCombiner(String idAttributeName) throws ParserConfigurationException {
		this(Lists.newArrayList(idAttributeName));
	}

	public XmlCombiner(List<String> keyAttributeNames) throws ParserConfigurationException {
		this(DocumentBuilderFactory.newInstance().newDocumentBuilder(), keyAttributeNames);
	}

	/**
	 * Creates XML combiner using given document builder and an id attribute name.
	 */
	public XmlCombiner(DocumentBuilder documentBuilder, String keyAttributeNames) {
		this(documentBuilder, Lists.newArrayList(keyAttributeNames));
	}

	public XmlCombiner(DocumentBuilder documentBuilder, List<String> keyAttributeNames) {
		this.documentBuilder = documentBuilder;
		document = documentBuilder.newDocument();
		this.defaultAttributeNames = keyAttributeNames;
	}

	/**
	 * Sets the filter.
	 */
	public void setFilter(Filter filter) {
		if (filter ==  null) {
			this.filter = NULL_FILTER;
			return;
		}
		this.filter = filter;
	}

	/**
	 * Combine given file.
	 * @param file file to combine
	 */
	public void combine(Path file) throws SAXException, IOException {
		combine(documentBuilder.parse(file.toFile()));
	}

	/**
	 * Combine given input stream.
	 * @param stream input stream to combine
	 */
	public void combine(InputStream stream) throws SAXException, IOException {
		combine(documentBuilder.parse(stream));
	}

	/**
	 * Combine given document.
	 * @param document document to combine
	 */
	public void combine(Document document) {
		combine(document.getDocumentElement());
	}

	/**
	 * Combine given element.
	 * @param element element to combine
	 */
	public void combine(Element element) {
		Element parent = document.getDocumentElement();
		if (parent != null) {
			document.removeChild(parent);
		}
		Context result = combine(Context.fromElement(parent), Context.fromElement(element));
		result.addAsChildTo(document);
	}

	/**
	 * Return the result of the merging process.
	 */
	public Document buildDocument() {
		filterOutDefaults(Context.fromElement(document.getDocumentElement()));
		filterOutCombines(document.getDocumentElement());
		return document;
	}

	/**
	 * Stores the result of the merging process.
	 */
	public void buildDocument(OutputStream out) throws TransformerException {
		Document result = buildDocument();

		Transformer transformer = TransformerFactory.newInstance().newTransformer();
		Result output = new StreamResult(out);
		Source input = new DOMSource(result);

		transformer.transform(input, output);
	}

	/**
	 * Stores the result of the merging process.
	 */
	public void buildDocument(Path path) throws TransformerException, FileNotFoundException {
		buildDocument(new FileOutputStream(path.toFile()));
	}

	private Context combine(Context recessive, Context dominant) {
		CombineSelf dominantCombineSelf = getCombineSelf(dominant.getElement());
		CombineSelf recessiveCombineSelf = getCombineSelf(recessive.getElement());

		if (dominantCombineSelf == CombineSelf.REMOVE) {
			return null;
		} else if (dominantCombineSelf == CombineSelf.OVERRIDE
				|| (recessiveCombineSelf == CombineSelf.OVERRIDABLE)) {
			Context result = copyRecursively(dominant);
			result.getElement().removeAttribute(CombineSelf.ATTRIBUTE_NAME);
			return result;
		}

		CombineChildren combineChildren = getCombineChildren(dominant.getElement());
		if (combineChildren == null) {
			combineChildren = getCombineChildren(recessive.getElement());
			if (combineChildren == null) {
				combineChildren = CombineChildren.MERGE;
			}
		}

		if (combineChildren == CombineChildren.APPEND) {
			if (recessive.getElement() != null) {
				removeWhitespaceTail(recessive.getElement());
				appendRecursively(dominant, recessive);
				return recessive;
			} else {
				return copyRecursively(dominant);
			}
		}

		Element resultElement = document.createElement(dominant.getElement().getTagName());

		copyAttributes(recessive.getElement(), resultElement);
		copyAttributes(dominant.getElement(), resultElement);

		// when dominant combineSelf is null or DEFAULTS use combineSelf from recessive
		CombineSelf combineSelf = dominantCombineSelf;
		if ((combineSelf == null && recessiveCombineSelf != CombineSelf.DEFAULTS)) {
				//|| (combineSelf == CombineSelf.DEFAULTS && recessive.getElement() != null)) {
			combineSelf = recessiveCombineSelf;
		}
		if (combineSelf != null) {
			resultElement.setAttribute(CombineSelf.ATTRIBUTE_NAME, combineSelf.name().toLowerCase());
		} else {
			resultElement.removeAttribute(CombineSelf.ATTRIBUTE_NAME);
		}

		List<String> keys = defaultAttributeNames;
		if (recessive.getElement() != null) {
			Attr keysNode = recessive.getElement().getAttributeNode(Context.KEYS_ATTRIBUTE_NAME);
			if (keysNode != null) {
				keys = Splitter.on(",").splitToList(keysNode.getValue());
			}
		}
		if (dominant.getElement() != null) {
			Attr keysNode = dominant.getElement().getAttributeNode(Context.KEYS_ATTRIBUTE_NAME);
			if (keysNode != null) {
				keys = Splitter.on(",").splitToList(keysNode.getValue());
			}
		}

		ListMultimap<Key, Context> recessiveContexts = childContextMapper.mapChildContexts(recessive, keys);
		ListMultimap<Key, Context> dominantContexts = childContextMapper.mapChildContexts(dominant, keys);

		Set<String> tagNamesInDominant = getTagNames(dominantContexts);

		// Execute only if there is at least one subelement in recessive
		if (!recessiveContexts.isEmpty()) {
			for (Entry<Key, Context> entry : recessiveContexts.entries()) {
				Key key = entry.getKey();
				Context recessiveContext = entry.getValue();

				if (key == Key.BEFORE_END) {
					continue;
				}

				if (getCombineSelf(recessiveContext.getElement()) == CombineSelf.OVERRIDABLE_BY_TAG) {
					if (!tagNamesInDominant.contains(key.getName())) {
						recessiveContext.addAsChildTo(resultElement);
						filter.postProcess(recessiveContext.getElement(), null, recessiveContext.getElement());
					}
					continue;
				}

				if (dominantContexts.get(key).size() == 1 && recessiveContexts.get(key).size() == 1) {
					Context dominantContext = dominantContexts.get(key).iterator().next();

					Context combined = combine(recessiveContext, dominantContext);
					if (combined != null) {
						combined.addAsChildTo(resultElement);
					}
				} else {
					recessiveContext.addAsChildTo(resultElement);
					if (recessiveContext.getElement() != null) {
						filter.postProcess(recessiveContext.getElement(), null, recessiveContext.getElement());
					}
				}
			}
		}

		for (Entry<Key, Context> entry : dominantContexts.entries()) {
			Key key = entry.getKey();
			Context dominantContext = entry.getValue();

			if (key == Key.BEFORE_END) {
				dominantContext.addAsChildTo(resultElement, document);
				if (dominantContext.getElement() != null) {
					filter.postProcess(null, dominantContext.getElement(), dominantContext.getElement());
				}
				// break? this should be the last anyway...
				continue;
			}
			List<Context> associatedRecessives = recessiveContexts.get(key);
			if (dominantContexts.get(key).size() == 1 && associatedRecessives.size() == 1
					&& getCombineSelf(associatedRecessives.get(0).getElement()) != CombineSelf.OVERRIDABLE_BY_TAG) {
				// already added
			} else {
				Context combined = combine(Context.fromElement(null), dominantContext);
				if (combined != null) {
					combined.addAsChildTo(resultElement);
				}
			}
		}

		Context result = new Context();
		result.setElement(resultElement);
		appendNeighbours(dominant, result);

		filter.postProcess(recessive.getElement(), dominant.getElement(), result.getElement());

		return result;
	}

	/**
	 * Copy element recursively.
	 * @param context context to copy, it is assumed it is from unrelated document
	 * @return copied element in current document
	 */
	private Context copyRecursively(Context context) {
		Context copy = new Context();

		appendNeighbours(context, copy);

		Element element = (Element) document.importNode(context.getElement(), false);
		copy.setElement(element);

		appendRecursively(context, copy);

		return copy;
	}

	/**
	 * Append neighbors from source to destination
	 * @param source source element, it is assumed it is from unrelated document
	 * @param destination destination element
	 */
	private void appendNeighbours(Context source, Context destination) {
		for (Node neighbour : source.getNeighbours()) {
			destination.addNeighbour(document.importNode(neighbour, true));
		}
	}

	/**
	 * Appends all attributes and subelements from source element do destination element.
	 * @param source source element, it is assumed it is from unrelated document
	 * @param destination destination element
	 */
	private void appendRecursively(Context source, Context destination) {
		copyAttributes(source.getElement(), destination.getElement());

		List<Context> contexts = source.groupChildContexts();

		for (Context context : contexts) {
			if (context.getElement() == null) {
				context.addAsChildTo(destination.getElement(), document);
				continue;
			}
			Context combined = combine(Context.fromElement(null), context);
			if (combined != null) {
				combined.addAsChildTo(destination.getElement());
			}
		}
	}

	/**
	 * Copies attributes from one {@link Element} to the other.
	 * @param source source element
	 * @param destination destination element
	 */
	private void copyAttributes(@Nullable Element source, Element destination) {
		if (source == null) {
			return;
		}
		NamedNodeMap attributes = source.getAttributes();
		for (int i = 0; i < attributes.getLength(); i++) {
			Attr attribute = (Attr) attributes.item(i);
			Attr destAttribute = destination.getAttributeNodeNS(attribute.getNamespaceURI(), attribute.getName());

			if (destAttribute == null) {
				destination.setAttributeNodeNS((Attr) document.importNode(attribute, true));
			} else {
				destAttribute.setValue(attribute.getValue());
			}
		}
	}

	private static CombineSelf getCombineSelf(@Nullable Element element) {
		CombineSelf combine = null;
		if (element == null) {
			return null;
		}
		Attr combineAttribute = element.getAttributeNode(CombineSelf.ATTRIBUTE_NAME);
		if (combineAttribute != null) {
			try {
				combine = CombineSelf.valueOf(combineAttribute.getValue().toUpperCase());
			} catch (IllegalArgumentException e) {
				throw new RuntimeException("The attribute 'combine' of element '"
						+ element.getTagName() + "' has invalid value '"
						+ combineAttribute.getValue(), e);
			}
		}
		return combine;
	}

	private static CombineChildren getCombineChildren(@Nullable Element element) {
		CombineChildren combine = null;
		if (element == null) {
			return null;
		}
		Attr combineAttribute = element.getAttributeNode(CombineChildren.ATTRIBUTE_NAME);
		if (combineAttribute != null) {
			try {
				combine = CombineChildren.valueOf(combineAttribute.getValue().toUpperCase());
			} catch (IllegalArgumentException e) {
				throw new RuntimeException("The attribute 'combine' of element '"
						+ element.getTagName() + "' has invalid value '"
						+ combineAttribute.getValue(), e);
			}
		}
		return combine;
	}

	private static void removeWhitespaceTail(Element element) {
		NodeList list = element.getChildNodes();
		for (int i = list.getLength() - 1; i >= 0; i--) {
			Node node = list.item(i);
			if (node instanceof Element) {
				break;
			}
			element.removeChild(node);
		}
	}

	private static void filterOutDefaults(Context context) {
		Element element = context.getElement();
		List<Context> childContexts = context.groupChildContexts();

		for (Context childContext : childContexts) {
			if (childContext.getElement() == null) {
				continue;
			}
			CombineSelf combineSelf = getCombineSelf(childContext.getElement());
			if (combineSelf == CombineSelf.DEFAULTS) {
				for (Node neighbour : childContext.getNeighbours()) {
					element.removeChild(neighbour);
				}
				element.removeChild(childContext.getElement());
			} else {
				filterOutDefaults(childContext);
			}
		}
	}

	private static void filterOutCombines(Element element) {
		element.removeAttribute(CombineSelf.ATTRIBUTE_NAME);
		element.removeAttribute(CombineChildren.ATTRIBUTE_NAME);
		element.removeAttribute(Context.KEYS_ATTRIBUTE_NAME);
		element.removeAttribute(Context.ID_ATTRIBUTE_NAME);

		NodeList childNodes = element.getChildNodes();
		for (int i = 0; i < childNodes.getLength(); i++) {
			Node item = childNodes.item(i);
			if (item instanceof Element) {
				filterOutCombines((Element) item);
			}
		}
	}

	private static Set<String> getTagNames(ListMultimap<Key, Context> dominantContexts) {
		Set<String> names = new HashSet<>();
		for (Key key : dominantContexts.keys()) {
			names.add(key.getName());
		}

		return names;
	}
}
