/*
 * Copyright 2012-2021 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 io.spring.initializr.generator.test.buildsystem.maven;

import java.net.MalformedURLException;
import java.net.URL;
import java.nio.file.Path;
import java.util.function.Predicate;

import io.spring.initializr.generator.test.io.AbstractTextAssert;
import io.spring.initializr.generator.test.io.NodeAssert;
import io.spring.initializr.generator.test.io.TextTestUtils;
import io.spring.initializr.metadata.BillOfMaterials;
import io.spring.initializr.metadata.Dependency;
import io.spring.initializr.metadata.Repository;
import org.assertj.core.api.BooleanAssert;
import org.assertj.core.api.Condition;
import org.assertj.core.api.ObjectAssert;
import org.assertj.core.api.StringAssert;
import org.w3c.dom.DOMException;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

/**
 * Assertions for a Maven build.
 *
 * @author Stephane Nicoll
 */
public class MavenBuildAssert extends AbstractTextAssert<MavenBuildAssert> {

	private final NodeAssert pom;

	public MavenBuildAssert(String content) {
		super(content, MavenBuildAssert.class);
		this.pom = new NodeAssert(content);
	}

	public MavenBuildAssert(Path pomFile) {
		this(TextTestUtils.readContent(pomFile));
	}

	/**
	 * Assert {@code pom.xml} defines the specified parent.
	 * @param groupId the groupId of the parent
	 * @param artifactId the artifactId of the parent
	 * @param version the version of the parent
	 * @return {@code this} assertion object
	 */
	public MavenBuildAssert hasParent(String groupId, String artifactId, String version) {
		return hasText("/project/parent/groupId", groupId).hasText("/project/parent/artifactId", artifactId)
				.hasText("/project/parent/version", version);
	}

	/**
	 * Assert {@code pom.xml} uses the specified {@code groupId}.
	 * @param groupId the groupId of the project
	 * @return {@code this} assertion object
	 */
	public MavenBuildAssert hasGroupId(String groupId) {
		return hasText("/project/groupId", groupId);
	}

	/**
	 * Assert {@code pom.xml} uses the specified {@code artifactId}.
	 * @param artifactId the artifactId of the project
	 * @return {@code this} assertion object
	 */
	public MavenBuildAssert hasArtifactId(String artifactId) {
		return hasText("/project/artifactId", artifactId);
	}

	/**
	 * Assert {@code pom.xml} uses the specified {@code version}.
	 * @param version the version of the project
	 * @return {@code this} assertion object
	 */
	public MavenBuildAssert hasVersion(String version) {
		return hasText("/project/version", version);
	}

	/**
	 * Assert {@code pom.xml} uses the specified {@code packaging}.
	 * @param packaging the packaging of the project
	 * @return {@code this} assertion object
	 */
	public MavenBuildAssert hasPackaging(String packaging) {
		return hasText("/project/packaging", packaging);
	}

	/**
	 * Assert {@code pom.xml} uses the specified {@code name}.
	 * @param name the name of the project
	 * @return {@code this} assertion object
	 */
	public MavenBuildAssert hasName(String name) {
		return hasText("/project/name", name);
	}

	/**
	 * Assert {@code pom.xml} uses the specified {@code description}.
	 * @param description the description of the project
	 * @return {@code this} assertion object
	 */
	public MavenBuildAssert hasDescription(String description) {
		return hasText("/project/description", description);
	}

	/**
	 * Assert {@code pom.xml} defines the specified property.
	 * @param name the name of the property
	 * @param value the value of the property
	 * @return {@code this} assertion object
	 */
	public MavenBuildAssert hasProperty(String name, String value) {
		return hasText("/project/properties/" + name, value);
	}

	/**
	 * Assert {@code pom.xml} does not define the specified property.
	 * @param name the name of the property
	 * @return {@code this} assertion object
	 */
	public MavenBuildAssert doesNotHaveProperty(String name) {
		return doesNotHaveNode("/project/properties/" + name);
	}

	/**
	 * Assert {@code pom.xml} defines the specified number of dependencies.
	 * @param size the number of dependencies
	 * @return {@code this} assertion object
	 */
	public MavenBuildAssert hasDependenciesSize(int size) {
		this.pom.nodesAtPath("project/dependencies/dependency").hasSize(size);
		return this;
	}

	/**
	 * Assert {@code pom.xml} defines the specified dependency with no version and compile
	 * scope.
	 * @param groupId the groupId of the dependency
	 * @param artifactId the artifactId of the dependency
	 * @return {@code this} assertion object
	 */
	public MavenBuildAssert hasDependency(String groupId, String artifactId) {
		return hasDependency(groupId, artifactId, null);
	}

	/**
	 * Assert {@code pom.xml} defines the specified dependency with compile scope.
	 * @param groupId the groupId of the dependency
	 * @param artifactId the artifactId of the dependency
	 * @param version the version of the dependency
	 * @return {@code this} assertion object
	 */
	public MavenBuildAssert hasDependency(String groupId, String artifactId, String version) {
		return hasDependency(Dependency.create(groupId, artifactId, version, "compile"));
	}

	/**
	 * Assert {@code pom.xml} defines the specified dependency with the specified scope.
	 * @param groupId the groupId of the dependency
	 * @param artifactId the artifactId of the dependency
	 * @param version the version of the dependency
	 * @param scope the scope of the dependency
	 * @return {@code this} assertion object
	 */
	public MavenBuildAssert hasDependency(String groupId, String artifactId, String version, String scope) {
		return hasDependency(Dependency.create(groupId, artifactId, version, scope));
	}

	/**
	 * Assert {@code pom.xml} defines the specified dependency.
	 * @param dependency the dependency
	 * @return {@code this} assertion object
	 */
	public MavenBuildAssert hasDependency(Dependency dependency) {
		this.pom.nodesAtPath("/project/dependencies/dependency").areExactly(1, new Condition<>((candidate) -> {
			Dependency actual = toDependency(candidate);
			if (dependency.getGroupId().equals(actual.getGroupId())
					&& dependency.getArtifactId().equals(actual.getArtifactId())) {
				if (dependency.getVersion() != null) {
					if (!dependency.getVersion().equals(actual.getVersion())) {
						return false;
					}
				}
				if (dependency.getScope() != null) {
					if (!dependency.getScope().equals(actual.getScope())) {
						return false;
					}
				}
				if (dependency.getType() != null) {
					if (!dependency.getType().equals(actual.getType())) {
						return false;
					}
				}
				return true;
			}
			return false;
		}, "matching dependency"));
		return this;
	}

	/**
	 * Assert that {@code pom.xml} does not define a dependency with the specified
	 * {@code groupId} and {@code artifactId}.
	 * @param groupId the dependency's groupId
	 * @param artifactId the dependency's artifactId
	 * @return {@code this} assertion object
	 */
	public MavenBuildAssert doesNotHaveDependency(String groupId, String artifactId) {
		this.pom.nodesAtPath("/project/dependencies/dependency").noneMatch((candidate) -> {
			Dependency actual = toDependency(candidate);
			return groupId.equals(actual.getGroupId()) && artifactId.equals(actual.getArtifactId());
		});
		return this;
	}

	/**
	 * Assert {@code pom.xml} defines the specified number of boms.
	 * @param size the number of boms
	 * @return {@code this} assertion object
	 */
	public MavenBuildAssert hasBomsSize(int size) {
		this.pom.nodesAtPath("/project/dependencyManagement/dependencies/dependency").hasSize(size);
		return this;
	}

	/**
	 * Assert {@code pom.xml} defines the specified bom.
	 * @param groupId the groupId of the bom
	 * @param artifactId the artifactId of the bom
	 * @param version the version of the bom
	 * @return {@code this} assertion object
	 */
	public MavenBuildAssert hasBom(String groupId, String artifactId, String version) {
		this.pom.nodesAtPath("/project/dependencyManagement/dependencies/dependency").areExactly(1,
				new Condition<>((candidate) -> {
					BillOfMaterials actual = toBom(candidate);
					return (actual != null && actual.getGroupId().equals(groupId)
							&& actual.getArtifactId().equals(artifactId) && actual.getVersion().equals(version));
				}, "matching bom"));
		return this;
	}

	/**
	 * Assert that {@code pom.xml} does not define the specified bom.
	 * @param groupId the groupId of the bom
	 * @param artifactId the artifactId of the bom
	 * @return {@code this} assertion object
	 */
	public MavenBuildAssert doesNotHaveBom(String groupId, String artifactId) {
		this.pom.nodesAtPath("/project/dependencyManagement/dependencies/dependency").noneMatch((candidate) -> {
			BillOfMaterials actual = toBom(candidate);
			return groupId.equals(actual.getGroupId()) && artifactId.equals(actual.getArtifactId());
		});
		return this;
	}

	/**
	 * Assert {@code pom.xml} defines the specified number of repositories.
	 * @param size the number of repositories
	 * @return {@code this} assertion object
	 */
	public MavenBuildAssert hasRepositoriesSize(int size) {
		this.pom.nodesAtPath("/project/repositories/repository").hasSize(size);
		return this;
	}

	/**
	 * Assert {@code pom.xml} defines the specified repository.
	 * @param id the id of the repository
	 * @param name the name of the repository
	 * @param url the url of the repository
	 * @param snapshotsEnabled whether snapshot is enabled for the repository
	 * @return {@code this} assertion object
	 */
	public MavenBuildAssert hasRepository(String id, String name, String url, Boolean snapshotsEnabled) {
		this.pom.nodesAtPath("/project/repositories/repository").areExactly(1, new Condition<>((candidate) -> {
			String actualId = ((Element) candidate).getElementsByTagName("id").item(0).getTextContent();
			if (actualId.equals(id)) {
				Repository repository = toRepository(candidate);
				if (name != null) {
					new StringAssert(repository.getName()).isEqualTo(name);
				}
				if (url != null) {
					new ObjectAssert<>(repository.getUrl()).describedAs("URL of repository " + id).isNotNull();
					new StringAssert(repository.getUrl().toExternalForm()).isEqualTo(url);
				}
				if (snapshotsEnabled) {
					new BooleanAssert(repository.isSnapshotsEnabled()).isEqualTo(snapshotsEnabled);
				}
				return true;
			}
			return false;
		}, "matching repository"));
		return this;
	}

	/**
	 * Assert {@code pom.xml} defines a profile with the specified {@code id}.
	 * @param id the id of the profile
	 * @return {@code this} assertion object
	 */
	public MavenBuildAssert hasProfile(String id) {
		this.pom.nodesAtPath("/project/profiles/profile").areExactly(1,
				new Condition<>(profile(id), "matching profile"));
		return this;
	}

	/**
	 * Assert {@code pom.xml} does not define a profile with the specified {@code id}.
	 * @param id the id of the profile
	 * @return {@code this} assertion object
	 */
	public MavenBuildAssert doesNotHaveProfile(String id) {
		this.pom.nodesAtPath("/project/profiles/profile").noneMatch(profile(id));
		return this;
	}

	/**
	 * Assert {@code pom.xml} does not define a node with the specified {@code path}.
	 * @param path the path of the node
	 * @return this
	 */
	public MavenBuildAssert doesNotHaveNode(String path) {
		this.pom.nodeAtPath(path).isNull();
		return this;
	}

	/**
	 * Assert {@code pom.xml} contains the specified value at the specified path.
	 * @param path the path to the element
	 * @param value the expected value of the element
	 * @return this
	 */
	public MavenBuildAssert hasText(String path, String value) {
		this.pom.textAtPath(path).isEqualTo(value);
		return this;
	}

	private static Dependency toDependency(Node item) {
		if (item instanceof Element) {
			Dependency dependency = new Dependency();
			Element element = (Element) item;
			NodeList groupId = element.getElementsByTagName("groupId");
			if (groupId.getLength() > 0) {
				dependency.setGroupId(groupId.item(0).getTextContent());
			}
			NodeList artifactId = element.getElementsByTagName("artifactId");
			if (artifactId.getLength() > 0) {
				dependency.setArtifactId(artifactId.item(0).getTextContent());
			}
			NodeList version = element.getElementsByTagName("version");
			if (version.getLength() > 0) {
				dependency.setVersion(version.item(0).getTextContent());
			}
			NodeList scope = element.getElementsByTagName("scope");
			if (scope.getLength() > 0) {
				dependency.setScope(scope.item(0).getTextContent());
			}
			NodeList type = element.getElementsByTagName("type");
			if (type.getLength() > 0) {
				dependency.setType(type.item(0).getTextContent());
			}
			return dependency;
		}
		return null;
	}

	private static BillOfMaterials toBom(Node item) {
		if (item instanceof Element) {
			Element element = (Element) item;
			NodeList type = element.getElementsByTagName("type");
			NodeList scope = element.getElementsByTagName("scope");
			if (isBom(type, scope)) {
				BillOfMaterials bom = new BillOfMaterials();
				NodeList groupId = element.getElementsByTagName("groupId");
				if (groupId.getLength() > 0) {
					bom.setGroupId(groupId.item(0).getTextContent());
				}
				NodeList artifactId = element.getElementsByTagName("artifactId");
				if (artifactId.getLength() > 0) {
					bom.setArtifactId(artifactId.item(0).getTextContent());
				}
				NodeList version = element.getElementsByTagName("version");
				if (version.getLength() > 0) {
					bom.setVersion(version.item(0).getTextContent());
				}
				return bom;
			}
		}
		return null;
	}

	private static boolean isBom(NodeList type, NodeList scope) {
		if (type.getLength() == 0 || scope.getLength() == 0) {
			return false;
		}
		String typeValue = type.item(0).getTextContent();
		String scopeValue = scope.item(0).getTextContent();
		return "pom".equals(typeValue) && "import".equals(scopeValue);
	}

	private static Repository toRepository(Node item) {
		Repository repository = new Repository();
		Element element = (Element) item;
		NodeList name = element.getElementsByTagName("name");
		if (name.getLength() > 0) {
			repository.setName(name.item(0).getTextContent());
		}
		NodeList url = element.getElementsByTagName("url");
		if (url.getLength() > 0) {
			try {
				repository.setUrl(new URL(url.item(0).getTextContent()));
			}
			catch (MalformedURLException | DOMException ex) {
				throw new IllegalStateException("Cannot parse URL", ex);
			}
		}
		NodeList snapshots = element.getElementsByTagName("snapshots");
		if (snapshots.getLength() > 0) {
			Element snapshotsElement = (Element) snapshots.item(0);
			NodeList snapshotsEnabled = snapshotsElement.getElementsByTagName("enabled");
			if (snapshotsEnabled.getLength() > 0) {
				repository.setSnapshotsEnabled("true".equals(snapshotsEnabled.item(0).getTextContent()));
			}
		}
		return repository;
	}

	private static Predicate<? super Node> profile(String id) {
		return (candidate) -> {
			String actualId = ((Element) candidate).getElementsByTagName("id").item(0).getTextContent();
			return (actualId.equals(id));
		};
	}

}
