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

import org.apiguardian.api.API;
import org.opentest4j.reporting.schema.Namespace;
import org.w3c.dom.ls.LSInput;
import org.w3c.dom.ls.LSResourceResolver;
import org.xml.sax.ErrorHandler;
import org.xml.sax.SAXException;
import org.xml.sax.SAXParseException;

import javax.xml.catalog.CatalogFeatures;
import javax.xml.catalog.CatalogFeatures.Feature;
import javax.xml.catalog.CatalogResolver;
import javax.xml.transform.Source;
import javax.xml.transform.stream.StreamSource;
import javax.xml.validation.SchemaFactory;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import static java.util.Objects.requireNonNull;
import static javax.xml.XMLConstants.W3C_XML_SCHEMA_NS_URI;
import static javax.xml.catalog.CatalogManager.catalogResolver;
import static org.apiguardian.api.API.Status.EXPERIMENTAL;
import static org.opentest4j.reporting.tooling.core.validator.Severity.ERROR;
import static org.opentest4j.reporting.tooling.core.validator.Severity.WARNING;

/**
 * Default implementation of {@link Validator}.
 *
 * @since 0.1.0
 */
@API(status = EXPERIMENTAL, since = "0.1.0")
public class DefaultValidator implements Validator {

	private static final Map<Namespace, String> SCHEMAS = Map.of( //
		Namespace.REPORTING_EVENTS_0_1_0, "/org/opentest4j/reporting/schema/events-0.1.0.xsd", //
		Namespace.REPORTING_HIERARCHY_0_1_0, "/org/opentest4j/reporting/schema/hierarchy-0.1.0.xsd", //
		Namespace.REPORTING_CORE_0_1_0, "/org/opentest4j/reporting/schema/core-0.1.0.xsd", //
		Namespace.REPORTING_JAVA_0_1_0, "/org/opentest4j/reporting/schema/java-0.1.0.xsd", //
		Namespace.REPORTING_EVENTS_0_2_0, "/org/opentest4j/reporting/schema/events-0.2.0.xsd", //
		Namespace.REPORTING_HIERARCHY_0_2_0, "/org/opentest4j/reporting/schema/hierarchy-0.2.0.xsd", //
		Namespace.REPORTING_CORE_0_2_0, "/org/opentest4j/reporting/schema/core-0.2.0.xsd", //
		Namespace.REPORTING_GIT_0_2_0, "/org/opentest4j/reporting/schema/git-0.2.0.xsd", //
		Namespace.REPORTING_JAVA_0_2_0, "/org/opentest4j/reporting/schema/java-0.2.0.xsd");

	private final SchemaFactory schemaFactory = SchemaFactory.newInstance(W3C_XML_SCHEMA_NS_URI);
	private final CatalogResolver catalogResolver;

	/**
	 * Create a new instance.
	 *
	 * @param catalogs for resolving references to XML schemas
	 */
	public DefaultValidator(URI... catalogs) {
		var features = CatalogFeatures.builder().with(Feature.RESOLVE, "continue").build();
		this.catalogResolver = catalogResolver(features, catalogs);
	}

	@Override
	public ValidationResult validate(Path xmlFile) {
		try (var in = Files.newInputStream(xmlFile)) {
			return validateSafely(xmlFile, new StreamSource(in));
		}
		catch (Exception e) {
			throw new RuntimeException("Failure during validation: " + xmlFile, e);
		}
	}

	private ValidationResult validateSafely(Path xmlFile, Source source) throws SAXException, IOException {
		var errorHandler = new CollectingErrorHandler(xmlFile);
		validate(source, errorHandler);
		return errorHandler.toValidationResult();
	}

	private void validate(Source source, ErrorHandler errorHandler) throws SAXException, IOException {
		var validator = schemaFactory.newSchema().newValidator();
		validator.setResourceResolver(createResourceResolver());
		validator.setErrorHandler(errorHandler);
		validator.validate(source);
	}

	private LSResourceResolver createResourceResolver() {
		return (type, namespaceURI, publicId, systemId, baseURI) -> {
			if (namespaceURI != null) {
				var namespace = Namespace.of(namespaceURI);
				if (SCHEMAS.containsKey(namespace)) {
					LSInputImpl input = new LSInputImpl();
					input.setPublicId(publicId);
					var schema = SCHEMAS.get(namespace);
					input.setSystemId(requireNonNull(Namespace.class.getResource(schema)).toExternalForm());
					input.setBaseURI(baseURI);
					@SuppressWarnings("resource")
					var stream = Namespace.class.getResourceAsStream(schema);
					input.setCharacterStream(new InputStreamReader(requireNonNull(stream)));
					return input;
				}
			}
			if (systemId != null) {
				return catalogResolver.resolveResource(type, namespaceURI, publicId, systemId, baseURI);
			}
			return null;
		};
	}

	static class LSInputImpl implements LSInput {

		private Reader characterStream;
		private InputStream byteStream;
		private String stringData;
		private String systemId;
		private String publicId;
		private String baseURI;
		private String encoding;
		private boolean certifiedText;

		@Override
		public Reader getCharacterStream() {
			return characterStream;
		}

		@Override
		public void setCharacterStream(Reader characterStream) {
			this.characterStream = characterStream;
		}

		@Override
		public InputStream getByteStream() {
			return byteStream;
		}

		@Override
		public void setByteStream(InputStream byteStream) {
			this.byteStream = byteStream;
		}

		@Override
		public String getStringData() {
			return stringData;
		}

		@Override
		public void setStringData(String stringData) {
			this.stringData = stringData;
		}

		@Override
		public String getSystemId() {
			return systemId;
		}

		@Override
		public void setSystemId(String systemId) {
			this.systemId = systemId;
		}

		@Override
		public String getPublicId() {
			return publicId;
		}

		@Override
		public void setPublicId(String publicId) {
			this.publicId = publicId;
		}

		@Override
		public String getBaseURI() {
			return baseURI;
		}

		@Override
		public void setBaseURI(String baseURI) {
			this.baseURI = baseURI;
		}

		@Override
		public String getEncoding() {
			return encoding;
		}

		@Override
		public void setEncoding(String encoding) {
			this.encoding = encoding;
		}

		@Override
		public boolean getCertifiedText() {
			return certifiedText;
		}

		@Override
		public void setCertifiedText(boolean certifiedText) {
			this.certifiedText = certifiedText;
		}
	}

	private static class CollectingErrorHandler implements ErrorHandler {
		private final Path xmlFile;
		private final List<ValidationMessage> messages = new ArrayList<>();

		public CollectingErrorHandler(Path xmlFile) {
			this.xmlFile = xmlFile;
		}

		@Override
		public void warning(SAXParseException e) {
			addValidationMessage(WARNING, e);
		}

		@Override
		public void error(SAXParseException e) {
			addValidationMessage(ERROR, e);
		}

		private void addValidationMessage(Severity severity, SAXParseException e) {
			var path = e.getSystemId() == null ? xmlFile.toString() : e.getSystemId();
			var location = new Location(path, e.getLineNumber(), e.getColumnNumber());
			messages.add(new ValidationMessage(severity, location, e.getMessage()));
		}

		@Override
		public void fatalError(SAXParseException e) throws SAXParseException {
			throw e;
		}

		public ValidationResult toValidationResult() {
			return new ValidationResult(List.copyOf(messages));
		}
	}
}
