/*******************************************************************************
 * 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
 *     Sergey Prigogin (Google) - [464072] Refresh on Access ignored during text search
 *     Lars Vogel <Lars.Vogel@vogella.com> - Bug 473427
 *******************************************************************************/
package org.eclipse.core.internal.resources;

import java.io.*;
import java.util.ArrayList;
import java.util.List;
import org.eclipse.core.filesystem.EFS;
import org.eclipse.core.filesystem.IFileStore;
import org.eclipse.core.internal.events.ILifecycleListener;
import org.eclipse.core.internal.events.LifecycleEvent;
import org.eclipse.core.internal.utils.*;
import org.eclipse.core.internal.watson.ElementTreeIterator;
import org.eclipse.core.internal.watson.IElementContentVisitor;
import org.eclipse.core.resources.*;
import org.eclipse.core.runtime.*;
import org.eclipse.core.runtime.content.*;
import org.eclipse.core.runtime.content.IContentTypeManager.ContentTypeChangeEvent;
import org.eclipse.core.runtime.jobs.ISchedulingRule;
import org.eclipse.osgi.util.NLS;
import org.osgi.framework.Bundle;

/**
 * Keeps a cache of recently read content descriptions.
 *
 * @since 3.0
 * @see IFile#getContentDescription()
 */
public class ContentDescriptionManager implements IManager, IRegistryChangeListener, IContentTypeManager.IContentTypeChangeListener, ILifecycleListener {
	/**
	 * This job causes the content description cache and the related flags
	 * in the resource tree to be flushed.
	 */
	private class FlushJob extends WorkspaceJob {
		private final List<IPath> toFlush;
		private boolean fullFlush;

		public FlushJob() {
			super(Messages.resources_flushingContentDescriptionCache);
			setSystem(true);
			setUser(false);
			setPriority(LONG);
			setRule(workspace.getRoot());
			toFlush = new ArrayList<>(5);
		}

		@Override
		public boolean belongsTo(Object family) {
			return FAMILY_DESCRIPTION_CACHE_FLUSH.equals(family);
		}

		@Override
		public IStatus runInWorkspace(final IProgressMonitor monitor) {
			if (monitor.isCanceled())
				return Status.CANCEL_STATUS;
			try {
				monitor.beginTask("", Policy.opWork); //$NON-NLS-1$
				//note that even though we are running in a workspace job, we
				//must do a begin/endOperation to re-acquire the workspace lock
				final ISchedulingRule rule = workspace.getRoot();
				try {
					workspace.prepareOperation(rule, monitor);
					workspace.beginOperation(true);
					//don't do anything 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.STOPPING)
						doFlushCache(monitor, getPathsToFlush());
				} finally {
					workspace.endOperation(rule, false);
				}
			} catch (OperationCanceledException e) {
				return Status.CANCEL_STATUS;
			} catch (CoreException e) {
				return e.getStatus();
			} finally {
				monitor.done();
			}
			return Status.OK_STATUS;
		}

		private IPath[] getPathsToFlush() {
			synchronized (toFlush) {
				try {
					if (fullFlush)
						return null;
					int size = toFlush.size();
					return (size == 0) ? null : toFlush.toArray(new IPath[size]);
				} finally {
					fullFlush = false;
					toFlush.clear();
				}
			}
		}

		/**
		 * @param project project to flush, or null for a full flush
		 */
		void flush(IProject project) {
			if (Policy.DEBUG_CONTENT_TYPE_CACHE)
				Policy.debug("Scheduling flushing of content type cache for " + (project == null ? Path.ROOT : project.getFullPath())); //$NON-NLS-1$
			synchronized (toFlush) {
				if (!fullFlush)
					if (project == null)
						fullFlush = true;
					else
						toFlush.add(project.getFullPath());
			}
			schedule(1000);
		}

	}

	/**
	 * An input stream that only opens the file if bytes are actually requested.
	 * @see #readDescription(File)
	 */
	static class LazyFileInputStream extends InputStream {
		private InputStream actual;
		private IFileStore target;

		LazyFileInputStream(IFileStore target) {
			this.target = target;
		}

		@Override
		public int available() throws IOException {
			if (actual == null)
				return 0;
			return actual.available();
		}

		@Override
		public void close() throws IOException {
			if (actual == null)
				return;
			actual.close();
		}

		private void ensureOpened() throws IOException {
			if (actual != null)
				return;
			if (target == null)
				throw new FileNotFoundException();
			try {
				actual = target.openInputStream(EFS.NONE, null);
			} catch (CoreException e) {
				if (e.getCause() instanceof IOException) {
					throw (IOException) e.getCause();
				}
				throw new IOException(e.getMessage());
			}
		}

		@Override
		public int read() throws IOException {
			ensureOpened();
			return actual.read();
		}

		@Override
		public int read(byte[] b, int off, int len) throws IOException {
			ensureOpened();
			return actual.read(b, off, len);
		}

		@Override
		public long skip(long n) throws IOException {
			ensureOpened();
			return actual.skip(n);
		}
	}

	private static final QualifiedName CACHE_STATE = new QualifiedName(ResourcesPlugin.PI_RESOURCES, "contentCacheState"); //$NON-NLS-1$
	private static final QualifiedName CACHE_TIMESTAMP = new QualifiedName(ResourcesPlugin.PI_RESOURCES, "contentCacheTimestamp"); //$NON-NLS-1$\

	public static final String FAMILY_DESCRIPTION_CACHE_FLUSH = ResourcesPlugin.PI_RESOURCES + ".contentDescriptionCacheFamily"; //$NON-NLS-1$

	//possible values for the CACHE_STATE property
	public static final byte EMPTY_CACHE = 1;
	public static final byte USED_CACHE = 2;
	public static final byte INVALID_CACHE = 3;
	public static final byte FLUSHING_CACHE = 4;

	// This state indicates that FlushJob is scheduled and full flush is going to be performed.
	// In the meantime the cache was discarded. It is used as a temporary cache till the FlushJob start.
	public static final byte ABOUT_TO_FLUSH = 5;

	private static final String PT_CONTENTTYPES = "contentTypes"; //$NON-NLS-1$

	private Cache cache;

	private byte cacheState;

	private FlushJob flushJob;
	private ProjectContentTypes projectContentTypes;

	Workspace workspace;
	protected final Bundle systemBundle = Platform.getBundle("org.eclipse.osgi"); //$NON-NLS-1$

	/**
	 * @see org.eclipse.core.runtime.content.IContentTypeManager.IContentTypeChangeListener#contentTypeChanged(IContentTypeManager.ContentTypeChangeEvent)
	 */
	@Override
	public void contentTypeChanged(ContentTypeChangeEvent event) {
		if (Policy.DEBUG_CONTENT_TYPE)
			Policy.debug("Content type settings changed for " + event.getContentType()); //$NON-NLS-1$
		invalidateCache(true, null);
	}

	synchronized void doFlushCache(final IProgressMonitor monitor, IPath[] toClean) throws CoreException {
		// nothing to be done if no information cached
		if (getCacheState() != INVALID_CACHE && getCacheState() != ABOUT_TO_FLUSH) {
			if (Policy.DEBUG_CONTENT_TYPE_CACHE)
				Policy.debug("Content type cache flush not performed"); //$NON-NLS-1$
			return;
		}
		try {
			setCacheState(FLUSHING_CACHE);
			// flush the MRU cache
			cache.discardAll();
			if (toClean == null || toClean.length == 0)
				// no project was added, must be a global flush
				clearContentFlags(Path.ROOT, monitor);
			else {
				// flush a project at a time
				for (IPath element : toClean)
					clearContentFlags(element, monitor);
			}
		} catch (CoreException ce) {
			setCacheState(INVALID_CACHE);
			throw ce;
		}
		// done cleaning (only if we didn't fail)
		setCacheState(EMPTY_CACHE);
	}

	/**
	 * Clears the content related flags for every file under the given root.
	 */
	private void clearContentFlags(IPath root, final IProgressMonitor monitor) {
		long flushStart = System.currentTimeMillis();
		if (Policy.DEBUG_CONTENT_TYPE_CACHE)
			Policy.debug("Flushing content type cache for " + root); //$NON-NLS-1$
		// discard content type related flags for all files in the tree
		IElementContentVisitor visitor = (tree, requestor, elementContents) -> {
			if (monitor.isCanceled())
				throw new OperationCanceledException();
			if (elementContents == null)
				return false;
			ResourceInfo info = (ResourceInfo) elementContents;
			if (info.getType() != IResource.FILE)
				return true;
			info = workspace.getResourceInfo(requestor.requestPath(), false, true);
			if (info == null)
				return false;
			info.clear(ICoreConstants.M_CONTENT_CACHE);
			return true;
		};
		new ElementTreeIterator(workspace.getElementTree(), root).iterate(visitor);
		if (Policy.DEBUG_CONTENT_TYPE_CACHE)
			Policy.debug("Content type cache for " + root + " flushed in " + (System.currentTimeMillis() - flushStart) + " ms"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
	}

	Cache getCache() {
		return cache;
	}

	/** Public so tests can examine it. */
	public synchronized byte getCacheState() {
		if (cacheState != 0)
			// we have read/set it before, no nead to read property
			return cacheState;
		String persisted;
		try {
			persisted = workspace.getRoot().getPersistentProperty(CACHE_STATE);
			cacheState = persisted != null ? Byte.parseByte(persisted) : INVALID_CACHE;
		} catch (NumberFormatException e) {
			cacheState = INVALID_CACHE;
		} catch (CoreException e) {
			Policy.log(e.getStatus());
			cacheState = INVALID_CACHE;
		}
		return cacheState;
	}

	public long getCacheTimestamp() throws CoreException {
		try {
			return Long.parseLong(workspace.getRoot().getPersistentProperty(CACHE_TIMESTAMP));
		} catch (NumberFormatException e) {
			return 0;
		}
	}

	public IContentTypeMatcher getContentTypeMatcher(Project project) throws CoreException {
		return projectContentTypes.getMatcherFor(project);
	}

	/**
	 * Discovers, and caches, the content description of the requested File.
	 * @param file to discover the content description for; result cached
	 * @param info ResourceInfo for the passed in file
	 * @param inSync boolean flag which indicates if cache can be trusted. If false false don't trust the cache
	 * @return IContentDescription for the file
	 * @throws CoreException
	 */
	public IContentDescription getDescriptionFor(File file, ResourceInfo info, boolean inSync) throws CoreException {
		if (ProjectContentTypes.usesContentTypePreferences(file.getFullPath().segment(0)))
			// caching for project containing project specific settings is not supported
			return readDescription(file);
		if (getCacheState() == INVALID_CACHE) {
			// discard the cache, so it can be used before the flush job starts
			setCacheState(ABOUT_TO_FLUSH);
			cache.discardAll();
			// the cache is not good, flush it
			flushJob.schedule(1000);
		}
		if (inSync && getCacheState() != ABOUT_TO_FLUSH) {
			// first look for the flags in the resource info to avoid looking in the cache
			// don't need to copy the info because the modified bits are not in the deltas
			if (info == null)
				return null;
			if (info.isSet(ICoreConstants.M_NO_CONTENT_DESCRIPTION))
				// presumably, this file has no known content type
				return null;
			if (info.isSet(ICoreConstants.M_DEFAULT_CONTENT_DESCRIPTION)) {
				// this file supposedly has a default content description for an "obvious" content type
				IContentTypeManager contentTypeManager = Platform.getContentTypeManager();
				// try to find the obvious content type matching its name
				IContentType type = contentTypeManager.findContentTypeFor(file.getName());
				if (type != null)
					// we found it, we are done
					return type.getDefaultDescription();
				// for some reason, there was no content type for this file name
				// fix this and keep going
				info.clear(ICoreConstants.M_CONTENT_CACHE);
			}
		}
		if (inSync) {
			// tries to get a description from the cache
			synchronized (this) {
				Cache.Entry entry = cache.getEntry(file.getFullPath());
				if (entry != null && entry.getTimestamp() == getTimestamp(info))
					// there was a description in the cache, and it was up to date
					return (IContentDescription) entry.getCached();
			}
		}

		// either we didn't find a description in the cache, or it was not up-to-date - has to be read again
		// reading description can call 3rd party code, so don't synchronize it
		IContentDescription newDescription = readDescription(file);

		synchronized (this) {
			// tries to get a description from the cache
			Cache.Entry entry = cache.getEntry(file.getFullPath());
			if (entry != null && inSync && entry.getTimestamp() == getTimestamp(info))
				// there was a description in the cache, and it was up to date
				return (IContentDescription) entry.getCached();

			if (getCacheState() != ABOUT_TO_FLUSH) {
				// we are going to add an entry to the cache or update the resource info - remember that
				setCacheState(USED_CACHE);
				if (newDescription == null) {
					// no content type exists for this file name/contents - remember this
					info.set(ICoreConstants.M_NO_CONTENT_DESCRIPTION);
					return null;
				}
				if (newDescription.getContentType().getDefaultDescription().equals(newDescription)) {
					// we got a default description
					IContentType defaultForName = Platform.getContentTypeManager().findContentTypeFor(file.getName());
					if (newDescription.getContentType().equals(defaultForName)) {
						// it is a default description for the obvious content type given its file name, we don't have to cache
						info.set(ICoreConstants.M_DEFAULT_CONTENT_DESCRIPTION);
						return newDescription;
					}
				}
			}
			// we actually got a description filled by a describer (or a default description for a non-obvious type)
			if (entry == null)
				// there was no entry before - create one
				entry = cache.addEntry(file.getFullPath(), newDescription, getTimestamp(info));
			else {
				// just update the existing entry
				entry.setTimestamp(getTimestamp(info));
				entry.setCached(newDescription);
			}
			return newDescription;
		}
	}

	/**
	 * Returns a timestamp that uniquely identifies a particular content state
	 * of a particular resource. For use as a key in a content type cache.
	 */
	private long getTimestamp(ResourceInfo info) {
		return info.getContentId() + info.getNodeId();
	}

	/**
	 * Marks the cache as invalid. Does not do anything if the cache is new.
	 * Optionally causes the cached information to be actually flushed.
	 *
	 * @param flush whether the cached information should be flushed
	 * @see #doFlushCache(IProgressMonitor, IPath[])
	 */
	public synchronized void invalidateCache(boolean flush, IProject project) {
		if (getCacheState() == EMPTY_CACHE)
			// cache has not been touched, nothing to do
			return;
		// mark the cache as invalid
		try {
			setCacheState(INVALID_CACHE);
		} catch (CoreException e) {
			Policy.log(e.getStatus());
		}
		if (Policy.DEBUG_CONTENT_TYPE_CACHE)
			Policy.debug("Invalidated cache for " + (project == null ? Path.ROOT : project.getFullPath())); //$NON-NLS-1$
		if (flush) {
			try {
				// discard the cache, so it can be used before the flush job starts
				setCacheState(ABOUT_TO_FLUSH);
				cache.discardAll();
			} catch (CoreException e) {
				Policy.log(e.getStatus());
			}
			// the cache is not good, flush it
			flushJob.flush(project);
		}
	}

	/**
	 * Tries to obtain a content description for the given file.
	 */
	private IContentDescription readDescription(File file) throws CoreException {
		if (Policy.DEBUG_CONTENT_TYPE)
			Policy.debug("reading contents of " + file); //$NON-NLS-1$
		// tries to obtain a description for this file contents
		try (
			InputStream contents = new LazyFileInputStream(file.getStore());
		) {
			IContentTypeMatcher matcher = getContentTypeMatcher((Project) file.getProject());
			return matcher.getDescriptionFor(contents, file.getName(), IContentDescription.ALL);
		} catch (FileNotFoundException e) {
			String message = NLS.bind(Messages.localstore_fileNotFound, file.getFullPath());
			throw new ResourceException(IResourceStatus.RESOURCE_NOT_FOUND, file.getFullPath(), message, e);
		} catch (IOException e) {
			String message = NLS.bind(Messages.resources_errorContentDescription, file.getFullPath());
			throw new ResourceException(IResourceStatus.FAILED_DESCRIBING_CONTENTS, file.getFullPath(), message, e);
		}
	}

	/**
	 * @see IRegistryChangeListener#registryChanged(IRegistryChangeEvent)
	 */
	@Override
	public void registryChanged(IRegistryChangeEvent event) {
		// no changes related to the content type registry
		if (event.getExtensionDeltas(Platform.PI_RUNTIME, PT_CONTENTTYPES).length == 0)
			return;
		invalidateCache(true, null);
	}

	/**
	 * @see ILifecycleListener#handleEvent(LifecycleEvent)
	 */
	@Override
	public void handleEvent(LifecycleEvent event) {
		//TODO are these the only events we care about?
		switch (event.kind) {
			case LifecycleEvent.POST_PROJECT_CHANGE :
				// if the project changes, its natures may have changed as well (content types may be associated to natures)
			case LifecycleEvent.PRE_PROJECT_DELETE :
				// if the project gets deleted, we may get confused if it is recreated again (content ids might match)
			case LifecycleEvent.PRE_PROJECT_MOVE :
				// if the project moves, resource paths (used as keys in the in-memory cache) will have changed
				invalidateCache(true, (IProject) event.resource);
		}
	}

	synchronized void setCacheState(byte newCacheState) throws CoreException {
		if (cacheState == newCacheState)
			return;
		workspace.getRoot().setPersistentProperty(CACHE_STATE, Byte.toString(newCacheState));
		cacheState = newCacheState;
	}

	private void setCacheTimeStamp(long timeStamp) throws CoreException {
		workspace.getRoot().setPersistentProperty(CACHE_TIMESTAMP, Long.toString(timeStamp));
	}

	@Override
	public void shutdown(IProgressMonitor monitor) throws CoreException {
		if (getCacheState() != INVALID_CACHE)
			// remember the platform timestamp for which we have a valid cache
			setCacheTimeStamp(Platform.getStateStamp());
		IContentTypeManager contentTypeManager = Platform.getContentTypeManager();
		//tolerate missing services during shutdown because they might be already gone
		if (contentTypeManager != null)
			contentTypeManager.removeContentTypeChangeListener(this);
		IExtensionRegistry registry = Platform.getExtensionRegistry();
		if (registry != null)
			registry.removeRegistryChangeListener(this);
		cache.dispose();
		cache = null;
		flushJob.cancel();
		flushJob = null;
		projectContentTypes = null;
	}

	@Override
	public void startup(IProgressMonitor monitor) throws CoreException {
		workspace = (Workspace) ResourcesPlugin.getWorkspace();
		cache = new Cache(100, 1000, 0.1);
		projectContentTypes = new ProjectContentTypes(workspace);
		getCacheState();
		if (cacheState == FLUSHING_CACHE || cacheState == ABOUT_TO_FLUSH)
			// in case we died before completing the last flushing
			setCacheState(INVALID_CACHE);
		flushJob = new FlushJob();
		// the cache is stale (plug-ins that might be contributing content types were added/removed)
		if (getCacheTimestamp() != Platform.getStateStamp())
			invalidateCache(false, null);
		// register a lifecycle listener
		workspace.addLifecycleListener(this);
		// register a content type change listener
		Platform.getContentTypeManager().addContentTypeChangeListener(this);
		// register a registry change listener
		Platform.getExtensionRegistry().addRegistryChangeListener(this, Platform.PI_RUNTIME);
	}

	public void projectPreferencesChanged(IProject project) {
		if (Policy.DEBUG_CONTENT_TYPE)
			Policy.debug("Project preferences changed for " + project); //$NON-NLS-1$
		projectContentTypes.contentTypePreferencesChanged(project);
	}
}
