/*******************************************************************************
 * Copyright (c) 2015, 2019 Ericsson
 *
 * All rights reserved. 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:
 *   Marc-Andre Laperle - Initial API and implementation
 *******************************************************************************/

package org.eclipse.tracecompass.internal.tmf.ui.views;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Timer;
import java.util.TimerTask;

import org.eclipse.core.runtime.preferences.IEclipsePreferences.IPreferenceChangeListener;
import org.eclipse.core.runtime.preferences.InstanceScope;
import org.eclipse.swt.graphics.Point;
import org.eclipse.swt.widgets.Composite;
import org.eclipse.swt.widgets.Display;
import org.eclipse.swt.widgets.Shell;
import org.eclipse.tracecompass.internal.tmf.ui.Activator;
import org.eclipse.tracecompass.internal.tmf.ui.ITmfUIPreferences;
import org.eclipse.tracecompass.tmf.core.signal.TmfSignalHandler;
import org.eclipse.tracecompass.tmf.core.signal.TmfSignalManager;
import org.eclipse.tracecompass.tmf.ui.signal.TmfTimeViewAlignmentInfo;
import org.eclipse.tracecompass.tmf.ui.signal.TmfTimeViewAlignmentSignal;
import org.eclipse.tracecompass.tmf.ui.views.ITmfTimeAligned;
import org.eclipse.tracecompass.tmf.ui.views.TmfView;
import org.eclipse.ui.IViewPart;
import org.eclipse.ui.IViewReference;
import org.eclipse.ui.IWorkbenchPage;
import org.eclipse.ui.IWorkbenchWindow;
import org.eclipse.ui.PlatformUI;

import com.google.common.annotations.VisibleForTesting;

/**
 * Receives various notifications for realignment and
 * performs the alignment on the appropriate views.
 *
 * @since 1.0
 */
public class TmfAlignmentSynchronizer {

    private static final long THROTTLE_DELAY = 500;
    private static final int NEAR_THRESHOLD = 10;

    /** Singleton instance */
    private static TmfAlignmentSynchronizer fInstance = null;

    private final Timer fTimer;
    private final List<AlignmentOperation> fPendingOperations = Collections.synchronizedList(new ArrayList<AlignmentOperation>());

    private TimerTask fCurrentTask;

    /**
     * Constructor
     */
    private TmfAlignmentSynchronizer() {
        TmfSignalManager.register(this);
        fTimer = new Timer();
        createPreferenceListener();
        fCurrentTask = new TimerTask() {
            @Override
            public void run() {
                /* Do nothing */
            }
        };
    }

    /**
     * Get the alignment synchronizer's instance
     *
     * @return The singleton instance
     */
    public static synchronized TmfAlignmentSynchronizer getInstance() {
        if (fInstance == null) {
            fInstance = new TmfAlignmentSynchronizer();
        }
        return fInstance;
    }

    /**
     * Disposes the alignment synchronizer
     */
    public void dispose() {
        TmfSignalManager.deregister(this);
        synchronized (fPendingOperations) {
            fTimer.cancel();
            fCurrentTask.cancel();
        }
    }

    private IPreferenceChangeListener createPreferenceListener() {
        IPreferenceChangeListener listener = event -> {
            if (event.getKey().equals(ITmfUIPreferences.PREF_ALIGN_VIEWS)) {
                Object oldValue = event.getOldValue();
                Object newValue = event.getNewValue();
                if (Boolean.toString(false).equals(oldValue) && Boolean.toString(true).equals(newValue)) {
                    realignViews();
                } else if (Boolean.toString(true).equals(oldValue) && Boolean.toString(false).equals(newValue)) {
                    restoreViews();
                }
            }
        };
        InstanceScope.INSTANCE.getNode(Activator.PLUGIN_ID).addPreferenceChangeListener(listener);
        return listener;
    }

    private class AlignmentOperation {
        final TmfView fView;
        final TmfTimeViewAlignmentInfo fAlignmentInfo;

        public AlignmentOperation(TmfView view, TmfTimeViewAlignmentInfo timeViewAlignmentInfo) {
            fView = view;
            fAlignmentInfo = timeViewAlignmentInfo;
        }
    }

    private class AlignTask extends TimerTask {
        private boolean fComplete = false;

        @Override
        public void run() {
            final List<AlignmentOperation> fCopy;
            synchronized (fPendingOperations) {
                fCopy = new ArrayList<>(fPendingOperations);
                fPendingOperations.clear();
            }
            Display.getDefault().syncExec(() -> performAllAlignments(fCopy));
            fComplete = true;
        }
    }

    /**
     * Handle a view that was just resized.
     *
     * @param view
     *            the view that was resized
     */
    public void handleViewResized(TmfView view) {
        if (view.getParentComposite().isDisposed()) {
            return;
        }

        TmfTimeViewAlignmentInfo alignmentInfo = new TmfTimeViewAlignmentInfo(view.getParentComposite().getShell(), getViewLocation(view), 0);

        // Don't use a view that was just resized as a reference view.
        // Otherwise, a view that was just
        // created might use itself as a reference but we want to
        // keep the existing alignment from the other views.
        ITmfTimeAligned referenceView = getReferenceView(alignmentInfo, view);
        if (referenceView != null) {
            queueAlignment(referenceView.getTimeViewAlignmentInfo(), false);
        }
    }

    /**
     * Handle a view that was just closed.
     *
     * @param view
     *            the view that was closed
     */
    public void handleViewClosed(TmfView view) {
        // Realign views so that they can use the maximum available width in the
        // event that a narrow view was just closed
        realignViews(view.getSite().getPage());
    }

    /**
     * Process signal for alignment.
     *
     * @param signal the alignment signal
     */
    @TmfSignalHandler
    public void timeViewAlignmentUpdated(TmfTimeViewAlignmentSignal signal) {
        queueAlignment(signal.getTimeViewAlignmentInfo(), signal.IsSynchronous());
    }

    /**
     * Returns true if an alignment operation is pending or running, false
     * otherwise.
     *
     * @return true if an alignment operation is pending or running, false
     *         otherwise
     */
    @VisibleForTesting
    public boolean isBusy() {
        synchronized (fPendingOperations) {
            TimerTask currentTask = fCurrentTask;
            return !fPendingOperations.isEmpty() || (currentTask instanceof AlignTask &&
                    !((AlignTask) currentTask).fComplete);
        }
    }

    /**
     * Perform all alignment operations for the specified alignment
     * informations.
     *
     * <pre>
     * - The alignment algorithm chooses the narrowest width to accommodate all views.
     * - View positions are recomputed for extra accuracy since the views could have been moved or resized.
     * - Based on the up-to-date view positions, only views that are near and aligned with each other
     * </pre>
     */
    private static void performAllAlignments(final List<AlignmentOperation> alignments) {
        for (final AlignmentOperation info : alignments) {
            performAlignment(info);
        }
    }

    private static void performAlignment(AlignmentOperation info) {

        TmfView referenceView = info.fView;
        if (isDisposedView(referenceView)) {
            return;
        }

        TmfTimeViewAlignmentInfo alignmentInfo = info.fAlignmentInfo;
        // The location of the view might have changed (resize, etc). Update the alignment info.
        alignmentInfo = new TmfTimeViewAlignmentInfo(alignmentInfo.getShell(), getViewLocation(referenceView), getClampedTimeAxisOffset(alignmentInfo));

        TmfView narrowestView = getNarrowestView(alignmentInfo);
        if (narrowestView == null) {
            // No valid view found for this alignment. This could mean that the views for this alignment are now too narrow (width == 0) or that shell is not a workbench window.
            return;
        }

        int narrowestWidth = ((ITmfTimeAligned) narrowestView).getAvailableWidth(getClampedTimeAxisOffset(alignmentInfo));
        narrowestWidth = getClampedTimeAxisWidth(alignmentInfo, narrowestWidth);
        IViewReference[] viewReferences = referenceView.getSite().getPage().getViewReferences();
        for (IViewReference ref : viewReferences) {
            IViewPart view = ref.getView(false);
            if (isTimeAlignedView(view)) {
                TmfView tmfView = (TmfView) view;
                ITmfTimeAligned alignedView = (ITmfTimeAligned) view;
                if (!isDisposedView(tmfView) && isViewLocationNear(getViewLocation(tmfView), alignmentInfo.getViewLocation())) {
                    alignedView.performAlign(getClampedTimeAxisOffset(alignmentInfo), narrowestWidth);
                }
            }
        }
    }

    /**
     * Realign all views
     */
    private void realignViews() {
        for (IWorkbenchWindow window : PlatformUI.getWorkbench().getWorkbenchWindows()) {
            for (IWorkbenchPage page : window.getPages()) {
                realignViews(page);
            }
        }
    }

    /**
     * Realign views inside a given page
     *
     * @param page
     *            the workbench page
     */
    private void realignViews(IWorkbenchPage page) {
        IViewReference[] viewReferences = page.getViewReferences();
        for (IViewReference ref : viewReferences) {
            IViewPart view = ref.getView(false);
            if (isTimeAlignedView(view)) {
                queueAlignment(((ITmfTimeAligned) view).getTimeViewAlignmentInfo(), false);
            }
        }
    }

    /**
     * Restore the views to their respective maximum widths
     */
    private static void restoreViews() {
        // We set the width to Integer.MAX_VALUE so that the
        // views remove any "filler" space they might have.
        for (IWorkbenchWindow window : PlatformUI.getWorkbench().getWorkbenchWindows()) {
            for (IWorkbenchPage page : window.getPages()) {
                for (IViewReference ref : page.getViewReferences()) {
                    restoreView(ref);
                }
            }
        }
    }

    private static void restoreView(IViewReference ref) {
        IViewPart view = ref.getView(false);
        if (isTimeAlignedView(view)) {
            ITmfTimeAligned alignedView = (ITmfTimeAligned) view;
            alignedView.performAlign(getClampedTimeAxisOffset(alignedView.getTimeViewAlignmentInfo()), Integer.MAX_VALUE);
        }
    }

    private static boolean isTimeAlignedView(IViewPart view) {
        if (view instanceof TmfView && view instanceof ITmfTimeAligned) {
            Composite parentComposite = ((TmfView) view).getParentComposite();
            if (parentComposite != null && !parentComposite.isDisposed()) {
                return true;
            }
        }
        return view instanceof TmfView && view instanceof ITmfTimeAligned;
    }

    private static boolean isDisposedView(TmfView view) {
        Composite parentComposite = (view).getParentComposite();
        return parentComposite != null && parentComposite.isDisposed();
    }

    /**
     * Queue the operation for processing. If an operation is considered the
     * same alignment (shell, location) as a previously queued one, it will
     * replace the old one. This way, only one up-to-date alignment operation is
     * kept per set of time-axis aligned views. The processing of the operation
     * is also throttled (TimerTask).
     *
     * @param operation
     *            the operation to queue
     */
    private void queue(AlignmentOperation operation) {
        synchronized(fPendingOperations) {
            fCurrentTask.cancel();
            for (AlignmentOperation pendingOperation : fPendingOperations) {
                if (!isDisposedView(pendingOperation.fView) && isSameAlignment(operation, pendingOperation)) {
                    fPendingOperations.remove(pendingOperation);
                    break;
                }
            }
            fPendingOperations.add(operation);
            fCurrentTask = new AlignTask();
            fTimer.schedule(fCurrentTask, THROTTLE_DELAY);
        }
    }

    /**
     * Two operations are considered to be for the same set of time-axis aligned
     * views if they are on the same Shell and near the same location.
     */
    private static boolean isSameAlignment(AlignmentOperation operation1, AlignmentOperation operation2) {
        if (operation1.fView == operation2.fView) {
            return true;
        }

        if (operation1.fAlignmentInfo.getShell() != operation2.fAlignmentInfo.getShell()) {
            return false;
        }
        return (isViewLocationNear(getViewLocation(operation1.fView), getViewLocation(operation2.fView)));
    }

    private static boolean isViewLocationNear(Point location1, Point location2) {
        return Math.abs(location1.x - location2.x) < NEAR_THRESHOLD;
    }

    private static Point getViewLocation(TmfView view) {
        return view.getParentComposite().toDisplay(0, 0);
    }

    private void queueAlignment(TmfTimeViewAlignmentInfo timeViewAlignmentInfo, boolean synchronous) {
        if (isAlignViewsPreferenceEnabled()) {
            IWorkbenchWindow workbenchWindow = getWorkbenchWindow(timeViewAlignmentInfo.getShell());
            if (workbenchWindow == null || workbenchWindow.getActivePage() == null) {
                // Only time aligned views that are part of a workbench window are supported
                return;
            }

            // We need a view so that we can compute position right as we are
            // about to realign the views. The view could have been resized,
            // moved, etc.
            TmfView view = (TmfView) getReferenceView(timeViewAlignmentInfo, null);
            if (view == null) {
                // No valid view found for this alignment
                return;
            }

            AlignmentOperation operation = new AlignmentOperation(view, timeViewAlignmentInfo);
            if (synchronous) {
                performAlignment(operation);
            } else {
                queue(operation);
            }
        }
    }

    private static boolean isAlignViewsPreferenceEnabled() {
        return InstanceScope.INSTANCE.getNode(Activator.PLUGIN_ID).getBoolean(ITmfUIPreferences.PREF_ALIGN_VIEWS, true);
    }

    /**
     * Get a view that corresponds to the alignment information. The view is
     * meant to be used as a "reference" for other views to align on. Heuristics
     * are applied to choose the best view. For example, the view has to be
     * visible. It also will prioritize the view with lowest time axis offset
     * because most of the interesting data should be in the time widget.
     *
     * @param alignmentInfo
     *            alignment information
     * @param blackListedView
     *            an optional black listed view that will not be used as
     *            reference (useful for a view that just got created)
     * @return the reference view
     */
    private static ITmfTimeAligned getReferenceView(TmfTimeViewAlignmentInfo alignmentInfo, TmfView blackListedView) {
        IWorkbenchWindow workbenchWindow = getWorkbenchWindow(alignmentInfo.getShell());
        if (workbenchWindow == null || workbenchWindow.getActivePage() == null) {
            // Only time aligned views that are part of a workbench window are supported
            return null;
        }
        IWorkbenchPage page = workbenchWindow.getActivePage();

        int lowestTimeAxisOffset = Integer.MAX_VALUE;
        ITmfTimeAligned referenceView = null;
        for (IViewReference ref : page.getViewReferences()) {
            IViewPart view = ref.getView(false);
            if (view != blackListedView && isTimeAlignedView(view)) {
                if (isCandidateForReferenceView((TmfView) view, alignmentInfo, lowestTimeAxisOffset)) {
                    referenceView = (ITmfTimeAligned) view;
                    lowestTimeAxisOffset = getClampedTimeAxisOffset(referenceView.getTimeViewAlignmentInfo());
                }
            }
        }
        return referenceView;
    }

    private static boolean isCandidateForReferenceView(TmfView tmfView, TmfTimeViewAlignmentInfo alignmentInfo, int lowestTimeAxisOffset) {
        ITmfTimeAligned alignedView = (ITmfTimeAligned) tmfView;
        TmfTimeViewAlignmentInfo timeViewAlignmentInfo = alignedView.getTimeViewAlignmentInfo();
        if (timeViewAlignmentInfo == null) {
            return false;
        }

        if (isDisposedView(tmfView)) {
            return false;
        }

        Composite parentComposite = tmfView.getParentComposite();
        boolean isVisible = parentComposite != null && parentComposite.isVisible();
        if (isVisible) {
            boolean isViewLocationNear = isViewLocationNear(alignmentInfo.getViewLocation(), getViewLocation(tmfView));
            boolean isLowestTimeAxisOffset = getClampedTimeAxisOffset(timeViewAlignmentInfo) < lowestTimeAxisOffset;
            if (isViewLocationNear && isLowestTimeAxisOffset) {
                int availableWidth = alignedView.getAvailableWidth(getClampedTimeAxisOffset(timeViewAlignmentInfo));
                availableWidth = getClampedTimeAxisWidth(timeViewAlignmentInfo, availableWidth);
                if (availableWidth > 0) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * Get the narrowest view that corresponds to the given alignment information.
     */
    private static TmfView getNarrowestView(TmfTimeViewAlignmentInfo alignmentInfo) {
        IWorkbenchWindow workbenchWindow = getWorkbenchWindow(alignmentInfo.getShell());
        if (workbenchWindow == null || workbenchWindow.getActivePage() == null) {
            // Only time aligned views that are part of a workbench window are supported
            return null;
        }
        IWorkbenchPage page = workbenchWindow.getActivePage();

        int narrowestWidth = Integer.MAX_VALUE;
        TmfView narrowestView = null;
        for (IViewReference ref : page.getViewReferences()) {
            IViewPart view = ref.getView(false);
            if (isTimeAlignedView(view)) {
                TmfView tmfView = (TmfView) view;
                if (isCandidateForNarrowestView(tmfView, alignmentInfo, narrowestWidth)) {
                    narrowestWidth = ((ITmfTimeAligned) tmfView).getAvailableWidth(getClampedTimeAxisOffset(alignmentInfo));
                    narrowestWidth = getClampedTimeAxisWidth(alignmentInfo, narrowestWidth);
                    narrowestView = tmfView;
                }
            }
        }

        return narrowestView;
    }

    private static int getClampedTimeAxisWidth(TmfTimeViewAlignmentInfo alignmentInfo, int width) {
        int max = getMaxInt(alignmentInfo.getShell());
        if (validateInt(width, max)) {
            Activator.getDefault().logError("Time-axis width out of range (" + width + ")", new Throwable());  //$NON-NLS-1$//$NON-NLS-2$
        }
        return Math.min(max, Math.max(0, width));
    }

    private static int getClampedTimeAxisOffset(TmfTimeViewAlignmentInfo alignmentInfo) {
        int timeAxisOffset = alignmentInfo.getTimeAxisOffset();
        int max = getMaxInt(alignmentInfo.getShell());
        if (validateInt(timeAxisOffset, max)) {
            Activator.getDefault().logError("Time-axis offset out of range (" + timeAxisOffset + ")", new Throwable());  //$NON-NLS-1$//$NON-NLS-2$
        }
        return Math.min(max, Math.max(0, timeAxisOffset));
    }

    private static boolean validateInt(int value, int max) {
        return value < 0 || value > max;
    }

    private static int getMaxInt(Shell shell) {
        // Consider an integer to be buggy if it's bigger than 10 times the
        // width of *all* monitors combined.
        final int DISPLAY_WIDTH_FACTOR = 10;
        return shell.getDisplay().getBounds().width * DISPLAY_WIDTH_FACTOR;
    }

    private static boolean isCandidateForNarrowestView(TmfView tmfView, TmfTimeViewAlignmentInfo alignmentInfo, int narrowestWidth) {
        ITmfTimeAligned alignedView = (ITmfTimeAligned) tmfView;
        TmfTimeViewAlignmentInfo timeViewAlignmentInfo = alignedView.getTimeViewAlignmentInfo();
        if (timeViewAlignmentInfo == null) {
            return false;
        }

        if (isDisposedView(tmfView)) {
            return false;
        }

        Composite parentComposite = tmfView.getParentComposite();
        boolean isVisible = parentComposite != null && parentComposite.isVisible();
        if (isVisible) {
            if (isViewLocationNear(getViewLocation(tmfView), alignmentInfo.getViewLocation())) {
                int availableWidth = alignedView.getAvailableWidth(getClampedTimeAxisOffset(alignmentInfo));
                availableWidth = getClampedTimeAxisWidth(alignmentInfo, availableWidth);
                boolean isNarrower = availableWidth < narrowestWidth && availableWidth > 0;
                return isNarrower;
            }
        }

        return false;
    }

    private static IWorkbenchWindow getWorkbenchWindow(Shell shell) {
        for (IWorkbenchWindow window : PlatformUI.getWorkbench().getWorkbenchWindows()) {
            if (window.getShell() != null && window.getShell().equals(shell)) {
                return window;
            }
        }

        return null;
    }
}
