/*******************************************************************************
 * Copyright (c) 2000, 2015 IBM Corporation and others.
 *
 * This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License 2.0
 * which accompanies this distribution, and is available at
 * https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *     IBM Corporation - initial API and implementation
 *     Stephan Herrmann <stephan@cs.tu-berlin.de> - inconsistent initialization of classpath container backed by external class folder, see https://bugs.eclipse.org/320618
 *     Thirumala Reddy Mutchukota <thirumala@google.com> - Contribution to bug: https://bugs.eclipse.org/bugs/show_bug.cgi?id=411423
 *     Terry Parker <tparker@google.com> - [performance] Low hit rates in JavaModel caches - https://bugs.eclipse.org/421165
 *     Andrey Loskutov <loskutov@gmx.de> - ExternalFoldersManager.RefreshJob interrupts auto build job - https://bugs.eclipse.org/476059
 *******************************************************************************/
package org.eclipse.jdt.internal.core;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;

import org.eclipse.core.resources.IFolder;
import org.eclipse.core.resources.IProject;
import org.eclipse.core.resources.IProjectDescription;
import org.eclipse.core.resources.IResource;
import org.eclipse.core.resources.IResourceStatus;
import org.eclipse.core.resources.IWorkspace;
import org.eclipse.core.resources.IWorkspaceRoot;
import org.eclipse.core.resources.ResourcesPlugin;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.IPath;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.MultiStatus;
import org.eclipse.core.runtime.Platform;
import org.eclipse.core.runtime.Status;
import org.eclipse.core.runtime.jobs.Job;
import org.eclipse.jdt.core.IClasspathEntry;
import org.eclipse.jdt.core.JavaCore;
import org.eclipse.jdt.core.JavaModelException;
import org.eclipse.jdt.internal.core.DeltaProcessor.RootInfo;
import org.eclipse.jdt.internal.core.util.Messages;
import org.eclipse.jdt.internal.core.util.Util;

public class ExternalFoldersManager {
	private static final boolean WINDOWS = System.getProperty("os.name").toLowerCase().contains("windows");  //$NON-NLS-1$//$NON-NLS-2$
	private static final String EXTERNAL_PROJECT_NAME = ".org.eclipse.jdt.core.external.folders"; //$NON-NLS-1$
	private static final String LINKED_FOLDER_NAME = ".link"; //$NON-NLS-1$
	private Map<IPath, IFolder> folders;
	private Set<IPath> pendingFolders; // subset of keys of 'folders', for which linked folders haven't been created yet.
	private final AtomicInteger counter = new AtomicInteger(0);
	/* Singleton instance */
	private static ExternalFoldersManager MANAGER;
	private RefreshJob refreshJob;

	private ExternalFoldersManager() {
		// Prevent instantiation
		// https://bugs.eclipse.org/bugs/show_bug.cgi?id=377806
		if (Platform.isRunning()) {
			getFolders();
		}
	}

	public static synchronized ExternalFoldersManager getExternalFoldersManager() {
		if (MANAGER == null) {
			 MANAGER = new ExternalFoldersManager();
		}
		return MANAGER;
	}

	/**
	 * Returns a set of external paths to external folders referred to on the given classpath.
	 * Returns <code>null</code> if there are none.
	 */
	public static Set<IPath> getExternalFolders(IClasspathEntry[] classpath) {
		if (classpath == null)
			return null;
		Set<IPath> folders = null;
		for (int i = 0; i < classpath.length; i++) {
			IClasspathEntry entry = classpath[i];
			if (entry.getEntryKind() == IClasspathEntry.CPE_LIBRARY) {
				IPath entryPath = entry.getPath();
				if (isExternalFolderPath(entryPath)) {
					if (folders == null)
						folders = new LinkedHashSet<>();
					folders.add(entryPath);
				}
				IPath attachmentPath = entry.getSourceAttachmentPath();
				if (isExternalFolderPath(attachmentPath)) {
					if (folders == null)
						folders = new LinkedHashSet<>();
					folders.add(attachmentPath);
				}
			}
		}
		return folders;
	}

	/**
	 * Returns <code>true</code> if the provided path is a folder external to the project.
	 * The path is expected to be one matching the {@link IClasspathEntry#CPE_LIBRARY} case in
	 * {@link IClasspathEntry#getPath()} definition.
	 */
	public static boolean isExternalFolderPath(IPath externalPath) {
		if (externalPath == null || externalPath.isEmpty()) {
			return false;
		}
		
		JavaModelManager manager = JavaModelManager.getJavaModelManager();
		if (manager.isExternalFile(externalPath) || manager.isAssumedExternalFile(externalPath)) {
			return false;
		}
		if (!externalPath.isAbsolute() || (WINDOWS && externalPath.getDevice() == null)) {
			// can be only project relative path
			return false;
		}
		// Test if this an absolute path in local file system (not the workspace path)
		File externalFolder = externalPath.toFile();
		if (Files.isRegularFile(externalFolder.toPath())) {
			manager.addExternalFile(externalPath);
			return false;
		}
		if (Files.isDirectory(externalFolder.toPath())) {
			return true;
		}
		// this can be now only full workspace path or an external path to a not existing file or folder
		if (isInternalFilePath(externalPath)) {
			return false;
		}
		if (isInternalContainerPath(externalPath)) {
			return false;
		}
		// From here on the legacy code assumes that not existing resource must be external.
		// We just follow the old assumption.
		if (externalPath.getFileExtension() != null/*likely a .jar, .zip, .rar or other file*/) {
			manager.addAssumedExternalFile(externalPath);
			// assume not existing external (?) file (?) (can also be a folder with dotted name!)
			return false;
		}
		// assume not existing external (?) folder (?)
		return true;
	}

	/**
	 * @param path full absolute workspace path
	 */
	private static boolean isInternalFilePath(IPath path) {
		IWorkspaceRoot wsRoot = ResourcesPlugin.getWorkspace().getRoot();
		// in case this is full workspace path it should start with project segment
		if(path.segmentCount() > 1 && wsRoot.getFile(path).exists()) {
			return true;
		}
		return false;
	}

	/**
	 * @param path full absolute workspace path
	 */
	private static boolean isInternalContainerPath(IPath path) {
		IWorkspaceRoot wsRoot = ResourcesPlugin.getWorkspace().getRoot();
		// in case this is full workspace path it should start with project segment
		int segmentCount = path.segmentCount();
		if(segmentCount == 1 && wsRoot.getProject(path.segment(0)).exists()) {
			return true;
		}
		if(segmentCount > 1 && wsRoot.getFolder(path).exists()) {
			return true;
		}
		return false;
	}

	public static boolean isInternalPathForExternalFolder(IPath resourcePath) {
		return EXTERNAL_PROJECT_NAME.equals(resourcePath.segment(0));
	}

	public IFolder addFolder(IPath externalFolderPath, boolean scheduleForCreation) {
		return addFolder(externalFolderPath, getExternalFoldersProject(), scheduleForCreation);
	}

	private IFolder addFolder(IPath externalFolderPath, IProject externalFoldersProject, boolean scheduleForCreation) {
		Map<IPath, IFolder> knownFolders = getFolders();

		IFolder existing;
		synchronized (this) {
			existing = knownFolders.get(externalFolderPath);
			if (existing != null) {
				return existing;
			}
		}

		IFolder result;
		do {
			result = externalFoldersProject.getFolder(LINKED_FOLDER_NAME + this.counter.incrementAndGet());
		} while (result.exists());

		synchronized (this) {
			if (scheduleForCreation) {
				if (this.pendingFolders == null)
					this.pendingFolders = new LinkedHashSet<>();
				this.pendingFolders.add(externalFolderPath);
			}
			existing = knownFolders.get(externalFolderPath);
			if (existing != null) {
				return existing;
			}
			knownFolders.put(externalFolderPath, result);
		}
		return result;
	}

	/**
	 * Try to remove the argument from the list of folders pending for creation.
	 * @param externalPath to link to
	 * @return true if the argument was found in the list of pending folders and could be removed from it.
	 */
	public synchronized boolean removePendingFolder(Object externalPath) {
		if (this.pendingFolders == null)
			return false;
		return this.pendingFolders.remove(externalPath);
	}

	public IFolder createLinkFolder(IPath externalFolderPath, boolean refreshIfExistAlready, IProgressMonitor monitor) throws CoreException {
		IProject externalFoldersProject = createExternalFoldersProject(monitor); // run outside synchronized as this can create a resource
		return createLinkFolder(externalFolderPath, refreshIfExistAlready, externalFoldersProject, monitor);
	}

	private IFolder createLinkFolder(IPath externalFolderPath, boolean refreshIfExistAlready,
									IProject externalFoldersProject, IProgressMonitor monitor) throws CoreException {

		IFolder result = addFolder(externalFolderPath, externalFoldersProject, false);
		if (!result.exists()) {
			try {
				result.createLink(externalFolderPath, IResource.ALLOW_MISSING_LOCAL, monitor);
			} catch (CoreException e) {
				// If we managed to create the folder in the meantime, don't complain
				if (!result.exists()) {
					throw e;
				}
			}
		} else if (refreshIfExistAlready) {
			result.refreshLocal(IResource.DEPTH_INFINITE,  monitor);
		}
		return result;
	}

	public void createPendingFolders(IProgressMonitor monitor) throws JavaModelException{
		synchronized (this) {
			if (this.pendingFolders == null || this.pendingFolders.isEmpty()) return;
		}

		IProject externalFoldersProject = null;
		try {
			externalFoldersProject = createExternalFoldersProject(monitor);
		}
		catch(CoreException e) {
			throw new JavaModelException(e);
		}
		// https://bugs.eclipse.org/bugs/show_bug.cgi?id=368152
		// To avoid race condition (from addFolder and removeFolder, load the map elements into an array and clear the map immediately.
		// The createLinkFolder being in the synchronized block can cause a deadlock and hence keep it out of the synchronized block.
		Object[] arrayOfFolders = null;
		synchronized (this) {
			arrayOfFolders = this.pendingFolders.toArray();
			this.pendingFolders.clear();
		}

		for (int i=0; i < arrayOfFolders.length; i++) {
			try {
				createLinkFolder((IPath) arrayOfFolders[i], false, externalFoldersProject, monitor);
			} catch (CoreException e) {
				Util.log(e, "Error while creating a link for external folder :" + arrayOfFolders[i]); //$NON-NLS-1$
			}
		}
	}

	public void cleanUp(IProgressMonitor monitor) throws CoreException {
		List<Entry<IPath, IFolder>> toDelete = getFoldersToCleanUp(monitor);
		if (toDelete == null)
			return;
		for (Entry<IPath, IFolder> entry : toDelete) {
			IFolder folder = entry.getValue();
			folder.delete(true, monitor);
			IPath key = entry.getKey();
			this.folders.remove(key);
		}
		IProject project = getExternalFoldersProject();
		if (project.isAccessible() && project.members().length == 1/*remaining member is .project*/)
			project.delete(true, monitor);
	}

	private List<Entry<IPath, IFolder>> getFoldersToCleanUp(IProgressMonitor monitor) throws CoreException {
		DeltaProcessingState state = JavaModelManager.getDeltaState();
		Map<IPath, RootInfo> roots = state.roots;
		Map<IPath, IPath> sourceAttachments = state.sourceAttachments;
		if (roots == null && sourceAttachments == null)
			return null;
		Map<IPath, IFolder> knownFolders = getFolders();
		List<Entry<IPath, IFolder>> result = null;
		synchronized (knownFolders) {
			Iterator<Entry<IPath, IFolder>> iterator = knownFolders.entrySet().iterator();
			while (iterator.hasNext()) {
				Entry<IPath, IFolder> entry = iterator.next();
				IPath path = entry.getKey();
				if ((roots != null && !roots.containsKey(path))
						&& (sourceAttachments != null && !sourceAttachments.containsKey(path))) {
					if (entry.getValue() != null) {
						if (result == null)
							result = new ArrayList<>();
						result.add(entry);
					}
				}
			}
		}
		return result;
	}

	public IProject getExternalFoldersProject() {
		return ResourcesPlugin.getWorkspace().getRoot().getProject(EXTERNAL_PROJECT_NAME);
	}

	public IProject createExternalFoldersProject(IProgressMonitor monitor) throws CoreException {
		IProject project = getExternalFoldersProject();
		if (!project.isAccessible()) {
			if (!project.exists()) {
				createExternalFoldersProject(project, monitor);
			}
			openExternalFoldersProject(project, monitor);
		}
		return project;
	}

	/*
	 * Attempt to open the given project (assuming it exists).
	 * If failing to open, make all attempts to recreate the missing pieces.
	 */
	private void openExternalFoldersProject(IProject project, IProgressMonitor monitor) throws CoreException {
		try {
			project.open(monitor);
		} catch (CoreException e1) {
			if (e1.getStatus().getCode() == IResourceStatus.FAILED_READ_METADATA) {
				// workspace was moved
				// (see https://bugs.eclipse.org/bugs/show_bug.cgi?id=241400 and https://bugs.eclipse.org/bugs/show_bug.cgi?id=252571 )
				project.delete(false/*don't delete content*/, true/*force*/, monitor);
				createExternalFoldersProject(project, monitor);
			} else {
				// .project or folder on disk have been deleted, recreate them
				IPath stateLocation = JavaCore.getPlugin().getStateLocation();
				IPath projectPath = stateLocation.append(EXTERNAL_PROJECT_NAME);
				try {
					Files.createDirectories(projectPath.toFile().toPath());
					try (FileOutputStream output = new FileOutputStream(projectPath.append(".project").toOSString())){ //$NON-NLS-1$
				        output.write((
				        		"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" + //$NON-NLS-1$
				        		"<projectDescription>\n" + //$NON-NLS-1$
				        		"	<name>" + EXTERNAL_PROJECT_NAME + "</name>\n" + //$NON-NLS-1$ //$NON-NLS-2$
				        		"	<comment></comment>\n" + //$NON-NLS-1$
				        		"	<projects>\n" + //$NON-NLS-1$
				        		"	</projects>\n" + //$NON-NLS-1$
				        		"	<buildSpec>\n" + //$NON-NLS-1$
				        		"	</buildSpec>\n" + //$NON-NLS-1$
				        		"	<natures>\n" + //$NON-NLS-1$
				        		"	</natures>\n" + //$NON-NLS-1$
				        		"</projectDescription>").getBytes()); //$NON-NLS-1$
				    }
				} catch (IOException e) {
					// fallback to re-creating the project
					project.delete(false/*don't delete content*/, true/*force*/, monitor);
					createExternalFoldersProject(project, monitor);
				}
			}
			project.open(monitor);
		}
	}


	private void createExternalFoldersProject(IProject project, IProgressMonitor monitor) throws CoreException {
		IProjectDescription desc = project.getWorkspace().newProjectDescription(project.getName());
		IPath stateLocation = JavaCore.getPlugin().getStateLocation();
		desc.setLocation(stateLocation.append(EXTERNAL_PROJECT_NAME));
		try {
			project.create(desc, IResource.HIDDEN, monitor);
		} catch (CoreException e) {
			// If we managed to create the project in the meantime, don't complain
			if (!project.exists()) {
				throw e;
			}
		}
	}

	public IFolder getFolder(IPath externalFolderPath) {
		return getFolders().get(externalFolderPath);
	}

	private Map<IPath, IFolder> getFolders() {
		if (this.folders == null) {
			Map<IPath, IFolder> tempFolders = new LinkedHashMap<>();
			IProject project = getExternalFoldersProject();
			try {
				if (!project.isAccessible()) {
					if (project.exists()) {
						// workspace was moved (see https://bugs.eclipse.org/bugs/show_bug.cgi?id=252571 )
						openExternalFoldersProject(project, null/*no progress*/);
					} else {
						// if project doesn't exist, do not open and recreate it as it means that there are no external folders
						return this.folders = Collections.synchronizedMap(tempFolders);
					}
				}
				IResource[] members = project.members();
				for (IResource member : members) {
					if (member.getType() == IResource.FOLDER && member.isLinked() && member.getName().startsWith(LINKED_FOLDER_NAME)) {
						IPath externalFolderPath = member.getLocation();
						tempFolders.put(externalFolderPath, (IFolder) member);
					}
				}
			} catch (CoreException e) {
				Util.log(e, "Exception while initializing external folders"); //$NON-NLS-1$
			}
			this.folders = Collections.synchronizedMap(tempFolders);
		}
		return this.folders;
	}

	// https://bugs.eclipse.org/bugs/show_bug.cgi?id=313153
	// Use the same RefreshJob if the job is still available
	private synchronized void runRefreshJob(Collection<IPath> paths) {
		if (paths == null || paths.isEmpty()) {
			return;
		}
		if (this.refreshJob == null) {
			this.refreshJob = new RefreshJob();
		}
		this.refreshJob.addFoldersToRefresh(paths);
	}

	/*
	 * Refreshes the external folders referenced on the classpath of the given source project
	 */
	public void refreshReferences(final IProject[] sourceProjects, IProgressMonitor monitor) {
		IProject externalProject = getExternalFoldersProject();
		try {
			Set<IPath> externalFolders = null;
			for (int index = 0; index < sourceProjects.length; index++) {
				if (sourceProjects[index].equals(externalProject))
					continue;
				if (!JavaProject.hasJavaNature(sourceProjects[index]))
					continue;

				Set<IPath> foldersInProject = getExternalFolders(((JavaProject) JavaCore.create(sourceProjects[index])).getResolvedClasspath());

				if (foldersInProject == null || foldersInProject.size() == 0)
					continue;
				if (externalFolders == null)
					externalFolders = new LinkedHashSet<>();

				externalFolders.addAll(foldersInProject);
			}
			runRefreshJob(externalFolders);

		} catch (CoreException e) {
			Util.log(e, "Exception while refreshing external project"); //$NON-NLS-1$
		}
	}

	public void refreshReferences(IProject source, IProgressMonitor monitor) {
		IProject externalProject = getExternalFoldersProject();
		if (source.equals(externalProject))
			return;
		if (!JavaProject.hasJavaNature(source))
			return;
		try {
			Set<IPath> externalFolders = getExternalFolders(((JavaProject) JavaCore.create(source)).getResolvedClasspath());
			runRefreshJob(externalFolders);
		} catch (CoreException e) {
			Util.log(e, "Exception while refreshing external project"); //$NON-NLS-1$
		}
	}

	public IFolder removeFolder(IPath externalFolderPath) {
		return getFolders().remove(externalFolderPath);
	}

	static class RefreshJob extends Job {

		final LinkedHashSet<IPath> externalFolders;

		RefreshJob(){
			super(Messages.refreshing_external_folders);
			// bug 476059: don't interrupt autobuild by using rule and system flag.
			setSystem(true);
			IWorkspace workspace = ResourcesPlugin.getWorkspace();
			setRule(workspace.getRuleFactory().refreshRule(workspace.getRoot()));
			this.externalFolders = new LinkedHashSet<>();
		}

		@Override
		public boolean belongsTo(Object family) {
			return family == ResourcesPlugin.FAMILY_MANUAL_REFRESH;
		}

		/*
		 * Add the collection of paths to be refreshed to the already
		 * existing set of paths and schedules the job
		 */
		public void addFoldersToRefresh(Collection<IPath> paths) {
			boolean shouldSchedule;
			synchronized (this.externalFolders) {
				this.externalFolders.addAll(paths);
				shouldSchedule = !this.externalFolders.isEmpty();
			}
			if (shouldSchedule) {
				schedule();
			}
		}

		@Override
		protected IStatus run(IProgressMonitor pm) {
			MultiStatus errors = new MultiStatus(JavaCore.PLUGIN_ID, IStatus.OK,
					"Exception while refreshing external folders", null); //$NON-NLS-1$
			while (true) {
				IPath externalPath;
				synchronized (this.externalFolders) {
					if (this.externalFolders.isEmpty()) {
						return errors.isOK()? Status.OK_STATUS : errors;
					}
					// keep the path in the list to avoid re-adding it while we are working
					externalPath = this.externalFolders.iterator().next();
				}

				try {
					IFolder folder = getExternalFoldersManager().getFolder(externalPath);
					// https://bugs.eclipse.org/bugs/show_bug.cgi?id=321358
					if (folder != null) {
						folder.refreshLocal(IResource.DEPTH_INFINITE, pm);
					}
				} catch (CoreException e) {
					errors.merge(e.getStatus());
				} finally {
					// we should always remove the path to avoid endless loop trying to refresh it
					synchronized (this.externalFolders) {
						this.externalFolders.remove(externalPath);
					}
				}
			}
		}
	}

}
