/*
 * Copyright 2021-2022 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
 *
 *      https://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.opentest4j.reporting.tooling.converter;

import org.opentest4j.reporting.events.core.Infrastructure;
import org.opentest4j.reporting.events.root.Event;
import org.opentest4j.reporting.events.root.Finished;
import org.opentest4j.reporting.events.root.Reported;
import org.opentest4j.reporting.events.root.Started;
import org.opentest4j.reporting.schema.Namespace;
import org.opentest4j.reporting.schema.QualifiedName;
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 javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.BiPredicate;
import java.util.function.IntFunction;
import java.util.stream.IntStream;
import java.util.stream.Stream;

import static java.util.stream.Collectors.toList;
import static java.util.stream.Collectors.toUnmodifiableSet;
import static javax.xml.XMLConstants.FEATURE_SECURE_PROCESSING;
import static javax.xml.XMLConstants.XMLNS_ATTRIBUTE;
import static javax.xml.XMLConstants.XMLNS_ATTRIBUTE_NS_URI;

/**
 * Default implementation of {@link Converter}.
 */
public class DefaultConverter implements Converter {

	private static final String HIERARCHY_PREFIX = "h";

	private static final String EXECUTION_NODE_NAME = HIERARCHY_PREFIX + ":execution";
	private static final String ROOT_NODE_NAME = HIERARCHY_PREFIX + ":root";
	private static final String CHILD_NODE_NAME = HIERARCHY_PREFIX + ":child";

	private static final String START_ATTRIBUTE_NAME = "start";
	private static final String DURATION_ATTRIBUTE_NAME = "duration";

	private static final Set<String> EVENT_ONLY_ATTRIBUTES = Stream.of(Event.ID, Event.TIME, Started.PARENT_ID) //
			.map(QualifiedName::getSimpleName) //
			.collect(toUnmodifiableSet());

	@Override
	public void convert(Path eventsXmlFile, Path hierarchicalXmlFile) throws Exception {

		DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
		factory.setNamespaceAware(true);
		DocumentBuilder builder = factory.newDocumentBuilder();

		Document sourceDocument = builder.parse(eventsXmlFile.toFile());
		Node sourceRoot = sourceDocument.getDocumentElement();

		Document targetDocument = builder.newDocument();
		createRootElement(sourceRoot, targetDocument);

		Map<String, Element> nodeById = new HashMap<>();

		for (Node child = sourceRoot.getFirstChild(); child != null; child = child.getNextSibling()) {
			if (child instanceof Element) {
				Element element = (Element) child;
				if (matches(Started.ELEMENT, element)) {
					started(targetDocument, nodeById, element);
				}
				else if (matches(Reported.ELEMENT, element)) {
					reported(nodeById, element);
				}
				else if (matches(Finished.ELEMENT, element)) {
					finished(nodeById, element);
				}
				else if (matches(Infrastructure.ELEMENT, element)) {
					infrastructure(targetDocument, element);
				}
			}
		}

		TransformerFactory transformerFactory = TransformerFactory.newInstance();
		transformerFactory.setFeature(FEATURE_SECURE_PROCESSING, true);
		Transformer transformer = transformerFactory.newTransformer();
		transformer.setOutputProperty(OutputKeys.INDENT, "yes");
		transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2");

		transformer.transform(new DOMSource(targetDocument), new StreamResult(hierarchicalXmlFile.toFile()));
	}

	private void createRootElement(Node sourceRoot, Document targetDocument) {
		Element targetRoot = targetDocument.createElement(EXECUTION_NODE_NAME);
		targetDocument.appendChild(targetRoot);

		copyAttributes(sourceRoot, targetRoot, (__, value) -> !Namespace.REPORTING_EVENTS.getUri().equals(value));
		targetRoot.setAttributeNS(XMLNS_ATTRIBUTE_NS_URI, XMLNS_ATTRIBUTE + ":" + HIERARCHY_PREFIX,
			Namespace.REPORTING_HIERARCHY.getUri());
	}

	private boolean matches(QualifiedName qualifiedName, Element element) {
		return qualifiedName.getNamespace().getUri().equals(element.getNamespaceURI())
				&& qualifiedName.getSimpleName().equals(element.getLocalName());
	}

	private void infrastructure(Document targetDocument, Element sourceElement) {
		Node targetElement = sourceElement.cloneNode(true);
		targetDocument.adoptNode(targetElement);
		targetDocument.getDocumentElement().appendChild(targetElement);
	}

	private void started(Document targetDocument, Map<String, Element> nodeById, Element sourceElement) {
		Element targetElement;
		if (sourceElement.hasAttribute(Started.PARENT_ID.getSimpleName())) {
			targetElement = targetDocument.createElementNS(Namespace.REPORTING_HIERARCHY.getUri(), CHILD_NODE_NAME);
			nodeById.get(sourceElement.getAttribute(Started.PARENT_ID.getSimpleName())).appendChild(targetElement);
		}
		else {
			targetElement = targetDocument.createElementNS(Namespace.REPORTING_HIERARCHY.getUri(), ROOT_NODE_NAME);
			targetDocument.getDocumentElement().appendChild(targetElement);
		}

		String id = sourceElement.getAttribute(Event.ID.getSimpleName());
		nodeById.put(id, targetElement);

		copyAttributes(sourceElement, targetElement, (name, __) -> !EVENT_ONLY_ATTRIBUTES.contains(name));
		copyChildren(sourceElement, targetElement);

		if (sourceElement.hasAttribute(Event.TIME.getSimpleName())) {
			targetElement.setAttribute(START_ATTRIBUTE_NAME, sourceElement.getAttribute(Event.TIME.getSimpleName()));
		}
	}

	private void reported(Map<String, Element> nodeById, Element sourceElement) {
		Element targetElement = nodeById.get(sourceElement.getAttribute(Event.ID.getSimpleName()));
		copyAttributes(sourceElement, targetElement, (name, __) -> !EVENT_ONLY_ATTRIBUTES.contains(name));
		mergeChildren(sourceElement, targetElement);
	}

	private void finished(Map<String, Element> nodeById, Element sourceElement) {
		Element targetElement = nodeById.get(sourceElement.getAttribute(Event.ID.getSimpleName()));

		copyAttributes(sourceElement, targetElement, (name, __) -> !EVENT_ONLY_ATTRIBUTES.contains(name));

		mergeChildren(sourceElement, targetElement);

		if (targetElement.hasAttribute(START_ATTRIBUTE_NAME)
				&& sourceElement.hasAttribute(Event.TIME.getSimpleName())) {
			Instant start = Instant.parse(targetElement.getAttribute(START_ATTRIBUTE_NAME));
			Instant finish = Instant.parse(sourceElement.getAttribute(Event.TIME.getSimpleName()));
			targetElement.setAttribute(DURATION_ATTRIBUTE_NAME, Duration.between(start, finish).toString());
		}

		moveChildrenToEnd(targetElement);
	}

	private void moveChildrenToEnd(Element targetElement) {
		stream(targetElement.getChildNodes()) //
				.filter(child -> CHILD_NODE_NAME.equals(child.getNodeName())) //
				.collect(toList()) // avoid concurrent modification
				.forEach(child -> {
					targetElement.removeChild(child);
					targetElement.appendChild(child);
				});
	}

	private void copyAttributes(Node sourceNode, Node targetNode, BiPredicate<String, String> filter) {
		stream(sourceNode.getAttributes()) //
				.filter(sourceItem -> filter.test(sourceItem.getNodeName(), sourceItem.getNodeValue())) //
				.map(sourceItem -> sourceItem.cloneNode(true)) //
				.forEach(targetItem -> {
					targetNode.getOwnerDocument().adoptNode(targetItem);
					targetNode.getAttributes().setNamedItem(targetItem);
				});
	}

	private void mergeChildren(Node sourceElement, Node targetElement) {
		stream(sourceElement.getChildNodes()) //
				.forEach(sourceChild -> findNode(targetElement.getChildNodes(), sourceChild.getNodeName()) //
						.ifPresentOrElse( //
							existingNode -> copyChildren(sourceChild, existingNode), //
							() -> {
								Node targetChild = sourceChild.cloneNode(true);
								targetElement.getOwnerDocument().adoptNode(targetChild);
								targetElement.appendChild(targetChild);
							}));
	}

	private Optional<Node> findNode(NodeList nodes, String name) {
		return stream(nodes) //
				.filter(node -> node.getNodeName().equals(name)) //
				.findAny();
	}

	private void copyChildren(Node sourceElement, Node targetElement) {
		stream(sourceElement.getChildNodes()) //
				.map(sourceChild -> sourceChild.cloneNode(true)) //
				.forEach(targetChild -> {
					targetElement.getOwnerDocument().adoptNode(targetChild);
					targetElement.appendChild(targetChild);
				});
	}

	private static Stream<Node> stream(NodeList nodeList) {
		return stream(nodeList.getLength(), nodeList::item);
	}

	private static Stream<Node> stream(NamedNodeMap namedNodeMap) {
		return stream(namedNodeMap.getLength(), namedNodeMap::item);
	}

	private static Stream<Node> stream(int length, IntFunction<Node> item) {
		return IntStream.range(0, length).mapToObj(item);
	}
}
