/*******************************************************************************
 * Copyright (c) 2000, 2018 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
 *******************************************************************************/
package org.eclipse.team.internal.ccvs.ui.subscriber;

import java.lang.ref.SoftReference;
import java.util.*;

import org.eclipse.core.resources.*;
import org.eclipse.core.runtime.*;
import org.eclipse.core.runtime.jobs.Job;
import org.eclipse.team.core.TeamException;
import org.eclipse.team.core.subscribers.Subscriber;
import org.eclipse.team.core.synchronize.*;
import org.eclipse.team.internal.ccvs.core.*;
import org.eclipse.team.internal.ccvs.core.resources.CVSWorkspaceRoot;
import org.eclipse.team.internal.ccvs.core.resources.RemoteFile;
import org.eclipse.team.internal.ccvs.core.syncinfo.FolderSyncInfo;
import org.eclipse.team.internal.ccvs.core.syncinfo.ResourceSyncInfo;
import org.eclipse.team.internal.ccvs.core.util.Util;
import org.eclipse.team.internal.ccvs.ui.CVSUIMessages;
import org.eclipse.team.internal.ccvs.ui.CVSUIPlugin;
import org.eclipse.team.internal.ccvs.ui.Policy;
import org.eclipse.team.internal.ccvs.ui.actions.CVSAction;
import org.eclipse.team.internal.ccvs.ui.mappings.ModelCompareParticipant;
import org.eclipse.team.internal.ccvs.ui.operations.RemoteLogOperation;
import org.eclipse.team.internal.ccvs.ui.operations.RemoteLogOperation.LogEntryCache;
import org.eclipse.team.internal.core.BackgroundEventHandler;
import org.eclipse.team.internal.core.subscribers.SubscriberResourceCollector;
import org.eclipse.team.ui.synchronize.*;

/**
 * This class wraps a LogEntryCache in order to clear entries once they are no longer
 * in the subscriber.
 */
public class LogEntryCacheUpdateHandler extends BackgroundEventHandler {
	
	private static final int REMOVAL = 1;
	private static final int CHANGE = 2;
	private static final int FETCH_REQUEST = 3;
	private static final int PAUSE = 4;
	
	/*
	 * Lock used to ensure that fetches are queued when the job is
	 * a non-system job.
	 */
	private final Object queueLock = new Object();
	
	/*
	 * Exception used to stop processing so the job can be restarted as a non-system job
	 */
	private static final OperationCanceledException PAUSE_EXCEPTION = new OperationCanceledException();
	
	/*
	 * Contants for configuring how long to wait for the job to be paused
	 * when a fetch is required and the job needs to be converted to a non-system
	 * job. If the wait time is elapsed, an exception is thrown.
	 */
	private static final int WAIT_INCREMENT = 10;
	private static final int MAX_WAIT = 1000;
	
	/*
	 * Set that keeps track of all resource for which we haved fetched log entries
	 */
	private final SyncInfoTree collectedInfos = new SyncInfoTree();
	
	/*
	 * The cache that hold the log entries while the job is running
	 */
	//private LogEntryCache logEntriesCache;
	
	/*
	 * SoftReference used to hold on to the log entry cache while
	 * the job is not running so the cache can be cleared if memory is low.
	 */
	private SoftReference cacheReference;
	
	/*
	 * Collector that forewards subscriber changes so that
	 * stale cache entries can be cleared.
	 */
	private final LogEntryResourceCollector collector;

	/*
	 * The subscriber generating the SyncInfo and log entries
	 */
	private final Subscriber subscriber;
	
	/*
	 * The accumulated list of updates that need to be dispatched
	 * (i.e. the cache should be purged of out-of-date resources).
	 * This list is only modified and accessed from the event processing
	 * thread.
	 */
	private final List<Event> updates = new ArrayList<>();
	
	/*
	 * The accumulated list of fetches that have been requested
	 */
	private final List<Event> fetches = new ArrayList<>();
	private final ISynchronizePageConfiguration configuration;
	
	
	/*
	 * Interface for notifying a single client that the infos have been fetched
	 */
	public interface ILogsFetchedListener {

		void logEntriesFetched(SyncInfoSet set, LogEntryCache logEntryCache, IProgressMonitor monitor);
		
	}
	
	/*
	 * The listener or null if noone is listening
	 */
	private ILogsFetchedListener listener;
	
	/*
	 * Subscriber resource collector that forwards subscriber changes
	 * through the handler so that stale cache entries can be cleared
	 */
	private class LogEntryResourceCollector extends SubscriberResourceCollector {

		public LogEntryResourceCollector(Subscriber subscriber) {
			super(subscriber);
		}

		@Override
		protected void remove(IResource resource) {
			queueEvent(new ResourceEvent(resource, REMOVAL, IResource.DEPTH_INFINITE), false /* do not put in on the front of the queue*/);  
		}

		@Override
		protected void change(IResource resource, int depth) {
			queueEvent(new ResourceEvent(resource, CHANGE, depth), false /* do not put in on the front of the queue*/); 
		}

		@Override
		protected boolean hasMembers(IResource resource) {
			return collectedInfos.hasMembers(resource);
		}
	}
	
	/*
	 * Custom event for queue a log entry fetch request
	 */
	private class FetchRequest extends Event {
		private final SyncInfo[] infos;
		public FetchRequest(SyncInfo[] infos) {
			super(FETCH_REQUEST);
			this.infos = infos;
		}
		public SyncInfo[] getInfos() {
			return infos;
		}
	}
	
	public LogEntryCacheUpdateHandler(ISynchronizePageConfiguration configuration) {
		super(CVSUIMessages.LogEntryCacheUpdateHandler_1, CVSUIMessages.LogEntryCacheUpdateHandler_0); // 
		this.configuration = configuration;
		this.subscriber = getSubscriber(configuration);
		cacheReference = new SoftReference(new LogEntryCache());
		collector = new LogEntryResourceCollector(subscriber);
	}

	private Subscriber getSubscriber(ISynchronizePageConfiguration configuration) {
		ISynchronizeParticipant participant = configuration.getParticipant();
		if (participant instanceof SubscriberParticipant) {
			SubscriberParticipant sp = (SubscriberParticipant) participant;
			return sp.getSubscriber();
		}
		if (participant instanceof ModelCompareParticipant) {
			ModelCompareParticipant mcp = (ModelCompareParticipant) participant;
			return mcp.getSubscriber();
		}
		return CVSProviderPlugin.getPlugin().getCVSWorkspaceSubscriber();
	}

	public ISynchronizePageConfiguration getConfiguration() {
		return configuration;
	}
	
	public Subscriber getSubscriber() {
		return subscriber;
	}
	
	/**
	 * Set the listener that should receive notification when log entries
	 * have been fetched and are avalable.
	 * @param listener the listener or <code>null</code>
	 */
	public void setListener(ILogsFetchedListener listener) {
		this.listener = listener;
	}
	
	@Override
	protected Object getJobFamiliy() {
		return ISynchronizeManager.FAMILY_SYNCHRONIZE_OPERATION;
	}
	
	@Override
	protected void createEventHandlingJob() {
		super.createEventHandlingJob();
		Job job = getEventHandlerJob();
		job.setSystem(false);
		job.setUser(false);
	}
	
	@Override
	protected void processEvent(Event event, IProgressMonitor monitor) throws CoreException {
		Policy.checkCanceled(monitor);
		switch (event.getType()) {
			case REMOVAL:
			case CHANGE:
				updates.add(event);
				break;
			case FETCH_REQUEST:
				fetches.add(event);
				break;
			case PAUSE:
				throw PAUSE_EXCEPTION;
		}
		
	}
	
	@Override
	protected boolean doDispatchEvents(IProgressMonitor monitor) throws TeamException {
		Policy.checkCanceled(monitor);
		boolean dispatched = false;
		monitor.beginTask(null, 50);
		dispatched |= updateCache(Policy.subMonitorFor(monitor, 20));
		dispatched |= processQueuedFetches(Policy.subMonitorFor(monitor, 80));
		monitor.done();
		return dispatched;
	}
	
	/*
	 * Remove any stale or unneeded log entries from the cache.
	 * Return whether there were any entries to purge.
	 */
	private boolean updateCache(IProgressMonitor monitor) {
		if (updates.isEmpty()) return false;
		try {
			collectedInfos.beginInput();
			// Cycle through the update events
			for (Iterator iter = updates.iterator(); iter.hasNext();) {
				Event event = (Event) iter.next();
				Policy.checkCanceled(monitor);
				if (event.getType() == REMOVAL) {
					remove(event.getResource(), ((ResourceEvent)event).getDepth());
				} else if (event.getType() == CHANGE) {
					change(event.getResource(), ((ResourceEvent)event).getDepth());
				}
				// Use the iterator to remove so that updates will not be lost
				// if the job is cancelled and then restarted.
				iter.remove();
			}
		} finally {
			collectedInfos.endInput(monitor);
		}
		return true;
	}

	@Override
	public void shutdown() {
		super.shutdown();
		collector.dispose();
		// Probably not necessary as GC would take care of it but we'll do it anyway
		if (cacheReference != null) {
			LogEntryCache cache = (LogEntryCache)cacheReference.get();
			if (cache != null) {
				cache.clearEntries();
			}
		}
		collectedInfos.clear();
		
	}
	
	private void remove(IResource resource, int depth) {
		collectedInfos.remove(resource, depth);
	}

	private void remove(SyncInfo info) {
		if (info != null) {
			collectedInfos.remove(info.getLocal());
			LogEntryCache cache = (LogEntryCache)cacheReference.get();
			if (cache != null) {
				ICVSRemoteResource remoteResource = getRemoteResource(info);
				if (remoteResource != null)
					cache.clearEntries(remoteResource);
			}
		}
	}

	public ICVSRemoteResource getRemoteResource(SyncInfo info) {
		try {
			ICVSRemoteResource remote = (ICVSRemoteResource) info.getRemote();
			ICVSRemoteResource local = CVSWorkspaceRoot.getRemoteResourceFor(info.getLocal());
			if(local == null) {
				local = (ICVSRemoteResource)info.getBase();
			}
			
			boolean useRemote = true;
			if (local != null && remote != null) {
				String remoteRevision = getRevisionString(remote);
				String localRevision = getRevisionString(local);
				useRemote = useRemote(localRevision, remoteRevision);
			} else if (remote == null) {
				useRemote = false;
			}
			if (useRemote) {
				return remote;
			} else if (local != null) {
				return local;
			}
			return null;
		} catch (CVSException e) {
			CVSUIPlugin.log(e);
			return null;
		}
	}

	private boolean useRemote(String localRevision, String remoteRevision) {
		boolean useRemote;
		if (remoteRevision == null && localRevision == null) {
			useRemote = true;
		} else if (localRevision == null) {
			useRemote = true;
		} else if (remoteRevision == null) {
			useRemote = false;
		} else {
			useRemote = ResourceSyncInfo.isLaterRevision(remoteRevision, localRevision);
		}
		return useRemote;
	}

	private String getRevisionString(ICVSRemoteResource remoteFile) {
		if(remoteFile instanceof RemoteFile) {
			return ((RemoteFile)remoteFile).getRevision();
		}
		return null;
	}
	
	private void change(IResource resource, int depth) {
		// We only need to remove collected log entries that don't apply
		// any longer. They will be refetched when they are required.
		SyncInfo[] collected = collectedInfos.getSyncInfos(resource, depth);
		change(collected);
	}
	
	private void change(SyncInfo[] collected) {
		Subscriber subscriber = getSubscriber();
		for (SyncInfo info : collected) {
			try {
				SyncInfo newInfo = subscriber.getSyncInfo(info.getLocal());
				if (newInfo == null || !newInfo.equals(info)) {
					// The cached log entry no longer applies to the new sync info.
					// It will be refetched when required.
					remove(info);
				}
			} catch (TeamException e) {
				// Log and continue
				CVSUIPlugin.log(e);
			}
		}
	}

	/**
	 * Queue a request to fetch log entries for the given SyncInfo nodes.
	 * The event handler must be a non-system job when revision histories 
	 * are fetched.
	 * @param infos the nodes whose log entries are to be fetched
	 */
	public void fetch(SyncInfo[] infos) throws CVSException {
		synchronized(queueLock) {
			Job job = getEventHandlerJob();
			if (job.isSystem() && job.getState() != Job.NONE) {
				// queue an event to pause the processor
				super.queueEvent(new Event(PAUSE), true /* put on the front of the queue */);
				int count = 0;
				while (job.getState() != Job.NONE && count < MAX_WAIT) {
					count += WAIT_INCREMENT;
					try {
						Thread.sleep(WAIT_INCREMENT); // Wait a little while
					} catch (InterruptedException e) {
						// Ignore
					}
				}
				if (job.getState() != Job.NONE) {
					// The job never completed in the time aloted so throw an exception
					throw new CVSException(CVSUIMessages.LogEntryCacheUpdateHandler_2); 
				}
			}
			// Queue the event even if the job didn't stop in the time aloted
			queueEvent(new FetchRequest(infos), false /* don't place at the end */);
		}
	}
	
	@Override
	protected void queueEvent(Event event, boolean front) {
		// Override to snure that queues by this handler are serialized
		synchronized(queueLock) {
			Job job = getEventHandlerJob();
			if (job.getState() == Job.NONE) {
				job.setSystem(event.getType() != FETCH_REQUEST); 
			}
			super.queueEvent(event, front);
		}
	}
	
	/*
	 * Method invoked during event dispatch to fetch log entries
	 */
	private boolean processQueuedFetches(IProgressMonitor monitor) {
		if (fetches.isEmpty()) return false;
		try {
			// Now perform the fetching
			Map projectMapping = getFetchesByProject();
			if (projectMapping.isEmpty()) return true;
			LogEntryCache logEntriesCache = (LogEntryCache)cacheReference.get();
			if (logEntriesCache == null) {
				logEntriesCache = new LogEntryCache();
				cacheReference = new SoftReference(logEntriesCache);
			}
			monitor.beginTask(CVSUIMessages.CVSChangeSetCollector_4, 100 * projectMapping.size()); 
			monitor.setTaskName(CVSUIMessages.CVSChangeSetCollector_4); 
			for (Iterator iter = projectMapping.values().iterator(); iter.hasNext();) {
				SyncInfoSet set = (SyncInfoSet) iter.next();
				Policy.checkCanceled(monitor);
				fetchLogEntries(logEntriesCache, set, Policy.subMonitorFor(monitor, 90));
				fireFetchedNotification(logEntriesCache, set, Policy.subMonitorFor(monitor, 10));
			}
		} finally {
			// Clear the fetches even if we were cancelled.
			// Restarting will need to re-request all infos
			fetches.clear();
			monitor.done();
		}
		return true;
	}

	private void fireFetchedNotification(LogEntryCache logEntriesCache, SyncInfoSet set, IProgressMonitor monitor) {
		if (listener != null) {
			listener.logEntriesFetched(set, logEntriesCache, monitor);
		}
	}

	/*
	 * Return a map of IProject to SyncInfoSet as that is how entries are fetched.
	 * The set for each project includes all infos from the original set.
	 * This is one so that the completion notification contains all infos
	 * including those were a fetch was not required either because the
	 * entry was already cached or the resource has no history.
	 */
	private Map getFetchesByProject() {
		Map<IProject, SyncInfoSet> result = new HashMap<>();
		for (Iterator iter = fetches.iterator(); iter.hasNext();) {
			FetchRequest request = (FetchRequest) iter.next();
			SyncInfo[] infos = request.getInfos();
			for (SyncInfo info : infos) {
				IProject project = info.getLocal().getProject();
				SyncInfoSet infoSet = result.get(project);
				if (infoSet == null) {
					infoSet = new SyncInfoSet();
					result.put(project, infoSet);
				}
				infoSet.add(info);
			}
		}
		return result;
	}

	private boolean isFetchRequired(SyncInfo info) {
		// We only need to fetch if we don't have the log entry already
		// and the change is a remote change
		return info.getLocal().getType() == IResource.FILE && !isLogEntryCached(info) && isRemoteChange(info);
		
	}
	
	/*
	 * Return whether the given SyncInfo is cached. If there is
	 * an info for the resource that does not match the given info,
	 * it is removed and false is returned.
	 */
	private boolean isLogEntryCached(SyncInfo info) {
		SyncInfo collectedInfo = collectedInfos.getSyncInfo(info.getLocal());
		if (collectedInfo != null && !collectedInfo.equals(info)) {
			remove(collectedInfo);
			collectedInfo = null;
		}
		return collectedInfo != null;
	}

	/*
	 * Return if this sync info should be considered as part of a remote change
	 * meaning that it can be placed inside an incoming commit set (i.e. the
	 * set is determined using the comments from the log entry of the file). 
	 */
	public boolean isRemoteChange(SyncInfo info) {
		int kind = info.getKind();
		if(info.getLocal().getType() != IResource.FILE) return false;
		if(info.getComparator().isThreeWay()) {
			return (kind & SyncInfo.DIRECTION_MASK) != SyncInfo.OUTGOING;
		}
		// For two-way, the change is only remote if it has a remote or has a base locally
		if (info.getRemote() != null) return true;
		ICVSFile file = CVSWorkspaceRoot.getCVSFileFor((IFile)info.getLocal());
		try {
			return file.getSyncBytes() != null;
		} catch (CVSException e) {
			// Log the error and exclude the file from consideration
			CVSUIPlugin.log(e);
			return false;
		}
	}
	
	/*
	 * Fetch the log entries for the info in the given set
	 */
	private void fetchLogEntries(LogEntryCache logEntriesCache, SyncInfoSet set, IProgressMonitor monitor) {
		try {
			if (subscriber instanceof CVSCompareSubscriber) {
				CVSCompareSubscriber compareSubscriber = (CVSCompareSubscriber)subscriber;
				fetchLogEntries(logEntriesCache, compareSubscriber, set, monitor);
			} else {
				// Run the log command once with no tags
				fetchLogs(logEntriesCache, set, null, null, monitor);
			}
		} catch (CVSException e) {
			handleException(e);
		} catch (InterruptedException e) {
			throw new OperationCanceledException();
		}
		
	}
	
	private void fetchLogEntries(LogEntryCache logEntriesCache, CVSCompareSubscriber compareSubscriber, SyncInfoSet set, IProgressMonitor monitor) throws CVSException, InterruptedException {
		Map localTagMap = getLocalTagMap(set);
		monitor.beginTask(null, 100 * localTagMap.size());
		for (Iterator iter = localTagMap.keySet().iterator(); iter.hasNext();) {
			CVSTag localTag = (CVSTag) iter.next();        
			fetchLogEntries(logEntriesCache, compareSubscriber, set, localTag, Policy.subMonitorFor(monitor, 100));
		}
		Policy.checkCanceled(monitor);
		monitor.done();
	}

	/*
	 * Return the resources grouped by the tag found in the
	 * workspace. The map is CVSTag->SyncInfoSet
	 */
	private Map getLocalTagMap(SyncInfoSet set) {
		Map<CVSTag, SyncInfoSet> result = new HashMap<>();
		for (Iterator iter = set.iterator(); iter.hasNext();) {
			SyncInfo info = (SyncInfo) iter.next();
			CVSTag tag = getLocalTag(info);
			SyncInfoSet tagSet = result.get(tag);
			if (tagSet == null) {
				tagSet = new SyncInfoSet();
				result.put(tag, tagSet);
			}
			tagSet.add(info);
		}
		return result;
	}

	private CVSTag getLocalTag(SyncInfo syncInfo) {
		try {
			IResource local = syncInfo.getLocal();
			ICVSResource cvsResource = CVSWorkspaceRoot.getCVSResourceFor(local);
			CVSTag tag = null;
			if(cvsResource.isFolder()) {
				FolderSyncInfo info = ((ICVSFolder)cvsResource).getFolderSyncInfo();
				if(info != null) {
					tag = info.getTag();									
				}
				if (tag != null && tag.getType() == CVSTag.BRANCH) {
					tag = Util.getAccurateFolderTag(local, tag);
				}
			} else {
				tag = CVSAction.getAccurateFileTag(cvsResource);
			}
			if(tag == null) {
				tag = new CVSTag();
			}
			return tag;
		} catch (CVSException e) {
			CVSUIPlugin.log(e);
			return new CVSTag();
		}
	}

	private void fetchLogEntries(LogEntryCache logEntriesCache, CVSCompareSubscriber compareSubscriber, SyncInfoSet set, CVSTag localTag, IProgressMonitor monitor) throws CVSException, InterruptedException {
		if (compareSubscriber.isMultipleTagComparison()) {
			Map rootToInfoMap = getRootToInfoMap(compareSubscriber, set);
			monitor.beginTask(null, 100 * rootToInfoMap.size());
			for (Iterator iterator = rootToInfoMap.keySet().iterator(); iterator.hasNext();) {
				IResource root = (IResource) iterator.next();
				Policy.checkCanceled(monitor);
				fetchLogs(logEntriesCache, set, localTag, compareSubscriber.getTag(root), Policy.subMonitorFor(monitor, 100));
			}
			monitor.done();
		} else {
			Policy.checkCanceled(monitor);
			fetchLogs(logEntriesCache, set, localTag, compareSubscriber.getTag(), monitor);
		}
	}

	private Map getRootToInfoMap(CVSCompareSubscriber compareSubscriber, SyncInfoSet set) {
		Map<IResource, SyncInfoSet> rootToInfosMap = new HashMap<>();
		IResource[] roots = compareSubscriber.roots();
		for (Iterator iter = set.iterator(); iter.hasNext();) {
			SyncInfo info = (SyncInfo) iter.next();
			IPath localPath = info.getLocal().getFullPath();
			for (IResource resource : roots) {
				if (resource.getFullPath().isPrefixOf(localPath)) {
					SyncInfoSet infoList = rootToInfosMap.get(resource);
					if (infoList == null) {
						infoList = new SyncInfoSet();
						rootToInfosMap.put(resource, infoList);
					}
					infoList.add(info);
					break; // out of inner loop
				}
			}
			
		}
		return rootToInfosMap;
	}

	private void fetchLogs(LogEntryCache logEntriesCache, SyncInfoSet set, CVSTag localTag, CVSTag remoteTag, IProgressMonitor monitor) throws CVSException, InterruptedException {
		ICVSRemoteResource[] remoteResources = getRemotesToFetch(set.getSyncInfos());
		if (remoteResources.length > 0) {
			RemoteLogOperation logOperation = new RemoteLogOperation(getConfiguration().getSite().getPart(), remoteResources, localTag, remoteTag, logEntriesCache);
			logOperation.execute(monitor);
		}
		collectedInfos.addAll(set);
	}
	
	private ICVSRemoteResource[] getRemotesToFetch(SyncInfo[] infos) {
		List<ICVSRemoteResource> remotes = new ArrayList<>();
		for (SyncInfo info : infos) {
			if (isFetchRequired(info)) {
				ICVSRemoteResource remote = getRemoteResource(info);
				if(remote != null) {
					remotes.add(remote);
				}
			}
		}
		return remotes.toArray(new ICVSRemoteResource[remotes.size()]);
	}

	/**
	 * Stop any current fetch in process.
	 */
	public void stopFetching() {
		try {
			getEventHandlerJob().cancel();
			getEventHandlerJob().join();
		} catch (InterruptedException e) {
		}
	}
}
