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

import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonSerializer;
import org.apiguardian.api.API;
import org.opentest4j.reporting.events.core.Result;
import org.opentest4j.reporting.events.root.Events;
import org.opentest4j.reporting.schema.Namespace;
import org.opentest4j.reporting.schema.QualifiedName;
import org.opentest4j.reporting.tooling.core.converter.DefaultConverter;
import org.opentest4j.reporting.tooling.spi.htmlreport.Block;
import org.opentest4j.reporting.tooling.spi.htmlreport.Contributor;
import org.opentest4j.reporting.tooling.spi.htmlreport.Image;
import org.opentest4j.reporting.tooling.spi.htmlreport.KeyValuePairs;
import org.opentest4j.reporting.tooling.spi.htmlreport.Labels;
import org.opentest4j.reporting.tooling.spi.htmlreport.Paragraph;
import org.opentest4j.reporting.tooling.spi.htmlreport.PreFormattedOutput;
import org.opentest4j.reporting.tooling.spi.htmlreport.Section;
import org.opentest4j.reporting.tooling.spi.htmlreport.Subsections;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Scanner;
import java.util.ServiceLoader;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.Function;

import static java.util.Comparator.comparing;
import static java.util.Objects.requireNonNull;
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.stream;

/**
 * Default implementation of {@link HtmlReportWriter}.
 *
 * @since 0.2.0
 */
@API(status = EXPERIMENTAL, since = "0.2.0")
public class DefaultHtmlReportWriter implements HtmlReportWriter {

	private static final QualifiedName ROOT_ELEMENT = QualifiedName.of(Namespace.REPORTING_HIERARCHY, "root");
	private static final QualifiedName CHILD_ELEMENT = QualifiedName.of(Namespace.REPORTING_HIERARCHY, "child");

	private static final String PLACEHOLDER_SCRIPT_TAG = "<script src=\"./init.js\"></script>";

	private final ServiceLoader<Contributor> contributors = ServiceLoader.load(Contributor.class);

	/**
	 * Create a new instance.
	 */
	public DefaultHtmlReportWriter() {
	}

	@Override
	public void writeHtmlReport(List<Path> xmlFiles, Path htmlFile) throws Exception {
		var executions = extractData(xmlFiles, htmlFile);

		try (var template = new Scanner(openTemplate()); var out = Files.newBufferedWriter(htmlFile)) {
			while (template.hasNextLine()) {
				var line = template.nextLine();
				var placeHolderIndex = line.indexOf(PLACEHOLDER_SCRIPT_TAG);
				if (placeHolderIndex >= 0) {
					var indent = " ".repeat(placeHolderIndex);
					appendScriptTag(executions, indent, out);
				}
				else {
					out.write(line);
					out.newLine();
				}
			}
		}
	}

	private List<Execution> extractData(List<Path> xmlFiles, Path htmlFile) throws Exception {
		var idGenerator = new IdGenerator();
		var executions = new ArrayList<Execution>();

		for (Path xmlFile : xmlFiles) {
			var rootElement = parseDom(xmlFile);
			var name = String.format(xmlFile.getFileName().toString());
			executions.add(
				extractData(idGenerator, rootElement, name, element -> new DefaultContext(element, xmlFile, htmlFile)));
		}
		return executions;
	}

	private static void appendScriptTag(List<Execution> executions, String indent, BufferedWriter out)
			throws IOException {
		out.append(indent).append("<script>");
		out.newLine();

		out.append(indent).append("  ");
		appendJavaScript(executions, out);
		out.newLine();

		out.append(indent).append("</script>");
		out.newLine();
	}

	private static void appendJavaScript(List<Execution> executions, Appendable out) throws IOException {
		var gson = new GsonBuilder() //
				.registerTypeHierarchyAdapter(Section.class,
					(JsonSerializer<Section>) (section, typeOfSrc, context) -> {
						var jsonObject = new JsonObject();
						jsonObject.addProperty("title", section.getTitle());
						jsonObject.add("blocks", context.serialize(section.getBlocks()));
						return jsonObject;
					}) //
				.registerTypeHierarchyAdapter(KeyValuePairs.class, blockSerializer("kvp")) //
				.registerTypeHierarchyAdapter(Labels.class, blockSerializer("labels")) //
				.registerTypeHierarchyAdapter(Paragraph.class, blockSerializer("p")) //
				.registerTypeHierarchyAdapter(PreFormattedOutput.class, blockSerializer("pre")) //
				.registerTypeHierarchyAdapter(Subsections.class, blockSerializer("sub")) //
				.registerTypeHierarchyAdapter(Image.class, blockSerializer("img",
					(Image image, JsonObject json) -> json.addProperty("altText", image.getAltText()))) //
				.disableJdkUnsafe() //
				.create();

		out.append("globalThis.testExecutions = ");
		gson.toJson(executions, out);
		out.append(";");
	}

	private static <T extends Block<?>> JsonSerializer<T> blockSerializer(String type) {
		return blockSerializer(type, (__, ___) -> {
		});
	}

	private static <T extends Block<?>> JsonSerializer<T> blockSerializer(String type,
			BiConsumer<T, JsonObject> additionalElementSerializer) {
		return (block, typeOfSrc, context) -> {
			var jsonObject = new JsonObject();
			jsonObject.addProperty("type", type);
			jsonObject.add("content", context.serialize(block.getContent()));
			additionalElementSerializer.accept(block, jsonObject);
			return jsonObject;
		};
	}

	private Execution extractData(IdGenerator idGenerator, Element rootElement, String name,
			Function<Element, Contributor.Context> contextCreator) {
		var execution = new Execution(idGenerator.next(), name);
		addSections(contextCreator.apply(rootElement), execution);

		for (Node child = rootElement.getFirstChild(); child != null; child = child.getNextSibling()) {
			if (child instanceof Element && matches(ROOT_ELEMENT, (Element) child)) {
				var root = visitNode((Element) child, idGenerator, new ArrayDeque<>(), execution, contextCreator);
				execution.durationMillis += root.durationMillis;
				execution.roots.add(root.id);
			}
		}
		return execution;
	}

	private TestNode visitNode(Element node, IdGenerator idGenerator, Deque<String> parentIds, Execution execution,
			Function<Element, Contributor.Context> contextCreator) {
		String currentId = idGenerator.next();

		String status = stream(node.getChildNodes()) //
				.filter(it -> it instanceof Element && matches(Result.ELEMENT, (Element) it)).findAny().map(
					it -> ((Element) it).getAttribute(Result.STATUS.getSimpleName())).orElse(null);

		var duration = Duration.parse(node.getAttribute("duration"));
		var testNode = new TestNode(currentId, node.getAttribute("name"), duration.toMillis(), status);
		execution.testNodes.add(testNode);

		addSections(contextCreator.apply(node), testNode);

		ChildMetadata parentChildMetadata = null;
		if (!parentIds.isEmpty()) {
			String parentId = parentIds.peek();
			parentChildMetadata = execution.children.computeIfAbsent(parentId, __ -> new ChildMetadata());
			parentChildMetadata.addChild(currentId, status);
		}
		parentIds.push(currentId);
		for (Node child = node.getFirstChild(); child != null; child = child.getNextSibling()) {
			if (child instanceof Element && matches(CHILD_ELEMENT, (Element) child)) {
				visitNode((Element) child, idGenerator, parentIds, execution, contextCreator);
			}
		}
		parentIds.pop();
		ChildMetadata currentNodeChildMetadata = execution.children.get(currentId);
		if (currentNodeChildMetadata != null && parentChildMetadata != null) {
			parentChildMetadata.childStatuses.addAll(currentNodeChildMetadata.childStatuses);
		}

		return testNode;
	}

	private void addSections(Contributor.Context context, Execution execution) {
		contributeSections(context, Contributor::contributeSectionsForExecution, execution.sections);
	}

	private void addSections(Contributor.Context context, TestNode node) {
		contributeSections(context, Contributor::contributeSectionsForTestNode, node.sections);
	}

	private void contributeSections(Contributor.Context context,
			BiFunction<Contributor, Contributor.Context, List<Section>> call, List<Section> sections) {
		contributors.stream().flatMap(it -> call.apply(it.get(), context).stream()).sorted(
			comparing(Section::getOrder)).forEach(sections::add);
	}

	static class IdGenerator {
		private int nextId = 1;

		String next() {
			return Integer.toString(nextId++);
		}
	}

	private static Element parseDom(Path xmlFile) throws Exception {
		DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
		factory.setNamespaceAware(true);
		DocumentBuilder builder = factory.newDocumentBuilder();

		Document sourceDocument = builder.parse(xmlFile.toFile());
		Element element = sourceDocument.getDocumentElement();

		if (matches(Events.ELEMENT, element)) {
			return new DefaultConverter().convert(element).getDocumentElement();
		}

		return element;
	}

	private InputStream openTemplate() {
		return requireNonNull(getClass().getResourceAsStream("template.html"));
	}

	private static class Execution {

		public final String id;
		public final String name;
		public long durationMillis;

		public final List<Section> sections = new ArrayList<>();

		public final List<String> roots = new ArrayList<>();
		public final Map<String, ChildMetadata> children = new LinkedHashMap<>();
		public final List<TestNode> testNodes = new ArrayList<>();

		Execution(String id, String name) {
			this.id = id;
			this.name = name;
		}
	}

	private record ChildMetadata(List<String> ids, Set<String> childStatuses) {

		ChildMetadata() {
			this(new ArrayList<>(), new HashSet<>());
		}

		private void addChild(String childId, String status) {
			ids.add(childId);
			if (status != null) {
				childStatuses.add(status);
			}
		}
	}

	private record TestNode(String id, String name, long durationMillis, String status, List<Section> sections) {

		TestNode(String id, String name, long durationMillis, String status) {
			this(id, name, durationMillis, status, new ArrayList<>());
		}
	}

	private record DefaultContext(Element element, Path sourceXmlFile, Path targetHtmlFile)
			implements Contributor.Context {
	}
}
