/*******************************************************************************
 *  Copyright (c) 2004, 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
 *     QNX Software Systems - Mikhail Khodjaiants - Bug 88232
 *******************************************************************************/
package org.eclipse.debug.core.sourcelookup;

import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.ISafeRunnable;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.MultiStatus;
import org.eclipse.core.runtime.SafeRunner;
import org.eclipse.core.runtime.Status;
import org.eclipse.debug.core.DebugPlugin;
import org.eclipse.debug.core.ILaunch;
import org.eclipse.debug.core.ILaunchConfiguration;
import org.eclipse.debug.core.ILaunchConfigurationListener;
import org.eclipse.debug.core.ILaunchListener;
import org.eclipse.debug.core.ILaunchManager;
import org.eclipse.debug.core.IStatusHandler;
import org.eclipse.debug.core.model.IStackFrame;
import org.eclipse.debug.core.sourcelookup.containers.DefaultSourceContainer;
import org.eclipse.debug.internal.core.sourcelookup.SourceLookupMessages;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

/**
 * Directs source lookup among a collection of source lookup participants,
 * and a common collection of source containers.
 * Each source lookup participant is a source locator itself, which allows
 * more than one source locator to participate in source lookup for a
 * launch. Each source lookup participant searches for source in the source
 * containers managed by this director, and each participant is notified
 * of changes in the source containers (i.e. when the set of source
 * containers changes).
 * <p>
 * When a source director is initialized, it adds it self as a launch listener,
 * and automatically disposes itself when its associated launch is removed
 * from the launch manager. If a source director is instantiated by a client
 * that is not part of a launch, that client is responsible for disposing
 * the source director.
 * </p>
 * <p>
 * Clients may subclass this class.
 * </p>
 * @since 3.0
 * @see org.eclipse.debug.core.model.ISourceLocator
 * @see org.eclipse.debug.core.sourcelookup.ISourceContainer
 * @see org.eclipse.debug.core.sourcelookup.ISourceContainerType
 * @see org.eclipse.debug.core.sourcelookup.ISourcePathComputer
 * @see org.eclipse.debug.core.sourcelookup.ISourceLookupParticipant
 */
public abstract class AbstractSourceLookupDirector implements ISourceLookupDirector, ILaunchConfigurationListener, ILaunchListener {

	// source locator type identifier
	protected String fId;
	//ISourceLocatorParticipants that are listening for container changes
	protected ArrayList<ISourceLookupParticipant> fParticipants = new ArrayList<>();
	//list of current source containers
	protected ISourceContainer[] fSourceContainers = null;
	//the launch config associated with this director
	protected ILaunchConfiguration fConfig;
	//whether duplicates should be searched for or not
	protected boolean fDuplicates = false;
	// source path computer, or null if default
	protected ISourcePathComputer fComputer = null;
	/**
	 * Cache of resolved source elements when duplicates exist.
	 * Keys are the duplicates, values are the source element to use.
	 */
	protected Map<Object, Object> fResolvedElements = null;
	// current participant performing lookup or <code>null</code>
	private ISourceLookupParticipant fCurrentParticipant;

	protected static final IStatus fPromptStatus = new Status(IStatus.INFO, "org.eclipse.debug.ui", 200, "", null);  //$NON-NLS-1$//$NON-NLS-2$
	protected static final IStatus fResolveDuplicatesStatus = new Status(IStatus.INFO, "org.eclipse.debug.ui", 205, "", null);  //$NON-NLS-1$//$NON-NLS-2$

	// XML nodes & attributes for persistence
	protected static final String DIRECTOR_ROOT_NODE = "sourceLookupDirector"; //$NON-NLS-1$
	protected static final String CONTAINERS_NODE = "sourceContainers"; //$NON-NLS-1$
	protected static final String DUPLICATES_ATTR = "duplicates"; //$NON-NLS-1$
	protected static final String CONTAINER_NODE = "container"; //$NON-NLS-1$
	protected static final String CONTAINER_TYPE_ATTR = "typeId"; //$NON-NLS-1$
	protected static final String CONTAINER_MEMENTO_ATTR = "memento"; //$NON-NLS-1$

	class SourceLookupQuery implements ISafeRunnable {

		private List<Object> fSourceElements = new ArrayList<>();
		private Object fElement = null;
		private Throwable fException = null;

		SourceLookupQuery(Object element) {
			fElement = element;
		}

		@Override
		public void handleException(Throwable exception) {
			fException = exception;
		}

		/**
		 * Returns any exception that occurred during source lookup.
		 *
		 * @return the (any) exception that occured during source lookup
		 */
		public Throwable getException() {
			return fException;
		}

		@Override
		public void run() throws Exception {
			MultiStatus multiStatus = null;
			CoreException single = null;
			ISourceLookupParticipant[] participants = getParticipants();
			try {
				for (ISourceLookupParticipant participant : participants) {
					setCurrentParticipant(participant);
					Object[] sourceArray;
					try {
						sourceArray = participant.findSourceElements(fElement);
						if (sourceArray !=null && sourceArray.length > 0) {
							if (isFindDuplicates()) {
								for (Object s : sourceArray) {
									if (!checkDuplicate(s, fSourceElements)) {
										fSourceElements.add(s);
									}
								}
							} else {
								fSourceElements.add(sourceArray[0]);
								return;
							}
						}
					} catch (CoreException e) {
						if (single == null) {
							single = e;
						} else if (multiStatus == null) {
							multiStatus = new MultiStatus(DebugPlugin.getUniqueIdentifier(), DebugPlugin.ERROR, new IStatus[]{single.getStatus()}, SourceLookupMessages.Source_Lookup_Error, null);
							multiStatus.add(e.getStatus());
						} else {
							multiStatus.add(e.getStatus());
						}
					}
				}
			} finally {
				setCurrentParticipant(null);
			}
			if (fSourceElements.isEmpty()) {
				// set exception if there was one
				if (multiStatus != null) {
					fException = new CoreException(multiStatus);
				} else if (single != null) {
					fException = single;
				}
			}
		}

		public List<Object> getSourceElements() {
			return fSourceElements;
		}

		public void dispose() {
			fElement = null;
			fSourceElements = null;
			fException = null;
		}

	}

	/**
	 * Constructs source lookup director
	 */
	public AbstractSourceLookupDirector() {
	}

	/**
	 * Sets the type identifier for this source locator's type
	 *
	 * @param id corresponds to source locator type identifier for a
	 *  persistable source locator
	 */
	public void setId(String id) {
		fId = id;
	}

	@Override
	public synchronized void dispose() {
		ILaunchManager launchManager = DebugPlugin.getDefault().getLaunchManager();
		launchManager.removeLaunchConfigurationListener(this);
		launchManager.removeLaunchListener(this);
		for (ISourceLookupParticipant participant : fParticipants) {
			//director may also be a participant
			if(participant != this) {
				participant.dispose();
			}
		}
		fParticipants.clear();
		if (fSourceContainers != null) {
			for (ISourceContainer container : fSourceContainers) {
				container.dispose();
			}
		}
		fSourceContainers = null;
		fResolvedElements = null;
	}

	/**
	 * Throws an exception with the given message and underlying exception.
	 *
	 * @param message error message
	 * @param exception underlying exception, or <code>null</code>
	 * @throws CoreException if a problem is encountered
	 */
	protected void abort(String message, Throwable exception) throws CoreException {
		IStatus status = new Status(IStatus.ERROR, DebugPlugin.getUniqueIdentifier(), DebugPlugin.ERROR, message, exception);
		throw new CoreException(status);
	}

	/**
	 * Constructs source containers from a list of container mementos.
	 *
	 * @param list the list of nodes to be parsed
	 * @exception CoreException if parsing encounters an error
	 * @return a list of source containers
	 */
	private List<ISourceContainer> parseSourceContainers(NodeList list) throws CoreException {
		List<ISourceContainer> containers = new ArrayList<>();
		for (int i=0; i < list.getLength(); i++) {
			if(!(list.item(i).getNodeType() == Node.ELEMENT_NODE)) {
				continue;
			}
			Element element = (Element)list.item(i);
			String typeId = element.getAttribute(CONTAINER_TYPE_ATTR);
			if (typeId == null || typeId.equals("")) {	 //$NON-NLS-1$
				abort(SourceLookupMessages.AbstractSourceLookupDirector_11, null);
			}
			ISourceContainerType type = DebugPlugin.getDefault().getLaunchManager().getSourceContainerType(typeId);
			if(type != null) {
				String memento = element.getAttribute(CONTAINER_MEMENTO_ATTR);
				if (memento == null || memento.equals("")) {	 //$NON-NLS-1$
					abort(SourceLookupMessages.AbstractSourceLookupDirector_13, null);
				}
				ISourceContainer container = type.createSourceContainer(memento);
				containers.add(container);
			}
			else {
				abort(MessageFormat.format(SourceLookupMessages.AbstractSourceLookupDirector_12, new Object[] { typeId }), null);
			}
		}
		return containers;
	}

	/**
	 * Registers the given source lookup participant. Has no effect if an identical
	 * participant is already registered. Participants receive notification
	 * when the source containers associated with this source director change.
	 *
	 * @param participant the participant to register
	 */
	private synchronized void addSourceLookupParticipant(ISourceLookupParticipant participant) {
		if (!fParticipants.contains(participant)) {
			fParticipants.add(participant);
			participant.init(this);
		}
	}

	@Override
	public synchronized ISourceContainer[] getSourceContainers() {
		if (fSourceContainers == null) {
			return new ISourceContainer[0];
		}
		ISourceContainer[] copy = new ISourceContainer[fSourceContainers.length];
		System.arraycopy(fSourceContainers, 0, copy, 0, fSourceContainers.length);
		return copy;
	}

	@Override
	public boolean isFindDuplicates() {
		return fDuplicates;
	}

	@Override
	public void setFindDuplicates(boolean duplicates) {
		fDuplicates = duplicates;
	}

	/**
	 * Removes the given participant from the list of registered participants.
	 * Has no effect if an identical participant is not already registered.
	 *
	 * @param participant the participant to remove
	 */
	private synchronized void removeSourceLookupParticipant(ISourceLookupParticipant participant) {
		if (fParticipants.remove(participant)) {
			participant.dispose();
		}
	}

	@Override
	public void launchConfigurationAdded(ILaunchConfiguration configuration) {
		ILaunchConfiguration from = DebugPlugin.getDefault().getLaunchManager().getMovedFrom(configuration);
		if (from != null && from.equals(getLaunchConfiguration())) {
			fConfig = configuration;
		}
	}

	/*
	 * Updates source containers in response to changes in underlying launch
	 * configuration. Only responds to changes in non-working copies.
	 * @see org.eclipse.debug.core.ILaunchConfigurationListener#
	 * launchConfigurationChanged(org.eclipse.debug.core.ILaunchConfiguration)
	 */
	@Override
	public void launchConfigurationChanged(ILaunchConfiguration configuration) {
		if (fConfig == null || configuration.isWorkingCopy()) {
			return;
		}
		if(fConfig.equals(configuration)) {
			try{
				String locatorMemento = configuration.getAttribute(ILaunchConfiguration.ATTR_SOURCE_LOCATOR_MEMENTO,(String)null);
				if (locatorMemento == null) {
					initializeDefaults(configuration);
				} else {
					initializeFromMemento(locatorMemento, configuration);
				}
			} catch (CoreException e){
			}
		}
	}


	@Override
	public void launchConfigurationRemoved(ILaunchConfiguration configuration) {
		if (configuration.equals(getLaunchConfiguration())) {
			if (DebugPlugin.getDefault().getLaunchManager().getMovedTo(configuration) == null) {
				fConfig = null;
			}
		}
	}

	@Override
	public synchronized String getMemento() throws CoreException {
		Document doc = DebugPlugin.newDocument();
		Element rootNode = doc.createElement(DIRECTOR_ROOT_NODE);
		doc.appendChild(rootNode);

		Element pathNode = doc.createElement(CONTAINERS_NODE);
		if(fDuplicates) {
			pathNode.setAttribute(DUPLICATES_ATTR, "true"); //$NON-NLS-1$
		} else {
			pathNode.setAttribute(DUPLICATES_ATTR, "false"); //$NON-NLS-1$
		}
		rootNode.appendChild(pathNode);
		if(fSourceContainers !=null){
			for (ISourceContainer container : fSourceContainers) {
				Element node = doc.createElement(CONTAINER_NODE);
				ISourceContainerType type = container.getType();
				node.setAttribute(CONTAINER_TYPE_ATTR, type.getId());
				node.setAttribute(CONTAINER_MEMENTO_ATTR, type.getMemento(container));
				pathNode.appendChild(node);
			}
		}
		return DebugPlugin.serializeDocument(doc);
	}

	@Override
	public void initializeFromMemento(String memento) throws CoreException {
		doInitializeFromMemento(memento, true);
	}

	/**
	 * Initializes this source lookup director from the given memento.
	 * Disposes itself before initialization if specified.
	 *
	 * @param memento source locator memento
	 * @param dispose whether to dispose any current source containers and participants
	 *  before initializing
	 * @throws CoreException if an exception occurs during initialization
	 * @since 3.1
	 */
	protected void doInitializeFromMemento(String memento, boolean dispose) throws CoreException {
		if (dispose) {
			dispose();
		}
		Element rootElement = DebugPlugin.parseDocument(memento);
		if (!rootElement.getNodeName().equalsIgnoreCase(DIRECTOR_ROOT_NODE)) {
			abort(SourceLookupMessages.AbstractSourceLookupDirector_14, null);
		}
		NodeList list = rootElement.getChildNodes();
		int length = list.getLength();
		for (int i = 0; i < length; ++i) {
			Node node = list.item(i);
			short type = node.getNodeType();
			if (type == Node.ELEMENT_NODE) {
				Element entry = (Element) node;
				if(entry.getNodeName().equalsIgnoreCase(CONTAINERS_NODE)){
					setFindDuplicates("true".equals(entry.getAttribute(DUPLICATES_ATTR))); //$NON-NLS-1$
					NodeList children = entry.getChildNodes();
					List<ISourceContainer> containers = parseSourceContainers(children);
					setSourceContainers(containers.toArray(new ISourceContainer[containers.size()]));
				}
			}
		}
		initializeParticipants();
	}

	/**
	 * Sets the source containers used by this source lookup
	 * director.
	 *
	 * @param containers source containers to search
	 */
	@Override
	public void setSourceContainers(ISourceContainer[] containers) {
		synchronized (this) {
			List<ISourceContainer> list = Arrays.asList(containers);
			ISourceContainer[] old = getSourceContainers();
			for (ISourceContainer container : old) {
				// skip overlapping containers
				if (!list.contains(container)) {
					container.dispose();
				}
			}
			fSourceContainers = containers;
			for (ISourceContainer container : containers) {
				container.init(this);
			}
		}
		// clear resolved duplicates
		fResolvedElements = null;
		// notify participants
		ISourceLookupParticipant[] participants = getParticipants();
		for (ISourceLookupParticipant participant : participants) {
			participant.sourceContainersChanged(this);
		}
	}

	/*
	 * Would be better to accept Object so this can be used for breakpoints and
	 * other objects.
	 */
	@Override
	public Object getSourceElement(IStackFrame stackFrame) {
		return getSourceElement((Object)stackFrame);
	}

	/**
	 * Performs a source lookup query for the given element
	 * returning the source elements associated with the element.
	 *
	 * @param element stack frame
	 * @return list of associated source elements
	 */
	protected List<Object> doSourceLookup(Object element) {
		SourceLookupQuery query = new SourceLookupQuery(element);
		SafeRunner.run(query);
		List<Object> sources = query.getSourceElements();
		Throwable exception = query.getException();
		if (exception != null) {
			if (exception instanceof CoreException) {
				CoreException ce = (CoreException) exception;
				if (ce.getStatus().getSeverity() == IStatus.ERROR) {
					DebugPlugin.log(ce);
				}
			} else {
				DebugPlugin.log(exception);
			}
		}
		query.dispose();
		return sources;
	}

	/**
	 * Returns the source element to associate with the given element.
	 * This method is called when more than one source element has been found
	 * for an element, and allows the source director to select a single
	 * source element to associate with the element.
	 * <p>
	 * Subclasses should override this method as appropriate. For example,
	 * to prompt the user to choose a source element.
	 * </p>
	 * @param element the debug artifact for which source is being searched for
	 * @param sources the source elements found for the given element
	 * @return a single source element for the given element
	 */
	public Object resolveSourceElement(Object element, List<Object> sources) {
		// check the duplicates cache first
		for (Object dup : sources) {
			Object resolved = getCachedElement(dup);
			if (resolved != null) {
				return resolved;
			}
		}
		// consult a status handler
		IStatusHandler prompter = DebugPlugin.getDefault().getStatusHandler(fPromptStatus);
		if (prompter != null) {
			try {
				Object result = prompter.handleStatus(fResolveDuplicatesStatus, new Object[]{element, sources});
				if (result != null) {
					cacheResolvedElement(sources, result);
					return result;
				}
			} catch (CoreException e) {
			}
		}
		return sources.get(0);
	}

	/**
	 * Checks if the object being added to the list of sources is a duplicate of what's already in the list
	 * @param sourceToAdd the new source file to be added
	 * @param sources the list that the source will be compared against
	 * @return true if it is already in the list, false if it is a new object
	 */
	private boolean checkDuplicate(Object sourceToAdd, List<Object> sources) {
		if(sources.isEmpty()) {
			return false;
		}
		for (Object obj : sources) {
			if (obj.equals(sourceToAdd)) {
				return true;
			}
		}
		return false;
	}

	@Override
	public void initializeFromMemento(String memento, ILaunchConfiguration configuration) throws CoreException {
		dispose();
		setLaunchConfiguration(configuration);
		doInitializeFromMemento(memento, false);
	}

	@Override
	public void initializeDefaults(ILaunchConfiguration configuration) throws CoreException {
		dispose();
		setLaunchConfiguration(configuration);
		setSourceContainers(new ISourceContainer[]{new DefaultSourceContainer()});
		initializeParticipants();
	}

	@Override
	public ILaunchConfiguration getLaunchConfiguration() {
		return fConfig;
	}

	/**
	 * Sets the launch configuration associated with this source lookup
	 * director. If the given configuration is a working copy, this director
	 * will respond to changes the working copy. If the given configuration
	 * is a persisted launch configuration, this director will respond to changes
	 * in the persisted launch configuration.
	 *
	 * @param configuration launch configuration to associate with this
	 *  source lookup director, or <code>null</code> if none
	 */
	protected void setLaunchConfiguration(ILaunchConfiguration configuration) {
		fConfig = configuration;
		ILaunchManager launchManager = DebugPlugin.getDefault().getLaunchManager();
		launchManager.addLaunchConfigurationListener(this);
		launchManager.addLaunchListener(this);
	}

	@Override
	public void launchAdded(ILaunch launch) {
	}

	@Override
	public void launchChanged(ILaunch launch) {
	}

	@Override
	public void launchRemoved(ILaunch launch) {
		if (this.equals(launch.getSourceLocator())) {
			dispose();
		}
	}

	@Override
	public synchronized ISourceLookupParticipant[] getParticipants() {
		return fParticipants.toArray(new ISourceLookupParticipant[fParticipants.size()]);
	}

	@Override
	public boolean supportsSourceContainerType(ISourceContainerType type) {
		return true;
	}

	/**
	 * Caches the resolved source element to use when one of the following
	 * duplicates is found.
	 *
	 * @param duplicates duplicates source elements
	 * @param sourceElement chosen source element to use in place of the
	 *  duplicates
	 */
	protected void cacheResolvedElement(List<Object> duplicates, Object sourceElement) {
		if (fResolvedElements == null) {
			fResolvedElements = new HashMap<>(10);
		}
		for (Object dup : duplicates) {
			fResolvedElements.put(dup, sourceElement);
		}
	}

	/**
	 * Returns the cached source element to use when the given duplicate
	 * is encountered.
	 *
	 * @param duplicate duplicates source element
	 * @return element to use in the duplicate's place
	 */
	protected Object getCachedElement(Object duplicate) {
		if (fResolvedElements != null) {
			return fResolvedElements.get(duplicate);
		}
		return null;
	}

	/**
	 * Clears any cached source element associated with the given duplicate
	 * is source element.
	 *
	 * @param duplicate duplicate source element to cache resolved results
	 *  for
	 */
	protected void clearCachedElement(Object duplicate) {
		if (fResolvedElements != null) {
			fResolvedElements.remove(duplicate);
		}
	}

	@Override
	public void clearSourceElements(Object element) {
		List<Object> list = doSourceLookup(element);
		if (list.size() > 0) {
			for (Object obj : list) {
				clearCachedElement(obj);
			}
		}
	}

	@Override
	public void addParticipants(ISourceLookupParticipant[] participants) {
		for (ISourceLookupParticipant participant : participants) {
			addSourceLookupParticipant(participant);
			participant.sourceContainersChanged(this);
		}
	}

	@Override
	public void removeParticipants(ISourceLookupParticipant[] participants) {
		for (ISourceLookupParticipant participant : participants) {
			removeSourceLookupParticipant(participant);
		}
	}

	@Override
	public String getId() {
		return fId;
	}

	@Override
	public ISourcePathComputer getSourcePathComputer() {
		if (fComputer == null && getLaunchConfiguration() != null) {
			try {
				return DebugPlugin.getDefault().getLaunchManager().getSourcePathComputer(getLaunchConfiguration());
			} catch (CoreException e) {
			}
		}
		return fComputer;
	}

	@Override
	public void setSourcePathComputer(ISourcePathComputer computer) {
		fComputer = computer;
	}

	@Override
	public Object[] findSourceElements(Object object) throws CoreException {
		SourceLookupQuery query = new SourceLookupQuery(object);
		SafeRunner.run(query);
		List<Object> sources = query.getSourceElements();
		Throwable exception = query.getException();
		query.dispose();
		if (exception != null && sources.isEmpty()) {
			if (exception instanceof CoreException) {
				throw (CoreException)exception;
			}
			abort(SourceLookupMessages.AbstractSourceLookupDirector_10, exception);
		}
		return sources.toArray();
	}

	@Override
	public Object getSourceElement(Object element) {
		List<Object> sources = doSourceLookup(element);
		if(sources.size() == 1) {
			return sources.get(0);
		} else if(sources.size() > 1) {
			return resolveSourceElement(element, sources);
		} else {
			return null;
		}
	}

	/**
	 * Sets the current participant or <code>null</code> if none.
	 *
	 * @param participant active participant or <code>null</code>
	 */
	private void setCurrentParticipant(ISourceLookupParticipant participant) {
		fCurrentParticipant = participant;
	}

	/**
	 * Returns the participant currently looking up source or <code>null</code>
	 * if none.
	 *
	 * @return the participant currently looking up source or <code>null</code>
	 * if none
	 * @since 3.5
	 */
	public ISourceLookupParticipant getCurrentParticipant() {
		return fCurrentParticipant;
	}
}
