/*
 * Copyright 2021-2024 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.core.converter;

import org.apiguardian.api.API;
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.Node;
import org.w3c.dom.NodeList;

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.stream.Stream;

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;
import static org.apiguardian.api.API.Status.EXPERIMENTAL;
import static org.opentest4j.reporting.tooling.core.util.DomUtils.matches;
import static org.opentest4j.reporting.tooling.core.util.DomUtils.parseNamespace;
import static org.opentest4j.reporting.tooling.core.util.DomUtils.stream;

/**
 * Default implementation of {@link Converter}.
 *
 * @since 0.1.0
 */
@API(status = EXPERIMENTAL, since = "0.1.0")
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());

	private final DocumentBuilderFactory factory;

	/**
	 * Create a new instance.
	 */
	public DefaultConverter() {
		this.factory = DocumentBuilderFactory.newInstance();
		factory.setNamespaceAware(true);
	}

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

		Document sourceDocument = factory.newDocumentBuilder().parse(eventsXmlFile.toFile());
		Document targetDocument = convert(sourceDocument.getDocumentElement());

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

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

	/**
	 * Convert the supplied event-based report into the hierarchical format.
	 *
	 * @param sourceRoot the root of the source document
	 * @return the converted document
	 * @throws Exception if an error occurs during the conversion
	 */
	public Document convert(Node sourceRoot) throws Exception {
		Document targetDocument = factory.newDocumentBuilder().newDocument();

		var targetNamespace = determineTargetNamespace(sourceRoot);
		createRootElement(sourceRoot, targetDocument, targetNamespace);

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

		for (Node child = sourceRoot.getFirstChild(); child != null; child = child.getNextSibling()) {
			if (child instanceof Element element) {
				if (matches(Started.ELEMENT, element)) {
					started(targetDocument, nodeById, element, targetNamespace);
				}
				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);
				}
			}
		}
		return targetDocument;
	}

	private static Namespace determineTargetNamespace(Node sourceRoot) {
		return parseNamespace(Namespace.REPORTING_HIERARCHY.getUri()).orElseThrow() //
				.withVersion(parseNamespace(sourceRoot.getNamespaceURI()).orElseThrow().getVersion()) //
				.toNamespace();
	}

	private void createRootElement(Node sourceRoot, Document targetDocument, Namespace targetNamespace) {
		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,
			targetNamespace.getUri());
	}

	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,
			Namespace targetNamespace) {
		Element targetElement;
		if (sourceElement.hasAttribute(Started.PARENT_ID.getSimpleName())) {
			targetElement = targetDocument.createElementNS(targetNamespace.getUri(), CHILD_NODE_NAME);
			nodeById.get(sourceElement.getAttribute(Started.PARENT_ID.getSimpleName())).appendChild(targetElement);
		}
		else {
			targetElement = targetDocument.createElementNS(targetNamespace.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())) //
				.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);
				});
	}
}
