/*******************************************************************************
 * Copyright (c) 2006, 2018 Wind River Systems 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:
 *     Wind River Systems - initial API and implementation
 *     John Dallaway - Report command execution error (Bug 539455)
 *******************************************************************************/
package org.eclipse.cdt.dsf.debug.ui.viewmodel;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;

import org.eclipse.cdt.debug.core.CDebugUtils;
import org.eclipse.cdt.dsf.concurrent.ConfinedToDsfExecutor;
import org.eclipse.cdt.dsf.concurrent.DataRequestMonitor;
import org.eclipse.cdt.dsf.concurrent.DsfExecutor;
import org.eclipse.cdt.dsf.concurrent.DsfRunnable;
import org.eclipse.cdt.dsf.concurrent.IDsfStatusConstants;
import org.eclipse.cdt.dsf.concurrent.RequestMonitor;
import org.eclipse.cdt.dsf.concurrent.ThreadSafe;
import org.eclipse.cdt.dsf.datamodel.AbstractDMEvent;
import org.eclipse.cdt.dsf.datamodel.DMContexts;
import org.eclipse.cdt.dsf.debug.service.IRunControl;
import org.eclipse.cdt.dsf.debug.service.IRunControl.IExecutionDMContext;
import org.eclipse.cdt.dsf.debug.service.IRunControl.IResumedDMEvent;
import org.eclipse.cdt.dsf.debug.service.IRunControl.ISuspendedDMEvent;
import org.eclipse.cdt.dsf.debug.service.IRunControl.StateChangeReason;
import org.eclipse.cdt.dsf.debug.service.IRunControl.StepType;
import org.eclipse.cdt.dsf.debug.ui.IDsfDebugUIConstants;
import org.eclipse.cdt.dsf.internal.ui.DsfUIPlugin;
import org.eclipse.cdt.dsf.service.DsfServiceEventHandler;
import org.eclipse.cdt.dsf.service.DsfServicesTracker;
import org.eclipse.cdt.dsf.service.DsfSession;
import org.eclipse.core.runtime.Platform;
import org.eclipse.jface.preference.IPreferenceStore;
import org.eclipse.jface.util.IPropertyChangeListener;
import org.eclipse.jface.util.PropertyChangeEvent;

/**
 * This class builds on top of standard run control service to provide
 * functionality for step queuing and delaying. Step queuing essentially allows
 * user to press and hold the step key and achieve maximum stepping speed. If
 * this class is used, other service implementations, such as stack and
 * expressions, can use it to avoid requesting data from debugger back end if
 * another step is about to be executed.
 *
 * @since 1.1
 */
@ConfinedToDsfExecutor("#getExecutor()")
public final class SteppingController {
	/**
	 * Default delay in milliseconds, that it takes the SteppingTimedOutEvent
	 * event to be issued after a step is started.
	 * @see SteppingTimedOutEvent
	 * @see #setStepTimeout(int)
	 * @see #getStepTimeout()
	 */
	public final static int STEPPING_TIMEOUT = 500;

	/**
	 * The depth of the step queue.  In other words, the maximum number of steps
	 * that are queued before the step queue manager is throwing them away.
	 */
	public final static int STEP_QUEUE_DEPTH = 2;

	/**
	 * The maximum delay (in milliseconds) between steps when synchronized
	 * stepping is enabled. This also serves as a safeguard in the case stepping
	 * control participants fail to indicate completion of event processing.
	 */
	public final static int MAX_STEP_DELAY = 5000;

	private final static boolean DEBUG = Boolean
			.parseBoolean(Platform.getDebugOption("org.eclipse.cdt.dsf.ui/debug/stepping")); //$NON-NLS-1$

	/**
	 * Indicates that the given context has been stepping for some time,
	 * and the UI (views and actions) may need to be updated accordingly.
	 */
	public static final class SteppingTimedOutEvent extends AbstractDMEvent<IExecutionDMContext> {
		private SteppingTimedOutEvent(IExecutionDMContext execCtx) {
			super(execCtx);
		}
	}

	/**
	 * Interface for clients interested in stepping control. When a stepping
	 * control participant is registered with the stepping controller, it is
	 * expected to call
	 * {@link SteppingController#doneStepping(IExecutionDMContext, ISteppingControlParticipant)
	 * doneStepping} as soon as a "step", i.e. a suspended event has been
	 * processed. If synchronized stepping is enabled, further stepping is
	 * blocked until all stepping control participants have indicated completion
	 * of event processing or the maximum timeout
	 * {@link SteppingController#MAX_STEP_DELAY} has been reached.
	 *
	 * @see SteppingController#addSteppingControlParticipant(ISteppingControlParticipant)
	 * @see SteppingController#removeSteppingControlParticipant(ISteppingControlParticipant)
	 */
	public interface ISteppingControlParticipant {
	}

	private static class StepRequest {
		IExecutionDMContext fContext;
		StepType fStepType;
		boolean inProgress = false;

		StepRequest(IExecutionDMContext execCtx, StepType type) {
			fContext = execCtx;
			fStepType = type;
		}
	}

	private class TimeOutRunnable extends DsfRunnable {

		TimeOutRunnable(IExecutionDMContext dmc) {
			fDmc = dmc;
		}

		private final IExecutionDMContext fDmc;

		@Override
		public void run() {
			fTimedOutFutures.remove(fDmc);

			if (getSession().isActive()) {
				fTimedOutFlags.put(fDmc, Boolean.TRUE);
				enableStepping(fDmc);
				// Issue the stepping time-out event.
				getSession().dispatchEvent(new SteppingTimedOutEvent(fDmc), null);
			}
		}
	}

	private final DsfSession fSession;
	private final DsfServicesTracker fServicesTracker;

	private IRunControl fRunControl;
	private int fQueueDepth = STEP_QUEUE_DEPTH;

	private final Map<IExecutionDMContext, List<StepRequest>> fStepQueues = new HashMap<>();
	private final Map<IExecutionDMContext, Boolean> fTimedOutFlags = new HashMap<>();
	private final Map<IExecutionDMContext, ScheduledFuture<?>> fTimedOutFutures = new HashMap<>();

	/**
	 * Records the time of the last step for an execution context.
	 */
	private final Map<IExecutionDMContext, Long> fLastStepTimes = new HashMap<>();

	/**
	 * Minimum step interval in milliseconds.
	 */
	private volatile int fMinStepInterval;

	/**
	 * Step timeout in milliseconds.
	 */
	private volatile int fStepTimeout = STEPPING_TIMEOUT;

	/**
	 * Map of execution contexts for which a step is in progress.
	 */
	private final Map<IExecutionDMContext, List<ISteppingControlParticipant>> fStepInProgress = new HashMap<>();

	/**
	 * List of registered stepping control participants.
	 */
	private final List<ISteppingControlParticipant> fParticipants = Collections
			.synchronizedList(new ArrayList<ISteppingControlParticipant>());

	/**
	 * Property change listener.  It updates the stepping control settings.
	 */
	private IPropertyChangeListener fPreferencesListener;

	public SteppingController(DsfSession session) {
		fSession = session;
		fServicesTracker = new DsfServicesTracker(DsfUIPlugin.getBundleContext(), session.getId());

		final IPreferenceStore store = DsfUIPlugin.getDefault().getPreferenceStore();

		fPreferencesListener = new IPropertyChangeListener() {
			@Override
			public void propertyChange(final PropertyChangeEvent event) {
				handlePropertyChanged(store, event);
			}
		};
		store.addPropertyChangeListener(fPreferencesListener);

		setMinimumStepInterval(store.getInt(IDsfDebugUIConstants.PREF_MIN_STEP_INTERVAL));
	}

	@ThreadSafe
	public void dispose() {
		try {
			fSession.getExecutor().execute(new DsfRunnable() {
				@Override
				public void run() {
					if (fRunControl != null) {
						getSession().removeServiceEventListener(SteppingController.this);
					}
				}
			});
		} catch (RejectedExecutionException e) {
			// Session already gone.
		}

		IPreferenceStore store = DsfUIPlugin.getDefault().getPreferenceStore();
		store.removePropertyChangeListener(fPreferencesListener);

		fServicesTracker.dispose();
	}

	/**
	 * Configure the minimum time (in milliseconds) to wait between steps.
	 *
	 * @param interval
	 */
	@ThreadSafe
	public void setMinimumStepInterval(int interval) {
		fMinStepInterval = interval;
	}

	/**
	 * Configure the step timeout value. This controls the delay how long it takes
	 * to send out the {@link SteppingTimedOutEvent} after a step has been issued.
	 *
	 * @param timeout  The timeout value in milliseconds (must be non-negative).
	 * @since 2.2
	 */
	@ThreadSafe
	public void setStepTimeout(int timeout) {
		assert timeout >= 0;
		fStepTimeout = timeout;
	}

	/**
	 * @return the currently configured step timeout value
	 * @since 2.2
	 */
	@ThreadSafe
	public int getStepTimeout() {
		return fStepTimeout;
	}

	/**
	 * Register given stepping control participant.
	 * <p>
	 * Participants are obliged to call
	 * {@link #doneStepping(IExecutionDMContext, ISteppingControlParticipant)}
	 * when they have received and completed processing an
	 * {@link ISuspendedDMEvent}. If synchronized stepping is enabled, further
	 * stepping is disabled until all participants have indicated completion of
	 * processing the event.
	 * </p>
	 *
	 * @param participant
	 */
	@ThreadSafe
	public void addSteppingControlParticipant(ISteppingControlParticipant participant) {
		fParticipants.add(participant);
	}

	/**
	 * Unregister given stepping control participant.
	 *
	 * @param participant
	 */
	@ThreadSafe
	public void removeSteppingControlParticipant(final ISteppingControlParticipant participant) {
		fParticipants.remove(participant);
	}

	/**
	 * Indicate that participant has completed processing of the last step.
	 *
	 * @param execCtx
	 */
	public void doneStepping(final IExecutionDMContext execCtx, final ISteppingControlParticipant participant) {
		if (DEBUG)
			System.out
					.println("[SteppingController] doneStepping participant=" + participant.getClass().getSimpleName()); //$NON-NLS-1$
		List<ISteppingControlParticipant> participants = fStepInProgress.get(execCtx);
		if (participants != null) {
			participants.remove(participant);
			if (participants.isEmpty()) {
				doneStepping(execCtx);
			}
		} else {
			for (IExecutionDMContext disabledCtx : fStepInProgress.keySet()) {
				if (DMContexts.isAncestorOf(disabledCtx, execCtx)) {
					participants = fStepInProgress.get(disabledCtx);
					if (participants != null) {
						participants.remove(participant);
						if (participants.isEmpty()) {
							doneStepping(disabledCtx);
						}
					}
				}
			}
		}
	}

	@ThreadSafe
	public DsfSession getSession() {
		return fSession;
	}

	/**
	 * All access to this class should happen through this executor.
	 * @return the executor this class is confined to
	 */
	@ThreadSafe
	public DsfExecutor getExecutor() {
		return getSession().getExecutor();
	}

	private DsfServicesTracker getServicesTracker() {
		return fServicesTracker;
	}

	private IRunControl getRunControl() {
		if (fRunControl == null) {
			fRunControl = getServicesTracker().getService(IRunControl.class);
			getSession().addServiceEventListener(this, null);
		}
		return fRunControl;
	}

	/**
	 * Checks whether a step command can be queued up for given context.
	 */
	public void canEnqueueStep(IExecutionDMContext execCtx, StepType stepType, DataRequestMonitor<Boolean> rm) {
		if (doCanEnqueueStep(execCtx, stepType)) {
			rm.setData(true);
			rm.done();
		} else {
			getRunControl().canStep(execCtx, stepType, rm);
		}
	}

	private boolean doCanEnqueueStep(IExecutionDMContext execCtx, StepType stepType) {
		return getRunControl().isStepping(execCtx) && !isSteppingTimedOut(execCtx);
	}

	/**
	 * Check whether the next step on the given execution context should be delayed
	 * based on the configured step delay.
	 *
	 * @param execCtx
	 * @return <code>true</code> if the step should be delayed
	 */
	private boolean shouldDelayStep(IExecutionDMContext execCtx) {
		final int stepDelay = getStepDelay(execCtx);
		if (DEBUG)
			System.out.println("[SteppingController] shouldDelayStep delay=" + stepDelay); //$NON-NLS-1$
		return stepDelay > 0;
	}

	/**
	 * Compute the delay in milliseconds before the next step for the given context may be executed.
	 *
	 * @param execCtx
	 * @return  the number of milliseconds before the next possible step
	 */
	private int getStepDelay(IExecutionDMContext execCtx) {
		int minStepInterval = fMinStepInterval;
		if (minStepInterval > 0) {
			for (IExecutionDMContext lastStepCtx : fLastStepTimes.keySet()) {
				if (execCtx.equals(lastStepCtx) || DMContexts.isAncestorOf(execCtx, lastStepCtx)) {
					long now = System.currentTimeMillis();
					int delay = (int) (fLastStepTimes.get(lastStepCtx) + minStepInterval - now);
					return Math.max(delay, 0);
				}
			}
		}
		return 0;
	}

	private void updateLastStepTime(IExecutionDMContext execCtx) {
		long now = System.currentTimeMillis();
		fLastStepTimes.put(execCtx, now);
		for (IExecutionDMContext lastStepCtx : fLastStepTimes.keySet()) {
			if (!execCtx.equals(lastStepCtx) && DMContexts.isAncestorOf(execCtx, lastStepCtx)) {
				fLastStepTimes.put(lastStepCtx, now);
			}
		}
	}

	private long getLastStepTime(IExecutionDMContext execCtx) {
		if (fLastStepTimes.containsKey(execCtx)) {
			return fLastStepTimes.get(execCtx);
		}
		for (IExecutionDMContext lastStepCtx : fLastStepTimes.keySet()) {
			if (DMContexts.isAncestorOf(execCtx, lastStepCtx)) {
				return fLastStepTimes.get(lastStepCtx);
			}
		}
		return 0;
	}

	/**
	 * Returns the number of step commands that are queued for given execution
	 * context.
	 */
	public int getPendingStepCount(IExecutionDMContext execCtx) {
		List<StepRequest> stepQueue = getStepQueue(execCtx);
		if (stepQueue == null)
			return 0;
		return stepQueue.size();
	}

	/**
	 * Adds a step command to the execution queue for given context.
	 * @param execCtx Execution context that should perform the step.
	 * @param stepType Type of step to execute.
	 */
	public void enqueueStep(final IExecutionDMContext execCtx, final StepType stepType) {
		if (DEBUG)
			System.out.println("[SteppingController] enqueueStep ctx=" + execCtx); //$NON-NLS-1$
		if (!shouldDelayStep(execCtx) || doCanEnqueueStep(execCtx, stepType)) {
			doEnqueueStep(execCtx, stepType);
			processStepQueue(execCtx);
		}
	}

	private void doStep(final IExecutionDMContext execCtx, final StepType stepType) {
		if (DEBUG)
			System.out.println("[SteppingController] doStep ctx=" + execCtx); //$NON-NLS-1$
		disableStepping(execCtx);
		updateLastStepTime(execCtx);

		getRunControl().step(execCtx, stepType, new RequestMonitor(getExecutor(), null) {
			@Override
			protected void handleSuccess() {
				fTimedOutFlags.remove(execCtx);
				ScheduledFuture<?> currentTimeOutFuture = fTimedOutFutures.get(execCtx);
				if (currentTimeOutFuture != null) {
					currentTimeOutFuture.cancel(false);
				}
				fTimedOutFutures.put(execCtx,
						getExecutor().schedule(new TimeOutRunnable(execCtx), fStepTimeout, TimeUnit.MILLISECONDS));
			}

			@Override
			protected void handleFailure() {
				// in case of a failed step - enable stepping again (bug 265267)
				enableStepping(execCtx);
				if (getStatus().getCode() == IDsfStatusConstants.INVALID_STATE) {
					// Ignore errors.  During fast stepping there can be expected race
					// conditions leading to stepping errors.
					return;
				}
				super.handleFailure();
			}

			@Override
			protected void handleError() {
				super.handleError();
				CDebugUtils.error(getStatus(), SteppingController.this);
			}
		});
	}

	/**
	 * Enqueue the given step for later execution.
	 *
	 * @param execCtx
	 * @param stepType
	 */
	private void doEnqueueStep(final IExecutionDMContext execCtx, final StepType stepType) {
		List<StepRequest> stepQueue = fStepQueues.get(execCtx);
		if (stepQueue == null) {
			stepQueue = new LinkedList<>();
			fStepQueues.put(execCtx, stepQueue);
		}
		if (stepQueue.size() < fQueueDepth) {
			stepQueue.add(new StepRequest(execCtx, stepType));
		}
	}

	/**
	 * Returns whether the step instruction for the given context has timed out.
	 */
	public boolean isSteppingTimedOut(IExecutionDMContext execCtx) {
		for (IExecutionDMContext timedOutCtx : fTimedOutFlags.keySet()) {
			if (execCtx.equals(timedOutCtx) || DMContexts.isAncestorOf(execCtx, timedOutCtx)) {
				return fTimedOutFlags.get(timedOutCtx);
			}
		}
		return false;
	}

	/**
	 * Process next step on queue if any.
	 * @param execCtx
	 */
	private void processStepQueue(final IExecutionDMContext execCtx) {
		final List<StepRequest> queue = getStepQueue(execCtx);
		if (queue != null) {
			final int stepDelay = getStepDelay(execCtx);
			if (stepDelay > 0) {
				getExecutor().schedule(new DsfRunnable() {
					@Override
					public void run() {
						processStepQueue(execCtx);
					}
				}, stepDelay, TimeUnit.MILLISECONDS);
				return;
			}
			final StepRequest request = queue.get(0);
			if (DEBUG)
				System.out.println("[SteppingController] processStepQueue request-in-progress=" + request.inProgress); //$NON-NLS-1$
			if (!request.inProgress) {
				if (isSteppingDisabled(request.fContext)) {
					return;
				}
				request.inProgress = true;
				getRunControl().canStep(request.fContext, request.fStepType,
						new DataRequestMonitor<Boolean>(getExecutor(), null) {
							@Override
							protected void handleCompleted() {
								if (isSuccess() && getData()) {
									queue.remove(0);
									if (queue.isEmpty())
										fStepQueues.remove(request.fContext);
									doStep(request.fContext, request.fStepType);
								} else {
									// For whatever reason we can't step anymore, so clear out
									// the step queue.
									fStepQueues.remove(request.fContext);
								}
							}
						});
			}
		}
	}

	private List<StepRequest> getStepQueue(IExecutionDMContext execCtx) {
		List<StepRequest> queue = fStepQueues.get(execCtx);
		if (queue == null) {
			for (IExecutionDMContext stepCtx : fStepQueues.keySet()) {
				if (DMContexts.isAncestorOf(stepCtx, execCtx)) {
					queue = fStepQueues.get(stepCtx);
					break;
				}
			}
		}
		return queue;
	}

	/**
	 * Disable stepping for the given execution context.
	 *
	 * @param execCtx
	 */
	private void disableStepping(IExecutionDMContext execCtx) {
		if (!fParticipants.isEmpty()) {
			fStepInProgress.put(execCtx, new ArrayList<>(fParticipants));
		}
	}

	/**
	 * Indicate that processing of the last step has completed and
	 * the next step can be issued.
	 *
	 * @param execCtx
	 */
	private void doneStepping(final IExecutionDMContext execCtx) {
		if (DEBUG)
			System.out.println("[SteppingController] doneStepping ctx=" + execCtx); //$NON-NLS-1$
		enableStepping(execCtx);
		processStepQueue(execCtx);
	}

	/**
	 * Enable stepping for the given execution context.
	 *
	 * @param execCtx
	 */
	public void enableStepping(final IExecutionDMContext execCtx) {
		fStepInProgress.remove(execCtx);
		for (IExecutionDMContext disabledCtx : fStepInProgress.keySet()) {
			if (DMContexts.isAncestorOf(disabledCtx, execCtx)) {
				fStepInProgress.remove(disabledCtx);
			}
		}
	}

	private boolean isSteppingDisabled(IExecutionDMContext execCtx) {
		boolean disabled = fStepInProgress.containsKey(execCtx);
		if (!disabled) {
			for (IExecutionDMContext disabledCtx : fStepInProgress.keySet()) {
				if (DMContexts.isAncestorOf(execCtx, disabledCtx)) {
					disabled = true;
					break;
				}
			}
		}
		if (disabled) {
			long now = System.currentTimeMillis();
			long lastStepTime = getLastStepTime(execCtx);
			if (now - lastStepTime > MAX_STEP_DELAY) {
				if (DEBUG)
					System.out.println("[SteppingController] stepping control participant(s) timed out"); //$NON-NLS-1$
				enableStepping(execCtx);
				disabled = false;
			}
		}
		return disabled;
	}

	protected void handlePropertyChanged(final IPreferenceStore store, final PropertyChangeEvent event) {
		String property = event.getProperty();
		if (IDsfDebugUIConstants.PREF_MIN_STEP_INTERVAL.equals(property)) {
			setMinimumStepInterval(store.getInt(property));
		}
	}

	///////////////////////////////////////////////////////////////////////////

	@DsfServiceEventHandler
	public void eventDispatched(final ISuspendedDMEvent e) {
		// Take care of the stepping time out
		boolean timedOut = false;
		IExecutionDMContext dmc = e.getDMContext();
		for (Iterator<Map.Entry<IExecutionDMContext, Boolean>> itr = fTimedOutFlags.entrySet().iterator(); itr
				.hasNext();) {
			Map.Entry<IExecutionDMContext, Boolean> entry = itr.next();
			IExecutionDMContext nextDmc = entry.getKey();
			if (nextDmc.equals(dmc) || DMContexts.isAncestorOf(nextDmc, dmc)) {
				if (entry.getValue()) {
					// after step timeout do not process queued steps
					fStepQueues.remove(dmc);
					timedOut = true;
				}
				itr.remove();
			}
		}

		// Cancel all time-out futures related to the event context.  I.e.
		// - If event is on a container, all child threads are suspended and
		// should not issue a stepping time-out event.
		// - If event is on a thread, and resumed event was on a container then
		// stepping timeout for container should be canceled as it would affect
		// suspended thread.
		for (Iterator<Map.Entry<IExecutionDMContext, ScheduledFuture<?>>> itr = fTimedOutFutures.entrySet()
				.iterator(); itr.hasNext();) {
			Map.Entry<IExecutionDMContext, ScheduledFuture<?>> entry = itr.next();
			IExecutionDMContext nextDmc = entry.getKey();
			if (nextDmc.equals(dmc) || DMContexts.isAncestorOf(nextDmc, dmc) || DMContexts.isAncestorOf(dmc, nextDmc)) {
				entry.getValue().cancel(false);
				itr.remove();
			}
		}

		if (e.getReason() != StateChangeReason.STEP) {
			// after any non-step suspend reason do not process queued steps for given context
			fStepQueues.remove(dmc);
		} else if (!timedOut) {
			// Check if there's a step pending, if so execute it
			processStepQueue(dmc);
		}
	}

	@DsfServiceEventHandler
	public void eventDispatched(final IResumedDMEvent e) {
		if (e.getReason().equals(StateChangeReason.STEP)) {
			final IExecutionDMContext dmc = e.getDMContext();
			fTimedOutFlags.remove(dmc);

			// Find any time-out futures for contexts that are children of the
			// resumed context, and cancel them as they'll be replaced.
			if (!fTimedOutFutures.containsKey(dmc)) {
				for (Iterator<Map.Entry<IExecutionDMContext, ScheduledFuture<?>>> itr = fTimedOutFutures.entrySet()
						.iterator(); itr.hasNext();) {
					Map.Entry<IExecutionDMContext, ScheduledFuture<?>> entry = itr.next();
					if (DMContexts.isAncestorOf(entry.getKey(), dmc)) {
						entry.getValue().cancel(false);
						itr.remove();
					}
				}

				fTimedOutFutures.put(dmc,
						getExecutor().schedule(new TimeOutRunnable(dmc), fStepTimeout, TimeUnit.MILLISECONDS));
			}
		}
	}
}
