/*
 * Copyright 2021-2025 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 org.apiguardian.api.API;
import org.opentest4j.reporting.events.core.Attachments;
import org.opentest4j.reporting.events.core.CpuCores;
import org.opentest4j.reporting.events.core.Data;
import org.opentest4j.reporting.events.core.File;
import org.opentest4j.reporting.events.core.FilePosition;
import org.opentest4j.reporting.events.core.HostName;
import org.opentest4j.reporting.events.core.Infrastructure;
import org.opentest4j.reporting.events.core.Metadata;
import org.opentest4j.reporting.events.core.OperatingSystem;
import org.opentest4j.reporting.events.core.Output;
import org.opentest4j.reporting.events.core.Reason;
import org.opentest4j.reporting.events.core.Result;
import org.opentest4j.reporting.events.core.Sources;
import org.opentest4j.reporting.events.core.Tag;
import org.opentest4j.reporting.events.core.Tags;
import org.opentest4j.reporting.events.core.UserName;
import org.opentest4j.reporting.schema.QualifiedName;
import org.opentest4j.reporting.tooling.core.util.DomUtils;
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.Element;
import org.w3c.dom.Node;

import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.regex.Pattern;
import java.util.stream.Stream;

import static org.apiguardian.api.API.Status.INTERNAL;
import static org.opentest4j.reporting.tooling.core.util.DomUtils.findChild;
import static org.opentest4j.reporting.tooling.core.util.DomUtils.findChildren;
import static org.opentest4j.reporting.tooling.core.util.DomUtils.getAttribute;
import static org.opentest4j.reporting.tooling.core.util.DomUtils.getAttributeValue;
import static org.opentest4j.reporting.tooling.core.util.DomUtils.matches;
import static org.opentest4j.reporting.tooling.core.util.DomUtils.stream;

/**
 * Contributes sections to the HTML report elements in the core schema.
 *
 * @since 0.2.0
 */
@API(status = INTERNAL, since = "0.2.0")
public class CoreContributor implements Contributor {

	private static final Pattern CAMEL_CASE_WORD_BOUNDARY_PATTERN = Pattern.compile("([a-z])([A-Z])");
	private static final Set<String> SUPPORTED_IMAGE_MEDIA_TYPES = Set.of("image/png", "image/jpeg", "image/gif",
		"image/avif", "image/webp", "image/svg+xml");
	private static final Set<String> SUPPORTED_TEXT_MEDIA_TYPES = Set.of("text/csv", "text/plain", "text/html",
		"text/xml", "application/xml", "application/json", "application/yaml");

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

	@Override
	public List<Section> contributeSectionsForExecution(Context context) {
		var sections = new ArrayList<Section>();
		createInfrastructureSection(context.element()).ifPresent(sections::add);
		return sections;
	}

	@Override
	public List<Section> contributeSectionsForTestNode(Context context) {
		var sections = new ArrayList<Section>();
		createTagsSection(context.element()).ifPresent(sections::add);
		createSourcesSection(context.element()).ifPresent(sections::add);
		createReasonSection(context.element()).ifPresent(sections::add);
		createAttachmentsSection(context).ifPresent(sections::add);
		return sections;
	}

	private static Optional<Section> createInfrastructureSection(Element element) {
		return findChild(element, Infrastructure.ELEMENT) //
				.map(infrastructure -> {
					var table = new LinkedHashMap<String, String>();
					addToTable(infrastructure, HostName.ELEMENT, "Hostname", table::put);
					addToTable(infrastructure, UserName.ELEMENT, "Username", table::put);
					addToTable(infrastructure, OperatingSystem.ELEMENT, "Operating system", table::put);
					addToTable(infrastructure, CpuCores.ELEMENT, "CPU cores", table::put);
					return table.isEmpty() ? null : table;
				}) //
				.map(table -> {
					var keyValuePairs = KeyValuePairs.builder().content(table).build();
					return Section.builder().title("Infrastructure").addBlock(keyValuePairs).build();
				});
	}

	private static Optional<Section> createTagsSection(Element element) {
		return findChild(element, Metadata.ELEMENT) //
				.flatMap(metadata -> findChild(metadata, Tags.ELEMENT)) //
				.map(tags -> findChildren(tags, Tag.ELEMENT)) //
				.map(tags -> tags.map(Node::getTextContent).sorted().toList()) //
				.filter(sortedTags -> !sortedTags.isEmpty()) //
				.map(sortedTags -> Labels.builder().content(sortedTags).build()) //
				.map(labels -> Section.builder().title("Tags").order(0).addBlock(labels).build());
	}

	private static Optional<Section> createSourcesSection(Element element) {
		var children = findChild(element, Sources.ELEMENT) //
				.map(DomUtils::children) //
				.orElseGet(Stream::empty) //
				.filter(Element.class::isInstance) //
				.toList();

		if (children.isEmpty()) {
			return Optional.empty();
		}
		var subsections = Subsections.builder();
		children.stream().map(child -> {
			var type = child.getLocalName();
			if (child.getLocalName().endsWith("Source")) {
				type = child.getLocalName().substring(0, child.getLocalName().length() - "Source".length());
			}
			var subsection = Section.builder().title(capitalize(type));

			var attributes = KeyValuePairs.builder();
			stream(child.getAttributes()) //
					.filter(it -> it.getNodeValue() != null && !it.getNodeValue().isEmpty()) //
					.forEach(it -> attributes.putContent(prettifyCamelCaseName(it.getNodeName()), it.getNodeValue()));

			findChild(child, FilePosition.ELEMENT).ifPresent(filePosition -> {
				addToTable(getAttribute(filePosition, FilePosition.LINE), "line", attributes::putContent);
				addToTable(getAttribute(filePosition, FilePosition.COLUMN), "column", attributes::putContent);
			});

			subsection.addBlock(attributes.build());
			return subsection.build();
		}).forEach(subsections::addContent);

		return Optional.of(Section.builder().title("Sources").order(10).addBlock(subsections.build()).build());
	}

	private static Optional<Section> createReasonSection(Element element) {
		return findChild(element, Result.ELEMENT) //
				.flatMap(result -> findChild(result, Reason.ELEMENT)) //
				.map(Node::getTextContent) //
				.map(reason -> {
					var paragraph = Paragraph.builder().content(reason).build();
					return Section.builder().title("Reason").order(20).addBlock(paragraph).build();
				});
	}

	private static Optional<Section> createAttachmentsSection(Context context) {
		var children = findChild(context.element(), Attachments.ELEMENT) //
				.map(DomUtils::children) //
				.orElseGet(Stream::empty) //
				.filter(Element.class::isInstance) //
				.toList();
		if (children.isEmpty()) {
			return Optional.empty();
		}

		var subsections = Subsections.builder();
		children.stream().map(child -> {
			var type = child.getLocalName();
			var section = Section.builder().title(capitalize(type));
			if (matches(File.ELEMENT, child)) {
				addFileAttachment(context, child, section);
			}
			else if (matches(Data.ELEMENT, child)) {
				addDataAttachment(child, section);
			}
			else if (matches(Output.ELEMENT, child)) {
				addOutputAttachment(child, section);
			}
			return section.build();
		}).forEach(subsections::addContent);

		return Optional.of(Section.builder().title("Attachments").order(30).addBlock(subsections.build()).build());
	}

	private static void addFileAttachment(Context context, Node child, Section.Builder section) {
		var attributes = KeyValuePairs.builder();
		getAttributeValue(child, File.TIME).ifPresent(section::metaInfo);
		var mediaType = getAttributeValue(child, File.MEDIA_TYPE);
		getAttributeValue(child, File.PATH).ifPresent(rawPath -> {
			var pathWithNormalizedSeparators = rawPath.replace('\\', '/');
			var originalPath = context.sourceXmlFile().getParent().resolve(
				pathWithNormalizedSeparators).toAbsolutePath();
			var path = context.relativizeToTargetDirectory(originalPath);
			var filename = path.getFileName().toString();
			filename = filename.substring(Math.max(filename.lastIndexOf('/'), filename.lastIndexOf('\\')) + 1);
			attributes.putContent("Filename", filename);
			attributes.putContent("Path", "link:" + path);
			if (Files.isRegularFile(originalPath)) {
				var resolvedMediaType = mediaType.or(() -> {
					try {
						return Optional.ofNullable(Files.probeContentType(path));
					}
					catch (IOException e) {
						return Optional.empty();
					}
				}).orElse(null);
				if (resolvedMediaType != null) {
					addInlineFileBlock(section, resolvedMediaType, path, filename, originalPath);
				}
			}
		});
		mediaType.ifPresent(it -> attributes.putContent("Media type", it));
		section.addBlock(attributes.build());
	}

	private static void addInlineFileBlock(Section.Builder section, String resolvedMediaType, Path path,
			String filename, Path originalPath) {
		if (SUPPORTED_IMAGE_MEDIA_TYPES.contains(resolvedMediaType)) {
			section.addBlock(Image.builder().content(path.toString()).altText(filename).build());
		}
		else if (SUPPORTED_TEXT_MEDIA_TYPES.stream().anyMatch(resolvedMediaType::startsWith)) {
			try {
				var charset = parseCharset(resolvedMediaType);
				var content = Files.readString(originalPath, charset);
				section.addBlock(PreFormattedOutput.builder().content(content).build());
			}
			catch (IOException ignore) {
			}
		}
	}

	private static void addDataAttachment(Node child, Section.Builder section) {
		var attributes = KeyValuePairs.builder();
		getAttributeValue(child, Data.TIME).ifPresent(section::metaInfo);
		findChildren(child, Data.Entry.ELEMENT).forEach(entry -> {
			getAttributeValue(entry, Data.Entry.KEY).ifPresent(key -> {
				var value = entry.getTextContent();
				attributes.putContent(key, value);
			});
		});
		section.addBlock(attributes.build());
	}

	private static void addOutputAttachment(Node child, Section.Builder section) {
		getAttributeValue(child, Output.SOURCE) //
				.map(source -> switch (source) {
					case "stdout" -> "Standard output";
					case "stderr" -> "Standard error";
					default -> "%s (%s)".formatted("Output", source);
				}) //
				.ifPresent(section::title);
		getAttributeValue(child, Output.TIME).ifPresent(section::metaInfo);
		section.addBlock(PreFormattedOutput.builder().content(child.getTextContent()).build());
	}

	private static Charset parseCharset(String resolvedMediaType) {
		var mediaTypeParts = resolvedMediaType.split(";");
		for (var part : mediaTypeParts) {
			if (part.trim().startsWith("charset=")) {
				var charsetName = part.trim().substring("charset=".length()).trim();
				if (charsetName.startsWith("\"") && charsetName.endsWith("\"")) {
					charsetName = charsetName.substring(1, charsetName.length() - 1);
				}
				return Charset.forName(charsetName);
			}
		}
		return Charset.defaultCharset();
	}

	private static String prettifyCamelCaseName(String value) {
		var matcher = CAMEL_CASE_WORD_BOUNDARY_PATTERN.matcher(value);
		while (matcher.find()) {
			value = matcher.replaceFirst(matcher.group(1) + " " + matcher.group(2).toLowerCase(Locale.ROOT));
			matcher = CAMEL_CASE_WORD_BOUNDARY_PATTERN.matcher(value);
		}
		return capitalize(value);
	}

	static void addToTable(Node parent, QualifiedName elementName, String label, BiConsumer<String, String> table) {
		addToTable(findChild(parent, elementName), label, table);
	}

	@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
	static void addToTable(Optional<Node> node, String label, BiConsumer<String, String> table) {
		node //
				.map(Node::getTextContent) //
				.ifPresent(value -> table.accept(label, value));
	}

	private static String capitalize(String value) {
		return value.substring(0, 1).toUpperCase(Locale.ROOT) + value.substring(1);
	}

}
