/*
 * Copyright 2019-2020 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.springframework.security.convention.versions;

import com.github.benmanes.gradle.versions.reporter.result.Dependency;
import com.github.benmanes.gradle.versions.reporter.result.DependencyOutdated;
import com.github.benmanes.gradle.versions.reporter.result.Result;
import com.github.benmanes.gradle.versions.reporter.result.VersionAvailable;
import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask;
import com.github.benmanes.gradle.versions.updates.gradle.GradleUpdateResult;
import com.github.benmanes.gradle.versions.updates.resolutionstrategy.ComponentSelectionRulesWithCurrent;
import com.github.benmanes.gradle.versions.updates.resolutionstrategy.ComponentSelectionWithCurrent;
import com.github.benmanes.gradle.versions.updates.resolutionstrategy.ResolutionStrategyWithCurrent;
import groovy.lang.Closure;
import org.gradle.api.Action;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.gradle.api.artifacts.component.ModuleComponentIdentifier;
import reactor.core.publisher.Mono;

import java.io.File;
import java.time.Duration;
import java.util.*;
import java.util.function.Supplier;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static org.springframework.security.convention.versions.TransitiveDependencyLookupUtils.NIMBUS_JOSE_JWT_NAME;
import static org.springframework.security.convention.versions.TransitiveDependencyLookupUtils.OIDC_SDK_NAME;

public class UpdateDependenciesPlugin implements Plugin<Project> {
	private GitHubApi gitHubApi;

	@Override
	public void apply(Project project) {
		UpdateDependenciesExtension updateDependenciesSettings = project.getExtensions().create("updateDependenciesSettings", UpdateDependenciesExtension.class, defaultFiles(project));
		if (project.hasProperty("updateMode")) {
			String updateMode = String.valueOf(project.findProperty("updateMode"));
			updateDependenciesSettings.setUpdateMode(UpdateDependenciesExtension.UpdateMode.valueOf(updateMode));
		}
		if (project.hasProperty("nextVersion")) {
			String nextVersion = String.valueOf(project.findProperty("nextVersion"));
			updateDependenciesSettings.getGitHub().setMilestone(nextVersion);
		}
		if (project.hasProperty("gitHubAccessToken")) {
			String gitHubAccessToken = String.valueOf(project.findProperty("gitHubAccessToken"));
			updateDependenciesSettings.getGitHub().setAccessToken(gitHubAccessToken);
		}
		project.getTasks().register("updateDependencies", DependencyUpdatesTask.class, new Action<DependencyUpdatesTask>() {
			@Override
			public void execute(DependencyUpdatesTask updateDependencies) {
				updateDependencies.setDescription("Update the dependencies");
				updateDependencies.setCheckConstraints(true);
				updateDependencies.setOutputFormatter(new Closure<Void>(null) {
					@Override
					public Void call(Object argument) {
						Result result = (Result) argument;
						if (gitHubApi == null && updateDependenciesSettings.getUpdateMode() != UpdateDependenciesExtension.UpdateMode.COMMIT) {
							gitHubApi = new GitHubApi(updateDependenciesSettings.getGitHub().getAccessToken());
						}
						updateDependencies(result, project, updateDependenciesSettings);
						updateGradleVersion(result, project, updateDependenciesSettings);
						return null;
					}
				});
				updateDependencies.resolutionStrategy(new Action<ResolutionStrategyWithCurrent>() {
					@Override
					public void execute(ResolutionStrategyWithCurrent resolution) {
						resolution.componentSelection(new Action<ComponentSelectionRulesWithCurrent>() {
							@Override
							public void execute(ComponentSelectionRulesWithCurrent components) {
								updateDependenciesSettings.getExcludes().getActions().forEach((action) -> {
									components.all(action);
								});
								updateDependenciesSettings.getExcludes().getComponents().forEach((action) -> {
									action.execute(components);
								});
								components.all((selection) -> {
									ModuleComponentIdentifier candidate = selection.getCandidate();
									if (candidate.getGroup().startsWith("org.apache.directory.") && !candidate.getVersion().equals(selection.getCurrentVersion())) {
										selection.reject("org.apache.directory.* has breaking changes in newer versions");
									}
								});
								String jaxbBetaRegex = ".*?b\\d+.*";
								components.withModule("javax.xml.bind:jaxb-api", excludeWithRegex(jaxbBetaRegex, "Reject jaxb-api beta versions"));
								components.withModule("com.sun.xml.bind:jaxb-impl", excludeWithRegex(jaxbBetaRegex, "Reject jaxb-api beta versions"));
								components.withModule("commons-collections:commons-collections", excludeWithRegex("^\\d{3,}.*", "Reject commons-collections date based releases"));
							}
						});
					}
				});
			}
		});
	}

	private void updateDependencies(Result result, Project project, UpdateDependenciesExtension updateDependenciesSettings) {
		SortedSet<DependencyOutdated> dependencies = result.getOutdated().getDependencies();
		if (dependencies.isEmpty()) {
			return;
		}
		Map<String, List<DependencyOutdated>> groups = new LinkedHashMap<>();
		dependencies.forEach(outdated -> {
			groups.computeIfAbsent(outdated.getGroup(), (key) -> new ArrayList<>()).add(outdated);
		});
		List<DependencyOutdated> nimbusds = groups.getOrDefault("com.nimbusds", new ArrayList<>());
		DependencyOutdated oidcSdc = nimbusds.stream().filter(d -> d.getName().equals(OIDC_SDK_NAME)).findFirst().orElseGet(() -> null);
		if(oidcSdc != null) {
			String oidcVersion = updatedVersion(oidcSdc);
			String jwtVersion = TransitiveDependencyLookupUtils.lookupJwtVersion(oidcVersion);

			Dependency nimbusJoseJwtDependency = result.getCurrent().getDependencies().stream().filter(d -> d.getName().equals(NIMBUS_JOSE_JWT_NAME)).findFirst().get();
			DependencyOutdated outdatedJwt = new DependencyOutdated();
			outdatedJwt.setVersion(nimbusJoseJwtDependency.getVersion());
			outdatedJwt.setGroup(oidcSdc.getGroup());
			outdatedJwt.setName(NIMBUS_JOSE_JWT_NAME);
			VersionAvailable available = new VersionAvailable();
			available.setRelease(jwtVersion);
			outdatedJwt.setAvailable(available);
			nimbusds.add(outdatedJwt);
		}
		File gradlePropertiesFile = project.getRootProject().file(Project.GRADLE_PROPERTIES);
		Mono<GitHubApi.FindCreateIssueResult> createIssueResult = createIssueResultMono(updateDependenciesSettings);
		List<File> filesWithDependencies = updateDependenciesSettings.getFiles().get();
		groups.forEach((group, outdated) -> {
			outdated.forEach((dependency) -> {
				String ga = dependency.getGroup() + ":" + dependency.getName() + ":";
				String originalDependency = ga + dependency.getVersion();
				String replacementDependency = ga + updatedVersion(dependency);
				System.out.println("Update " + originalDependency + " to " + replacementDependency);
				filesWithDependencies.forEach((fileWithDependency) -> {
					updateDependencyInlineVersion(fileWithDependency, dependency);
					updateDependencyWithVersionVariable(fileWithDependency, gradlePropertiesFile, dependency);
				});
			});

			// commit
			DependencyOutdated firstDependency = outdated.get(0);
			String updatedVersion = updatedVersion(firstDependency);
			String title = outdated.size() == 1 ? "Update " + firstDependency.getName() + " to " + updatedVersion : "Update " + firstDependency.getGroup() + " to " + updatedVersion;
			afterGroup(updateDependenciesSettings, project.getRootDir(), title, createIssueResult);
		});
	}

	private void afterGroup(UpdateDependenciesExtension updateDependenciesExtension, File rootDir, String title, Mono<GitHubApi.FindCreateIssueResult> createIssueResultMono) {

		String commitMessage = title;
		if (updateDependenciesExtension.getUpdateMode() == UpdateDependenciesExtension.UpdateMode.GITHUB_ISSUE) {
			GitHubApi.FindCreateIssueResult createIssueResult = createIssueResultMono.block();
			Integer issueNumber = gitHubApi.createIssue(createIssueResult.getRepositoryId(), title, createIssueResult.getLabelIds(), createIssueResult.getMilestoneId(), createIssueResult.getAssigneeId()).delayElement(Duration.ofSeconds(1)).block();
			commitMessage += "\n\nCloses gh-" + issueNumber;
		}
		CommandLineUtils.runCommand(rootDir, "git", "commit", "-am", commitMessage);
	}

	private Mono<GitHubApi.FindCreateIssueResult> createIssueResultMono(UpdateDependenciesExtension updateDependenciesExtension) {
		return Mono.defer(() -> {
			UpdateDependenciesExtension.GitHub gitHub = updateDependenciesExtension.getGitHub();
			return gitHubApi.findCreateIssueInput(gitHub.getOrganization(), gitHub.getRepository(), gitHub.getMilestone()).cache();
		});
	}

	private void updateGradleVersion(Result result, Project project, UpdateDependenciesExtension updateDependenciesSettings) {
		if (!result.getGradle().isEnabled()) {
			return;
		}
		GradleUpdateResult current = result.getGradle().getCurrent();
		GradleUpdateResult running = result.getGradle().getRunning();
		if (current.compareTo(running) > 0) {
			String title = "Update Gradle to " + current.getVersion();
			System.out.println(title);
			CommandLineUtils.runCommand(project.getRootDir(), "./gradlew", "wrapper", "--gradle-version", current.getVersion(), "--no-daemon");
			afterGroup(updateDependenciesSettings, project.getRootDir(), title, createIssueResultMono(updateDependenciesSettings));
		}
	}

	private static Supplier<List<File>> defaultFiles(Project project) {
		return () -> {
			List<File> result = new ArrayList<>();
			result.add(project.getBuildFile());
			project.getChildProjects().values().forEach((childProject) ->
					result.add(childProject.getBuildFile())
			);
			result.add(project.getRootProject().file("buildSrc/build.gradle"));
			return result;
		};
	}

	static Action<ComponentSelectionWithCurrent> excludeWithRegex(String regex, String reason) {
		Pattern pattern = Pattern.compile(regex);
		return (selection) -> {
			String candidateVersion = selection.getCandidate().getVersion();
			if (pattern.matcher(candidateVersion).matches()) {
				selection.reject(candidateVersion + " is not allowed because it is " + reason);
			}
		};
	}

	static void updateDependencyInlineVersion(File buildFile, DependencyOutdated dependency){
		String ga = dependency.getGroup() + ":" + dependency.getName() + ":";
		String originalDependency = ga + dependency.getVersion();
		String replacementDependency = ga + updatedVersion(dependency);
		FileUtils.replaceFileText(buildFile, buildFileText -> buildFileText.replace(originalDependency, replacementDependency));
	}

	static void updateDependencyWithVersionVariable(File scanFile, File gradlePropertiesFile, DependencyOutdated dependency) {
		if (!gradlePropertiesFile.exists()) {
			return;
		}
		FileUtils.replaceFileText(gradlePropertiesFile, (gradlePropertiesText) -> {
			String ga = dependency.getGroup() + ":" + dependency.getName() + ":";
			Pattern pattern = Pattern.compile("\"" + ga + "\\$\\{?([^'\"]+?)\\}?\"");
			String buildFileText = FileUtils.readString(scanFile);
			Matcher matcher = pattern.matcher(buildFileText);
			while (matcher.find()) {
				String versionVariable = matcher.group(1);
				gradlePropertiesText = gradlePropertiesText.replace(versionVariable + "=" + dependency.getVersion(), versionVariable + "=" + updatedVersion(dependency));
			}
			return gradlePropertiesText;
		});
	}

	private static String updatedVersion(DependencyOutdated dependency) {
		VersionAvailable available = dependency.getAvailable();
		String release = available.getRelease();
		if (release != null) {
			return release;
		}
		return available.getMilestone();
	}
}
