/*******************************************************************************
 * Copyright (c) 2012-2016 Igor Fedorenko
 *
 * 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:
 *      Igor Fedorenko - initial API and implementation
 *******************************************************************************/
package org.eclipse.jdt.internal.launching.sourcelookup.advanced;

import static org.eclipse.jdt.core.IJavaElementDelta.F_ADDED_TO_CLASSPATH;
import static org.eclipse.jdt.core.IJavaElementDelta.F_CLASSPATH_CHANGED;
import static org.eclipse.jdt.core.IJavaElementDelta.F_CLOSED;
import static org.eclipse.jdt.core.IJavaElementDelta.F_OPENED;
import static org.eclipse.jdt.core.IJavaElementDelta.F_REMOVED_FROM_CLASSPATH;
import static org.eclipse.jdt.core.IJavaElementDelta.F_RESOLVED_CLASSPATH_CHANGED;

import java.io.File;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Supplier;

import org.eclipse.core.resources.ResourcesPlugin;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.IConfigurationElement;
import org.eclipse.core.runtime.IExtensionRegistry;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.Platform;
import org.eclipse.core.runtime.SubMonitor;
import org.eclipse.debug.core.sourcelookup.ISourceContainer;
import org.eclipse.jdt.core.ElementChangedEvent;
import org.eclipse.jdt.core.IElementChangedListener;
import org.eclipse.jdt.core.IJavaElement;
import org.eclipse.jdt.core.IJavaElementDelta;
import org.eclipse.jdt.core.IJavaModel;
import org.eclipse.jdt.core.IJavaProject;
import org.eclipse.jdt.core.IPackageFragmentRoot;
import org.eclipse.jdt.core.JavaCore;
import org.eclipse.jdt.core.JavaModelException;
import org.eclipse.jdt.internal.launching.sourcelookup.advanced.FileHashing.Hasher;
import org.eclipse.jdt.launching.sourcelookup.advanced.IWorkspaceProjectDescriber;
import org.eclipse.jdt.launching.sourcelookup.advanced.IWorkspaceProjectDescriber.IJavaProjectSourceDescription;
import org.eclipse.jdt.launching.sourcelookup.containers.PackageFragmentRootSourceContainer;

/**
 * Workspace project source container factory.
 *
 * <p>
 * The factory creates both project and project classpath entry containers. Both projects and project classpath entries can be identified by their
 * filesystem location and, if the location is a file, by the file SHA1 checksum.
 *
 * <p>
 * The factory maintains up-to-date registry of workspace projects and their classpath entries and can be used to create source containers fast enough
 * to be used from UI thread.
 */
public class WorkspaceProjectSourceContainers {
	private final IElementChangedListener changeListener = new IElementChangedListener() {
		@Override
		public void elementChanged(ElementChangedEvent event) {
			try {
				final Set<IJavaProject> remove = new HashSet<>();
				final Set<IJavaProject> add = new HashSet<>();

				processDelta(event.getDelta(), remove, add);

				if (!remove.isEmpty() || !add.isEmpty()) {
					AdvancedSourceLookupSupport.schedule((m) -> updateProjects(remove, add, m));
				}
			}
			catch (CoreException e) {
				// maybe do something about it?
			}
		}

		private void processDelta(final IJavaElementDelta delta, Set<IJavaProject> remove, Set<IJavaProject> add) throws CoreException {
			// TODO review, this looks too complicated to add/remove java projects

			final IJavaElement element = delta.getElement();
			final int kind = delta.getKind();
			switch (element.getElementType()) {
				case IJavaElement.JAVA_MODEL:
					processChangedChildren(delta, remove, add);
					break;
				case IJavaElement.JAVA_PROJECT:
					switch (kind) {
						case IJavaElementDelta.REMOVED:
							remove.add((IJavaProject) element);
							break;
						case IJavaElementDelta.ADDED:
							add.add((IJavaProject) element);
							break;
						case IJavaElementDelta.CHANGED:
							if ((delta.getFlags() & F_CLOSED) != 0) {
								remove.add((IJavaProject) element);
							} else if ((delta.getFlags() & F_OPENED) != 0) {
								add.add((IJavaProject) element);
							} else if ((delta.getFlags() & (F_CLASSPATH_CHANGED | F_RESOLVED_CLASSPATH_CHANGED)) != 0) {
								remove.add((IJavaProject) element);
								add.add((IJavaProject) element);
							}
							break;
					}
					processChangedChildren(delta, remove, add);
					break;
				case IJavaElement.PACKAGE_FRAGMENT_ROOT:
					if ((delta.getFlags() & (F_ADDED_TO_CLASSPATH | F_REMOVED_FROM_CLASSPATH)) != 0) {
						remove.add(element.getJavaProject());
						add.add(element.getJavaProject());
					}
					break;
			}
		}

		private void processChangedChildren(IJavaElementDelta delta, Set<IJavaProject> remove, Set<IJavaProject> add) throws CoreException {
			for (IJavaElementDelta childDelta : delta.getAffectedChildren()) {
				processDelta(childDelta, remove, add);
			}
		}
	};

	private static class JavaProjectDescriptionBuilder implements IJavaProjectSourceDescription {
		final Set<File> locations = new HashSet<>();
		final List<Supplier<ISourceContainer>> factories = new ArrayList<>();
		final Map<File, IPackageFragmentRoot> dependencyLocations = new HashMap<>();

		@Override
		public void addLocation(File location) {
			locations.add(location);
		}

		@Override
		public void addSourceContainerFactory(Supplier<ISourceContainer> factory) {
			factories.add(factory);
		}

		@Override
		public void addDependencies(Map<File, IPackageFragmentRoot> dependencies) {
			// TODO decide what happens if the same location is associated with multiple package fragment roots
			this.dependencyLocations.putAll(dependencies);
		}

	}

	private static class JavaProjectDescription {
		final Set<File> classesLocations;

		final Set<Object> classesLocationsHashes;

		final List<Supplier<ISourceContainer>> sourceContainerFactories;

		final Map<File, IPackageFragmentRoot> dependencies;

		final Map<Object, IPackageFragmentRoot> dependencyHashes;

		public JavaProjectDescription(Set<File> locations, Set<Object> hashes, List<Supplier<ISourceContainer>> factories, Map<File, IPackageFragmentRoot> dependencies, Map<Object, IPackageFragmentRoot> dependencyHashes) {
			this.classesLocations = Collections.unmodifiableSet(locations);
			this.classesLocationsHashes = Collections.unmodifiableSet(hashes);
			this.sourceContainerFactories = Collections.unmodifiableList(factories);
			this.dependencies = Collections.unmodifiableMap(dependencies);
			this.dependencyHashes = Collections.unmodifiableMap(dependencyHashes);
		}

		@Override
		public boolean equals(Object obj) {
			if (this == obj) {
				return true;
			}
			if (!(obj instanceof JavaProjectDescription)) {
				return false;
			}
			JavaProjectDescription other = (JavaProjectDescription) obj;
			return classesLocations.equals(other.classesLocations);
		}

		@Override
		public int hashCode() {
			return classesLocations.hashCode();
		}
	}

	/**
	 * Guards concurrent access to {@link #locations}, {@link #hashes} and {@link #projects}. Necessary because source lookup queries and java model
	 * changes are processed on different threads.
	 *
	 * @TODO consider using ConcurrentMaps instead of explicit locking.
	 */
	private final Object lock = new Object() {
	};

	/**
	 * Maps project classes location to project description.
	 */
	private final Map<File, JavaProjectDescription> locations = new HashMap<>();

	/**
	 * Maps project dependency hash to project descriptions. Hash-based source lookup is useful when runtime uses copies of jars used by the
	 * workspace.
	 */
	private final Map<Object, Collection<JavaProjectDescription>> hashes = new HashMap<>();

	/**
	 * Maps java project to project description.
	 */
	private final Map<IJavaProject, JavaProjectDescription> projects = new HashMap<>();

	/**
	 * Creates and returns new source containers for the workspace project identified by the given location. Returns {@code null} if there is no such
	 * workspace project.
	 */
	public ISourceContainer createProjectContainer(File projectLocation) {
		Hasher hasher = FileHashing.hasher(); // use long-lived hasher

		JavaProjectDescription description = getProjectByLocation(projectLocation);

		if (description == null) {
			Collection<JavaProjectDescription> desciptions = getProjectsByHash(projectLocation, hasher);
			if (!desciptions.isEmpty()) {
				// it is possible, but unlikely, to have multiple binary projects for the same jar
				description = desciptions.iterator().next();
			}
		}

		if (description == null) {
			return null;
		}

		List<ISourceContainer> containers = new ArrayList<>();
		for (Supplier<ISourceContainer> factory : description.sourceContainerFactories) {
			containers.add(factory.get());
		}

		return CompositeSourceContainer.compose(containers);
	}

	private JavaProjectDescription getProjectByLocation(File projectLocation) {
		synchronized (lock) {
			return locations.get(projectLocation);
		}
	}

	private Collection<JavaProjectDescription> getProjectsByHash(File projectLocation, FileHashing.Hasher hasher) {
		Collection<JavaProjectDescription> projects;
		synchronized (lock) {
			projects = hashes.get(hasher.hash(projectLocation));
			return projects != null ? new HashSet<>(projects) : Collections.emptySet();
		}
	}

	/**
	 * Creates and returns new source container for the workspace project classpath entry identified by the given project and entry locations. Returns
	 * {@code null} if there is no such project classpath entry or if the classpath entry does not have associated sources.
	 */
	public ISourceContainer createClasspathEntryContainer(File projectLocation, File entryLocation) {
		Hasher hasher = FileHashing.hasher(); // use long-lived hasher

		JavaProjectDescription projectByLocation = getProjectByLocation(projectLocation);

		IPackageFragmentRoot dependency = getProjectDependency(projectByLocation, entryLocation, hasher);

		if (dependency == null && projectByLocation == null) {
			for (JavaProjectDescription projectByHash : getProjectsByHash(projectLocation, hasher)) {
				dependency = getProjectDependency(projectByHash, entryLocation, hasher);
				if (dependency != null) {
					break;
				}
			}
		}

		try {
			if (dependency == null || (dependency.getKind() == IPackageFragmentRoot.K_BINARY && dependency.getSourceAttachmentPath() == null)) {
				return null;
			}
		} catch (JavaModelException e) {
			return null;
		}

		return new PackageFragmentRootSourceContainer(dependency);
	}

	private IPackageFragmentRoot getProjectDependency(JavaProjectDescription project, File entryLocation, FileHashing.Hasher hasher) {
		if (project == null) {
			return null;
		}

		IPackageFragmentRoot dependency = project.dependencies.get(entryLocation);

		if (dependency == null) {
			dependency = project.dependencyHashes.get(hasher.hash(entryLocation));
		}
		return dependency;
	}

	public void initialize(IProgressMonitor monitor) throws CoreException {
		// note that initialization and java element change events are processed by the same background job
		// this guarantees the events aren't lost when they are delivered while the initialization is running
		JavaCore.addElementChangedListener(changeListener);

		final IJavaModel javaModel = JavaCore.create(ResourcesPlugin.getWorkspace().getRoot());
		final IJavaProject[] javaProjects = javaModel.getJavaProjects();

		SubMonitor progress = SubMonitor.convert(monitor, javaProjects.length);

		// TODO this can take significant time for large workspaces, consider running on multiple threads
		// NB: can't persist state across restarts because java element change events are not delivered when this plugin isn't active

		Hasher hasher = FileHashing.newHasher(); // short-lived hasher for bulk workspace indexing

		List<IWorkspaceProjectDescriber> describers = getJavaProjectDescribers();
		for (IJavaProject project : javaProjects) {
			addJavaProject(project, describers, hasher, progress.split(1));
		}
	}

	public void close() {
		JavaCore.removeElementChangedListener(changeListener);
		synchronized (lock) {
			this.locations.clear();
			this.hashes.clear();
			this.projects.clear();
		}
	}

	private void addJavaProject(IJavaProject project, List<IWorkspaceProjectDescriber> describers, FileHashing.Hasher hasher, IProgressMonitor monitor) throws CoreException {
		if (project == null) {
			throw new IllegalArgumentException();
		}

		JavaProjectDescriptionBuilder builder = new JavaProjectDescriptionBuilder();

		for (IWorkspaceProjectDescriber describer : describers) {
			describer.describeProject(project, builder);
		}

		Set<File> locations = builder.locations;
		List<Supplier<ISourceContainer>> factories = builder.factories;
		Map<File, IPackageFragmentRoot> dependencies = builder.dependencyLocations;

		// make binary project support little easier to implement
		locations.forEach(location -> dependencies.remove(location));

		Set<Object> hashes = new HashSet<>();
		locations.forEach(location -> {
			Object hash = hasher.hash(location);
			if (hash != null) {
				hashes.add(hash);
			}
		});

		Map<Object, IPackageFragmentRoot> dependencyHashes = new HashMap<>();
		dependencies.forEach((location, packageFragmentRoot) -> dependencyHashes.put(hasher.hash(location), packageFragmentRoot));

		JavaProjectDescription info = new JavaProjectDescription(locations, hashes, factories, dependencies, dependencyHashes);

		synchronized (this.lock) {
			for (File location : locations) {
				this.locations.put(location, info);
			}
			for (Object hash : hashes) {
				Collection<JavaProjectDescription> hashProjects = this.hashes.get(hash);
				if (hashProjects == null) {
					hashProjects = new HashSet<>();
					this.hashes.put(hash, hashProjects);
				}
				hashProjects.add(info);
			}
			this.projects.put(project, info);
		}

		SubMonitor.done(monitor);
	}

	protected List<IWorkspaceProjectDescriber> getJavaProjectDescribers() {
		List<IWorkspaceProjectDescriber> result = new ArrayList<>();

		IExtensionRegistry registry = Platform.getExtensionRegistry();

		IConfigurationElement[] elements = registry.getConfigurationElementsFor(AdvancedSourceLookupSupport.ID_workspaceProjectDescribers);

		for (IConfigurationElement element : elements) {
			if ("describer".equals(element.getName())) { //$NON-NLS-1$
				try {
					result.add((IWorkspaceProjectDescriber) element.createExecutableExtension("class")); //$NON-NLS-1$
				}
				catch (CoreException e) {
				}
			}
		}

		result.add(new DefaultProjectDescriber());

		return result;
	}

	private void removeJavaProject(IJavaProject project) {
		if (project == null) {
			throw new IllegalArgumentException();
		}
		synchronized (lock) {
			JavaProjectDescription description = projects.remove(project);
			if (description != null) {
				for (File location : description.classesLocations) {
					locations.remove(location);
				}
				for (Object hash : description.classesLocationsHashes) {
					Collection<JavaProjectDescription> hashProjects = hashes.get(hash);
					if (hashProjects != null) {
						hashProjects.remove(description);
						if (hashProjects.isEmpty()) {
							hashes.remove(hash);
						}
					}
				}
			}
		}
	}

	void updateProjects(final Set<IJavaProject> remove, final Set<IJavaProject> add, IProgressMonitor monitor) throws CoreException {
		SubMonitor progress = SubMonitor.convert(monitor, 1 + add.size());

		progress.split(1);
		for (IJavaProject project : remove) {
			removeJavaProject(project);
		}
		List<IWorkspaceProjectDescriber> describers = getJavaProjectDescribers();
		Hasher hasher = FileHashing.newHasher();
		for (IJavaProject project : add) {
			addJavaProject(project, describers, hasher, progress.split(1));
		}
	}

}
