/*******************************************************************************
 * Copyright (c) 2004, 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
 *     James Blackburn (Broadcom Corp.) - ongoing development
 *     Tom Hochstein (Freescale) - Bug 409996 - 'Restore Defaults' does not work properly on Project Properties > Resource tab
 *     Lars Vogel <Lars.Vogel@vogella.com> - Bug 473427
 *******************************************************************************/
package org.eclipse.core.internal.resources;

import java.util.*;
import org.eclipse.core.internal.utils.Messages;
import org.eclipse.core.internal.utils.Policy;
import org.eclipse.core.resources.*;
import org.eclipse.core.runtime.*;
import org.eclipse.core.runtime.jobs.ISchedulingRule;
import org.eclipse.core.runtime.jobs.Job;
import org.eclipse.core.runtime.preferences.IEclipsePreferences;
import org.eclipse.core.runtime.preferences.InstanceScope;
import org.osgi.framework.Bundle;
import org.osgi.service.prefs.BackingStoreException;
import org.osgi.service.prefs.Preferences;

/**
 * Manages user-defined encodings as preferences in the project content area.
 *
 * @since 3.0
 */
public class CharsetManager implements IManager {
	/**
	 * This job implementation is used to allow the resource change listener
	 * to schedule operations that need to modify the workspace.
	 */
	private class CharsetManagerJob extends Job {
		private static final int CHARSET_UPDATE_DELAY = 500;
		private List<Map.Entry<IProject, Boolean>> asyncChanges = new ArrayList<>();

		public CharsetManagerJob() {
			super(Messages.resources_charsetUpdating);
			setSystem(true);
			setPriority(Job.INTERACTIVE);
		}

		@Override
		public boolean belongsTo(Object family) {
			return CharsetManager.class == family;
		}

		public void addChanges(Map<IProject, Boolean> newChanges) {
			if (newChanges.isEmpty())
				return;
			synchronized (asyncChanges) {
				asyncChanges.addAll(newChanges.entrySet());
				asyncChanges.notify();
			}
			schedule(CHARSET_UPDATE_DELAY);
		}

		public Map.Entry<IProject, Boolean> getNextChange() {
			synchronized (asyncChanges) {
				return asyncChanges.isEmpty() ? null : asyncChanges.remove(asyncChanges.size() - 1);
			}
		}

		@Override
		protected IStatus run(IProgressMonitor monitor) {
			MultiStatus result = new MultiStatus(ResourcesPlugin.PI_RESOURCES, IResourceStatus.FAILED_SETTING_CHARSET, Messages.resources_updatingEncoding, null);
			monitor = Policy.monitorFor(monitor);
			try {
				monitor.beginTask(Messages.resources_charsetUpdating, Policy.totalWork);
				final ISchedulingRule rule = workspace.getRuleFactory().modifyRule(workspace.getRoot());
				try {
					workspace.prepareOperation(rule, monitor);
					workspace.beginOperation(true);
					Map.Entry<IProject, Boolean> next;
					while ((next = getNextChange()) != null) {
						//just exit if the system is shutting down or has been shut down
						//it is too late to change the workspace at this point anyway
						if (systemBundle.getState() != Bundle.ACTIVE)
							return Status.OK_STATUS;
						IProject project = next.getKey();
						try {
							if (project.isAccessible()) {
								boolean shouldDisableCharsetDeltaJob = next.getValue().booleanValue();
								// flush preferences for non-derived resources
								flushPreferences(getPreferences(project, false, false, true), shouldDisableCharsetDeltaJob);
								// flush preferences for derived resources
								flushPreferences(getPreferences(project, false, true, true), shouldDisableCharsetDeltaJob);
							}
						} catch (BackingStoreException e) {
							// we got an error saving
							String detailMessage = Messages.resources_savingEncoding;
							result.add(new ResourceStatus(IResourceStatus.FAILED_SETTING_CHARSET, project.getFullPath(), detailMessage, e));
						}
					}
					monitor.worked(Policy.opWork);
				} catch (OperationCanceledException e) {
					workspace.getWorkManager().operationCanceled();
					throw e;
				} finally {
					workspace.endOperation(rule, true);
				}
			} catch (CoreException ce) {
				return ce.getStatus();
			} finally {
				monitor.done();
			}
			return result;
		}

		@Override
		public boolean shouldRun() {
			synchronized (asyncChanges) {
				return !asyncChanges.isEmpty();
			}
		}
	}

	private class ResourceChangeListener implements IResourceChangeListener {
		public ResourceChangeListener() {
		}

		private boolean moveSettingsIfDerivedChanged(IResourceDelta parent, IProject currentProject, Preferences projectPrefs, String[] affectedResources) {
			boolean resourceChanges = false;

			if ((parent.getFlags() & IResourceDelta.DERIVED_CHANGED) != 0) {
				// if derived changed, move encoding to correct preferences
				IPath parentPath = parent.getResource().getProjectRelativePath();
				for (String affectedResource : affectedResources) {
					IPath affectedPath = new Path(affectedResource);
					// if parentPath is an ancestor of affectedPath
					if (parentPath.isPrefixOf(affectedPath)) {
						IResource member = currentProject.findMember(affectedPath);
						if (member != null) {
							Preferences targetPrefs = getPreferences(currentProject, true, member.isDerived(IResource.CHECK_ANCESTORS));
							// if new preferences are different than current
							if (!projectPrefs.absolutePath().equals(targetPrefs.absolutePath())) {
								// remove encoding from old preferences and save in correct preferences
								String currentValue = projectPrefs.get(affectedResource, null);
								projectPrefs.remove(affectedResource);
								targetPrefs.put(affectedResource, currentValue);
								resourceChanges = true;
							}
						}
					}
				}
			}

			for (IResourceDelta child : parent.getAffectedChildren()) {
				resourceChanges = moveSettingsIfDerivedChanged(child, currentProject, projectPrefs, affectedResources) || resourceChanges;
			}
			return resourceChanges;
		}

		private void processEntryChanges(IResourceDelta projectDelta, Map<IProject, Boolean> projectsToSave) {
			// check each resource with user-set encoding to see if it has
			// been moved/deleted or if derived state has been changed
			IProject currentProject = (IProject) projectDelta.getResource();
			Preferences projectRegularPrefs = getPreferences(currentProject, false, false, true);
			Preferences projectDerivedPrefs = getPreferences(currentProject, false, true, true);
			Map<Boolean, String[]> affectedResourcesMap = new HashMap<>();
			try {
				// no regular preferences for this project
				if (projectRegularPrefs == null)
					affectedResourcesMap.put(Boolean.FALSE, new String[0]);
				else
					affectedResourcesMap.put(Boolean.FALSE, projectRegularPrefs.keys());
				// no derived preferences for this project
				if (projectDerivedPrefs == null)
					affectedResourcesMap.put(Boolean.TRUE, new String[0]);
				else
					affectedResourcesMap.put(Boolean.TRUE, projectDerivedPrefs.keys());
			} catch (BackingStoreException e) {
				// problems with the project scope... we will miss the changes (but will log)
				String message = Messages.resources_readingEncoding;
				Policy.log(new ResourceStatus(IResourceStatus.FAILED_GETTING_CHARSET, currentProject.getFullPath(), message, e));
				return;
			}
			for (Map.Entry<Boolean, String[]> entry : affectedResourcesMap.entrySet()) {
				Boolean isDerived = entry.getKey();
				String[] affectedResources = entry.getValue();
				Preferences projectPrefs = isDerived.booleanValue() ? projectDerivedPrefs : projectRegularPrefs;
				for (String affectedResource : affectedResources) {
					IResourceDelta memberDelta = projectDelta.findMember(new Path(affectedResource));
					// no changes for the given resource
					if (memberDelta == null)
						continue;
					if (memberDelta.getKind() == IResourceDelta.REMOVED) {
						boolean shouldDisableCharsetDeltaJobForCurrentProject = false;
						// remove the setting for the original location - save its value though
						String currentValue = projectPrefs.get(affectedResource, null);
						projectPrefs.remove(affectedResource);
						if ((memberDelta.getFlags() & IResourceDelta.MOVED_TO) != 0) {
							IPath movedToPath = memberDelta.getMovedToPath();
							IResource resource = workspace.getRoot().findMember(movedToPath);
							if (resource != null) {
								Preferences encodingSettings = getPreferences(resource.getProject(), true, resource.isDerived(IResource.CHECK_ANCESTORS));
								if (currentValue == null || currentValue.trim().length() == 0)
									encodingSettings.remove(getKeyFor(movedToPath));
								else
									encodingSettings.put(getKeyFor(movedToPath), currentValue);
								IProject targetProject = workspace.getRoot().getProject(movedToPath.segment(0));
								if (targetProject.equals(currentProject))
									// if the file was moved inside the same project disable charset listener
									shouldDisableCharsetDeltaJobForCurrentProject = true;
								else
									projectsToSave.put(targetProject, Boolean.FALSE);
							}
						}
						projectsToSave.put(currentProject, Boolean.valueOf(shouldDisableCharsetDeltaJobForCurrentProject));
					}
				}
				if (moveSettingsIfDerivedChanged(projectDelta, currentProject, projectPrefs, affectedResources)) {
					// if settings were moved between preferences files disable charset listener so we don't react to changes made by ourselves
					projectsToSave.put(currentProject, Boolean.TRUE);
				}
			}
		}

		/**
		 * For any change to the encoding file or any resource with encoding
		 * set, just discard the cache for the corresponding project.
		 */
		@Override
		public void resourceChanged(IResourceChangeEvent event) {
			IResourceDelta delta = event.getDelta();
			if (delta == null)
				return;
			IResourceDelta[] projectDeltas = delta.getAffectedChildren();
			// process each project in the delta
			Map<IProject, Boolean> projectsToSave = new HashMap<>();
			for (IResourceDelta projectDelta : projectDeltas)
				//nothing to do if a project has been added/removed/moved
				if (projectDelta.getKind() == IResourceDelta.CHANGED && (projectDelta.getFlags() & IResourceDelta.OPEN) == 0)
					processEntryChanges(projectDelta, projectsToSave);
			job.addChanges(projectsToSave);
		}
	}

	private static final String PROJECT_KEY = "<project>"; //$NON-NLS-1$
	private CharsetDeltaJob charsetListener;
	CharsetManagerJob job;
	private IResourceChangeListener resourceChangeListener;
	protected final Bundle systemBundle = Platform.getBundle("org.eclipse.osgi"); //$NON-NLS-1$
	Workspace workspace;

	public CharsetManager(Workspace workspace) {
		this.workspace = workspace;
	}

	void flushPreferences(Preferences projectPrefs, boolean shouldDisableCharsetDeltaJob) throws BackingStoreException {
		if (projectPrefs != null) {
			try {
				if (shouldDisableCharsetDeltaJob)
					charsetListener.setDisabled(true);
				projectPrefs.flush();
			} finally {
				if (shouldDisableCharsetDeltaJob)
					charsetListener.setDisabled(false);
			}
		}
	}

	/**
	 * Returns the charset explicitly set by the user for the given resource,
	 * or <code>null</code>. If no setting exists for the given resource and
	 * <code>recurse</code> is <code>true</code>, every parent up to the
	 * workspace root will be checked until a charset setting can be found.
	 *
	 * @param resourcePath the path for the resource
	 * @param recurse whether the parent should be queried
	 * @return the charset setting for the given resource
	 */
	public String getCharsetFor(IPath resourcePath, boolean recurse) {
		Assert.isLegal(resourcePath.segmentCount() >= 1);
		IProject project = workspace.getRoot().getProject(resourcePath.segment(0));

		Preferences prefs = getPreferences(project, false, false);
		Preferences derivedPrefs = getPreferences(project, false, true);

		if (prefs == null && derivedPrefs == null)
			// no preferences found - for performance reasons, short-circuit
			// lookup by falling back to workspace's default setting
			return recurse ? ResourcesPlugin.getEncoding() : null;

		return internalGetCharsetFor(prefs, derivedPrefs, resourcePath, recurse);
	}

	static String getKeyFor(IPath resourcePath) {
		return resourcePath.segmentCount() > 1 ? resourcePath.removeFirstSegments(1).toString() : PROJECT_KEY;
	}

	Preferences getPreferences(IProject project, boolean create, boolean isDerived) {
		return getPreferences(project, create, isDerived, isDerivedEncodingStoredSeparately(project));
	}

	Preferences getPreferences(IProject project, boolean create, boolean isDerived, boolean isDerivedEncodingStoredSeparately) {
		boolean localIsDerived = isDerivedEncodingStoredSeparately ? isDerived : false;
		String qualifier = localIsDerived ? ProjectPreferences.PREFS_DERIVED_QUALIFIER : ProjectPreferences.PREFS_REGULAR_QUALIFIER;
		if (create)
			// create all nodes down to the one we are interested in
			return new ProjectScope(project).getNode(qualifier).node(ResourcesPlugin.PREF_ENCODING);
		// be careful looking up for our node so not to create any nodes as side effect
		Preferences node = Platform.getPreferencesService().getRootNode().node(ProjectScope.SCOPE);
		try {
			//TODO once bug 90500 is fixed, should be as simple as this:
			//			String path = project.getName() + IPath.SEPARATOR + ResourcesPlugin.PI_RESOURCES + IPath.SEPARATOR + ENCODING_PREF_NODE;
			//			return node.nodeExists(path) ? node.node(path) : null;
			// for now, take the long way
			if (!node.nodeExists(project.getName()))
				return null;
			node = node.node(project.getName());
			if (!node.nodeExists(qualifier))
				return null;
			node = node.node(qualifier);
			if (!node.nodeExists(ResourcesPlugin.PREF_ENCODING))
				return null;
			return node.node(ResourcesPlugin.PREF_ENCODING);
		} catch (BackingStoreException e) {
			// nodeExists failed
			String message = Messages.resources_readingEncoding;
			Policy.log(new ResourceStatus(IResourceStatus.FAILED_GETTING_CHARSET, project.getFullPath(), message, e));
		}
		return null;
	}

	private String internalGetCharsetFor(Preferences prefs, Preferences derivedPrefs, IPath resourcePath, boolean recurse) {
		String charset = null;

		// try to find the encoding in regular and then derived preferences
		if (prefs != null)
			charset = prefs.get(getKeyFor(resourcePath), null);
		// derivedPrefs may be not null, only if #isDerivedEncodingStoredSeparately returns true
		// so the explicit check against #isDerivedEncodingStoredSeparately is not required
		if (charset == null && derivedPrefs != null)
			charset = derivedPrefs.get(getKeyFor(resourcePath), null);

		if (!recurse)
			return charset;

		while (charset == null && resourcePath.segmentCount() > 1) {
			resourcePath = resourcePath.removeLastSegments(1);
			// try to find the encoding in regular and then derived preferences
			if (prefs != null)
				charset = prefs.get(getKeyFor(resourcePath), null);
			if (charset == null && derivedPrefs != null)
				charset = derivedPrefs.get(getKeyFor(resourcePath), null);
		}

		// ensure we default to the workspace encoding if none is found
		return charset == null ? ResourcesPlugin.getEncoding() : charset;
	}

	private boolean isDerivedEncodingStoredSeparately(IProject project) {
		// be careful looking up for our node so not to create any nodes as side effect
		Preferences node = Platform.getPreferencesService().getRootNode().node(ProjectScope.SCOPE);
		try {
			//TODO once bug 90500 is fixed, should be as simple as this:
			//			String path = project.getName() + IPath.SEPARATOR + ResourcesPlugin.PI_RESOURCES;
			//			return node.nodeExists(path) ? node.node(path).getBoolean(ResourcesPlugin.PREF_SEPARATE_DERIVED_ENCODINGS, false) : false;
			// for now, take the long way
			if (!node.nodeExists(project.getName()))
				return ResourcesPlugin.DEFAULT_PREF_SEPARATE_DERIVED_ENCODINGS;
			node = node.node(project.getName());
			if (!node.nodeExists(ResourcesPlugin.PI_RESOURCES))
				return ResourcesPlugin.DEFAULT_PREF_SEPARATE_DERIVED_ENCODINGS;
			node = node.node(ResourcesPlugin.PI_RESOURCES);
			return node.getBoolean(ResourcesPlugin.PREF_SEPARATE_DERIVED_ENCODINGS, ResourcesPlugin.DEFAULT_PREF_SEPARATE_DERIVED_ENCODINGS);
		} catch (BackingStoreException e) {
			// nodeExists failed
			String message = Messages.resources_readingEncoding;
			Policy.log(new ResourceStatus(IResourceStatus.FAILED_GETTING_CHARSET, project.getFullPath(), message, e));
			return ResourcesPlugin.DEFAULT_PREF_SEPARATE_DERIVED_ENCODINGS;
		}
	}

	protected void mergeEncodingPreferences(IProject project) {
		Preferences projectRegularPrefs = null;
		Preferences projectDerivedPrefs = getPreferences(project, false, true, true);
		if (projectDerivedPrefs == null)
			return;
		try {
			boolean prefsChanged = false;
			String[] affectedResources;
			affectedResources = projectDerivedPrefs.keys();
			for (String path : affectedResources) {
				String value = projectDerivedPrefs.get(path, null);
				projectDerivedPrefs.remove(path);
				// lazy creation of non-derived preferences
				if (projectRegularPrefs == null)
					projectRegularPrefs = getPreferences(project, true, false, false);
				projectRegularPrefs.put(path, value);
				prefsChanged = true;
			}
			if (prefsChanged) {
				Map<IProject, Boolean> projectsToSave = new HashMap<>();
				// this is internal change so do not notify charset delta job
				projectsToSave.put(project, Boolean.TRUE);
				job.addChanges(projectsToSave);
			}
		} catch (BackingStoreException e) {
			// problems with the project scope... we will miss the changes (but will log)
			String message = Messages.resources_readingEncoding;
			Policy.log(new ResourceStatus(IResourceStatus.FAILED_GETTING_CHARSET, project.getFullPath(), message, e));
		}
	}

	public void projectPreferencesChanged(IProject project) {
		charsetListener.charsetPreferencesChanged(project);
	}

	public void setCharsetFor(IPath resourcePath, String newCharset) throws CoreException {
		// for the workspace root we just set a preference in the instance scope
		if (resourcePath.segmentCount() == 0) {
			IEclipsePreferences resourcesPreferences = InstanceScope.INSTANCE.getNode(ResourcesPlugin.PI_RESOURCES);
			if (newCharset != null)
				resourcesPreferences.put(ResourcesPlugin.PREF_ENCODING, newCharset);
			else
				resourcesPreferences.remove(ResourcesPlugin.PREF_ENCODING);
			try {
				resourcesPreferences.flush();
			} catch (BackingStoreException e) {
				IProject project = workspace.getRoot().getProject(resourcePath.segment(0));
				String message = Messages.resources_savingEncoding;
				throw new ResourceException(IResourceStatus.FAILED_SETTING_CHARSET, project.getFullPath(), message, e);
			}
			return;
		}
		// for all other cases, we set a property in the corresponding project
		IResource resource = workspace.getRoot().findMember(resourcePath);
		if (resource != null) {
			try {
				// disable the listener so we don't react to changes made by ourselves
				Preferences encodingSettings = getPreferences(resource.getProject(), true, resource.isDerived(IResource.CHECK_ANCESTORS));
				if (newCharset == null || newCharset.trim().length() == 0)
					encodingSettings.remove(getKeyFor(resourcePath));
				else
					encodingSettings.put(getKeyFor(resourcePath), newCharset);
				flushPreferences(encodingSettings, true);
			} catch (BackingStoreException e) {
				IProject project = workspace.getRoot().getProject(resourcePath.segment(0));
				String message = Messages.resources_savingEncoding;
				throw new ResourceException(IResourceStatus.FAILED_SETTING_CHARSET, project.getFullPath(), message, e);
			}
		}
	}

	@Override
	public void shutdown(IProgressMonitor monitor) {
		workspace.removeResourceChangeListener(resourceChangeListener);
		if (charsetListener != null)
			charsetListener.shutdown();
	}

	protected void splitEncodingPreferences(IProject project) {
		Preferences projectRegularPrefs = getPreferences(project, false, false, false);
		Preferences projectDerivedPrefs = null;
		if (projectRegularPrefs == null)
			return;
		try {
			boolean prefsChanged = false;
			String[] affectedResources;
			affectedResources = projectRegularPrefs.keys();
			for (String path : affectedResources) {
				IResource resource = project.findMember(path);
				if (resource != null) {
					if (resource.isDerived(IResource.CHECK_ANCESTORS)) {
						String value = projectRegularPrefs.get(path, null);
						projectRegularPrefs.remove(path);
						// lazy creation of derived preferences
						if (projectDerivedPrefs == null)
							projectDerivedPrefs = getPreferences(project, true, true, true);
						projectDerivedPrefs.put(path, value);
						prefsChanged = true;
					}
				}
			}
			if (prefsChanged) {
				Map<IProject, Boolean> projectsToSave = new HashMap<>();
				// this is internal change so do not notify charset delta job
				projectsToSave.put(project, Boolean.TRUE);
				job.addChanges(projectsToSave);
			}
		} catch (BackingStoreException e) {
			// problems with the project scope... we will miss the changes (but will log)
			String message = Messages.resources_readingEncoding;
			Policy.log(new ResourceStatus(IResourceStatus.FAILED_GETTING_CHARSET, project.getFullPath(), message, e));
		}
	}

	@Override
	public void startup(IProgressMonitor monitor) {
		job = new CharsetManagerJob();
		resourceChangeListener = new ResourceChangeListener();
		workspace.addResourceChangeListener(resourceChangeListener, IResourceChangeEvent.POST_CHANGE);
		charsetListener = new CharsetDeltaJob(workspace);
		charsetListener.startup();
	}
}
