/*******************************************************************************
 * Copyright (c) 2016 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:
 *   Patrick Tasse - Initial API and implementation
 *******************************************************************************/

package org.eclipse.tracecompass.tmf.ui.widgets.timegraph.widgets;

import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;

import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.swt.SWT;
import org.eclipse.swt.events.MouseAdapter;
import org.eclipse.swt.events.MouseEvent;
import org.eclipse.swt.events.PaintEvent;
import org.eclipse.swt.graphics.Color;
import org.eclipse.swt.graphics.GC;
import org.eclipse.swt.graphics.Image;
import org.eclipse.swt.graphics.Point;
import org.eclipse.swt.graphics.Rectangle;
import org.eclipse.swt.widgets.Composite;
import org.eclipse.swt.widgets.Display;
import org.eclipse.tracecompass.internal.tmf.ui.Activator;
import org.eclipse.tracecompass.tmf.ui.widgets.timegraph.model.IMarkerEvent;

import com.google.common.collect.LinkedHashMultimap;
import com.google.common.collect.Multimap;

/**
 * A control that shows marker labels on a time axis.
 *
 * @since 2.0
 */
public class TimeGraphMarkerAxis extends TimeGraphBaseControl {

    private static final Image COLLAPSED = Activator.getDefault().getImageFromPath("icons/ovr16/collapsed_ovr.gif"); //$NON-NLS-1$
    private static final Image EXPANDED = Activator.getDefault().getImageFromPath("icons/ovr16/expanded_ovr.gif"); //$NON-NLS-1$
    private static final Image HIDE = Activator.getDefault().getImageFromPath("icons/etool16/hide.gif"); //$NON-NLS-1$
    private static final int HIDE_BORDER = 4; // transparent border of the hide icon

    private static final int HEIGHT;
    static {
        GC gc = new GC(Display.getDefault());
        HEIGHT = gc.getFontMetrics().getHeight() + 1;
        gc.dispose();
    }

    private static final int TOP_MARGIN = 1;
    private static final int MAX_LABEL_LENGTH = 256;
    private static final int TEXT_MARGIN = 2;
    private static final int MAX_GAP = 5;
    private static final int X_LIMIT = Integer.MAX_VALUE / 256;

    private @NonNull ITimeDataProvider fTimeProvider;
    private final Set<IMarkerAxisListener> fListeners = new LinkedHashSet<>();
    private Multimap<String, IMarkerEvent> fMarkers = LinkedHashMultimap.create();
    private @NonNull List<String> fCategories = Collections.emptyList();
    private boolean fCollapsed = false;

    /**
     * Contructor
     *
     * @param parent
     *            The parent composite object
     * @param colorScheme
     *            The color scheme to use
     * @param timeProvider
     *            The time data provider
     */
    public TimeGraphMarkerAxis(Composite parent, @NonNull TimeGraphColorScheme colorScheme, @NonNull ITimeDataProvider timeProvider) {
        super(parent, colorScheme, SWT.NO_BACKGROUND | SWT.NO_FOCUS | SWT.DOUBLE_BUFFERED);
        fTimeProvider = timeProvider;
        addMouseListener(new MouseAdapter() {
            @Override
            public void mouseDown(MouseEvent e) {
                Point size = getSize();
                Rectangle bounds = new Rectangle(0, 0, size.x, size.y);
                TimeGraphMarkerAxis.this.mouseDown(e, bounds, fTimeProvider.getNameSpace());
            }
        });
    }

    @Override
    public Point computeSize(int wHint, int hHint, boolean changed) {
        int height = 0;
        if (!fMarkers.isEmpty() && fTimeProvider.getTime0() != fTimeProvider.getTime1()) {
            if (fCollapsed) {
                height = COLLAPSED.getBounds().height;
            } else {
                height = TOP_MARGIN + fMarkers.keySet().size() * HEIGHT;
            }
        }
        return super.computeSize(wHint, height, changed);
    }

    /**
     * Add a marker axis listener.
     *
     * @param listener
     *            the listener
     */
    public void addMarkerAxisListener(IMarkerAxisListener listener) {
        fListeners.add(listener);
    }

    /**
     * Remove a marker axis listener.
     *
     * @param listener
     *            the listener
     */
    public void removeMarkerAxisListener(IMarkerAxisListener listener) {
        fListeners.remove(listener);
    }

    /**
     * Set the time provider
     *
     * @param timeProvider
     *            The provider to use
     */
    public void setTimeProvider(@NonNull ITimeDataProvider timeProvider) {
        fTimeProvider = timeProvider;
    }

    /**
     * Set the list of marker categories.
     *
     * @param categories
     *            The list of marker categories, or null
     */
    public void setMarkerCategories(List<String> categories) {
        if (categories == null) {
            fCategories = Collections.emptyList();
        } else {
            fCategories = categories;
        }
    }

    /**
     * Handle a mouseDown event.
     *
     * @param e
     *            the mouse event
     * @param bounds
     *            the bounds of the marker axis in the mouse event's coordinates
     * @param nameSpace
     *            the width of the marker name area
     */
    public void mouseDown(MouseEvent e, Rectangle bounds, int nameSpace) {
        if (bounds.isEmpty()) {
            return;
        }
        if (fCollapsed || (e.x < bounds.x + Math.min(nameSpace, EXPANDED.getBounds().width))) {
            fCollapsed = !fCollapsed;
            getParent().layout();
            redraw();
            return;
        }
        if (e.x < bounds.x + nameSpace) {
            String category = getHiddenCategoryForEvent(e, bounds);
            if (category != null) {
                for (IMarkerAxisListener listener : fListeners) {
                    listener.setMarkerCategoryVisible(category, false);
                }
            }
            return;
        }
        IMarkerEvent marker = getMarkerForEvent(e);
        if (marker != null) {
            if ((e.stateMask & SWT.MODIFIER_MASK) == SWT.SHIFT) {
                /* Extend current selection */
                long selectionBegin = fTimeProvider.getSelectionBegin();
                long selectionEnd = fTimeProvider.getSelectionEnd();
                long markerBegin = marker.getTime();
                long markerEnd = marker.getTime() + marker.getDuration();
                /* If marker end is outside selection, extend closest boundary */
                if (((markerEnd - selectionBegin) > 0) == ((markerEnd - selectionEnd) > 0)) {
                    if (Math.abs(markerEnd - selectionBegin) < Math.abs(markerEnd - selectionEnd)) {
                        selectionBegin = markerEnd;
                    } else {
                        selectionEnd = markerEnd;
                    }
                }
                /* If marker begin is outside selection, extend closest boundary */
                if (((markerBegin - selectionBegin) > 0) == ((markerBegin - selectionEnd) > 0)) {
                    if (Math.abs(markerBegin - selectionBegin) < Math.abs(markerBegin - selectionEnd)) {
                        selectionBegin = markerBegin;
                    } else {
                        selectionEnd = markerBegin;
                    }
                }
                fTimeProvider.setSelectionRangeNotify(selectionBegin, selectionEnd, false);
            } else {
                /* Replace current selection */
                fTimeProvider.setSelectionRangeNotify(marker.getTime(), marker.getTime() + marker.getDuration(), false);
            }
        }
    }

    /**
     * Set the markers list.
     *
     * @param markers
     *            The markers list
     */
    public void setMarkers(List<IMarkerEvent> markers) {
        Multimap<String, IMarkerEvent> map = LinkedHashMultimap.create();
        for (IMarkerEvent marker : markers) {
            if (marker.getEntry() == null) {
                map.put(marker.getCategory(), marker);
            }
        }
        Display.getDefault().asyncExec(() -> {
            if (isDisposed()) {
                return;
            }
            fMarkers = map;
            getParent().layout();
            redraw();
        });
    }

    @Override
    void paint(Rectangle bounds, PaintEvent e) {
        drawMarkerAxis(bounds, fTimeProvider.getNameSpace(), e.gc);
    }

    /**
     * Draw the marker axis
     *
     * @param bounds
     *            the bounds of the marker axis
     * @param nameSpace
     *            the width of the marker name area
     * @param gc
     *            the GC instance
     */
    protected void drawMarkerAxis(Rectangle bounds, int nameSpace, GC gc) {
        if (bounds.isEmpty()) {
            return;
        }
        // draw background
        gc.fillRectangle(bounds);

        if (!fCollapsed) {
            Rectangle rect = new Rectangle(bounds.x, bounds.y + TOP_MARGIN, bounds.width, HEIGHT);
            for (String category : getVisibleCategories()) {
                rect.x = bounds.x;
                rect.width = nameSpace;
                drawMarkerCategory(category, rect, gc);
                rect.x = nameSpace;
                rect.width = bounds.width - nameSpace;
                drawMarkerLabels(category, rect, gc);
                rect.y += HEIGHT;
            }
        }

        Rectangle rect = new Rectangle(bounds.x, bounds.y, nameSpace, bounds.height);
        gc.setClipping(rect);
        drawToolbar(rect, nameSpace, gc);
    }

    /**
     * Draw the marker category
     *
     * @param category
     *            the category
     * @param rect
     *            the bounds of the marker name area
     * @param gc
     *            the GC instance
     */
    protected void drawMarkerCategory(String category, Rectangle rect, GC gc) {
        if (rect.isEmpty()) {
            return;
        }
        // draw marker category
        gc.setForeground(getColorScheme().getColor(TimeGraphColorScheme.TOOL_FOREGROUND));
        gc.setClipping(rect);
        int width = gc.textExtent(category).x + TEXT_MARGIN;
        int x = rect.x + EXPANDED.getBounds().width + HIDE.getBounds().width;
        gc.drawText(category, Math.max(x, rect.x + rect.width - width), rect.y, true);
    }

    /**
     * Draw the marker labels for the specified category
     *
     * @param category
     *            the category
     * @param rect
     *            the bounds of the marker time area
     * @param gc
     *            the GC instance
     */
    protected void drawMarkerLabels(String category, Rectangle rect, GC gc) {
        if (rect.isEmpty()) {
            return;
        }
        long time0 = fTimeProvider.getTime0();
        long time1 = fTimeProvider.getTime1();
        if (time0 == time1) {
            return;
        }
        int timeSpace = fTimeProvider.getTimeSpace();
        double pixelsPerNanoSec = (timeSpace <= RIGHT_MARGIN) ? 0 :
            (double) (timeSpace - RIGHT_MARGIN) / (time1 - time0);

        gc.setClipping(rect);
        for (IMarkerEvent markerEvent : fMarkers.get(category)) {
            Color color = getColorScheme().getColor(markerEvent.getColor());
            gc.setForeground(color);
            gc.setBackground(color);
            int x1 = getXForTime(rect, time0, pixelsPerNanoSec, markerEvent.getTime());
            if (x1 > rect.x + rect.width) {
                return;
            }
            if (markerEvent.getEntry() != null) {
                continue;
            }
            int x2 = getXForTime(rect, time0, pixelsPerNanoSec, markerEvent.getTime() + markerEvent.getDuration()) - 1;
            String label = getTrimmedLabel(markerEvent);
            if (label != null) {
                int width = gc.textExtent(label).x + TEXT_MARGIN;
                if (x1 < rect.x && x1 + width < x2) {
                    int gap = Math.min(rect.x - x1, MAX_GAP);
                    x1 = Math.min(rect.x + gap, x2 - width);
                    if (x1 > rect.x) {
                        int y = rect.y + rect.height / 2;
                        gc.drawLine(rect.x, y, x1, y);
                    }
                }
                gc.fillRectangle(x1, rect.y, width, rect.height - 1);
                gc.drawRectangle(x1, rect.y, width, rect.height - 1);
                if (x2 > x1 + width) {
                    int y = rect.y + rect.height / 2;
                    gc.drawLine(x1 + width, y, x2, y);
                }
                gc.setForeground(Utils.getDistinctColor(color.getRGB()));
                Utils.drawText(gc, label, x1 + TEXT_MARGIN, rect.y, true);
            } else {
                int y = rect.y + rect.height / 2;
                gc.drawLine(x1, y, x2, y);
            }
        }
    }

    /**
     * Draw the toolbar
     *
     * @param bounds
     *            the bounds of the marker axis
     * @param nameSpace
     *            the width of the marker name area
     * @param gc
     *            the GC instance
     */
    protected void drawToolbar(Rectangle bounds, int nameSpace, GC gc) {
        if (bounds.isEmpty()) {
            return;
        }
        if (fCollapsed) {
            gc.drawImage(COLLAPSED, bounds.x, bounds.y);
        } else {
            gc.drawImage(EXPANDED, bounds.x, bounds.y);
            int x = bounds.x + EXPANDED.getBounds().width;
            for (int i = 0; i < fMarkers.keySet().size(); i++) {
                int y = bounds.y + TOP_MARGIN + i * HEIGHT;
                gc.drawImage(HIDE, x, y);
            }
        }
    }

    private static String getTrimmedLabel(IMarkerEvent marker) {
        String label = marker.getLabel();
        if (label == null) {
            return null;
        }
        return label.substring(0, Math.min(label.indexOf(SWT.LF) != -1 ? label.indexOf(SWT.LF) : label.length(), MAX_LABEL_LENGTH));
    }

    private static int getXForTime(Rectangle rect, long time0, double pixelsPerNanoSec, long time) {
        int x = rect.x + (int) (Math.floor((time - time0) * pixelsPerNanoSec));
        return Math.min(Math.max(x, -X_LIMIT), X_LIMIT);
    }

    private IMarkerEvent getMarkerForEvent(MouseEvent event) {
        long time0 = fTimeProvider.getTime0();
        long time1 = fTimeProvider.getTime1();
        if (time0 == time1) {
            return null;
        }
        int timeSpace = fTimeProvider.getTimeSpace();
        double pixelsPerNanoSec = (timeSpace <= RIGHT_MARGIN) ? 0 :
            (double) (timeSpace - RIGHT_MARGIN) / (time1 - time0);

        int categoryIndex = Math.max((event.y - TOP_MARGIN) / HEIGHT, 0);
        if (categoryIndex >= getVisibleCategories().size()) {
            return null;
        }

        String category = getVisibleCategories().get(categoryIndex);

        IMarkerEvent marker = null;
        GC gc = new GC(Display.getDefault());
        Rectangle rect = getBounds();
        rect.x += fTimeProvider.getNameSpace();
        rect.width -= fTimeProvider.getNameSpace();

        for (IMarkerEvent markerEvent : fMarkers.get(category)) {
            String label = getTrimmedLabel(markerEvent);
            if (markerEvent.getEntry() == null) {
                int x1 = getXForTime(rect, time0, pixelsPerNanoSec, markerEvent.getTime());
                if (x1 <= event.x) {
                    if (label != null) {
                        int width = gc.textExtent(label).x + TEXT_MARGIN;
                        if (event.x <= x1 + width) {
                            marker = markerEvent;
                            continue;
                        }
                    }
                    int x2 = getXForTime(rect, time0, pixelsPerNanoSec, markerEvent.getTime() + markerEvent.getDuration()) - 1;
                    if (event.x <= x2) {
                        marker = markerEvent;
                    }
                } else {
                    break;
                }
            }
        }
        gc.dispose();
        return marker;
    }

    private String getHiddenCategoryForEvent(MouseEvent e, Rectangle bounds) {
        List<String> categories = getVisibleCategories();
        Rectangle rect = HIDE.getBounds();
        rect.x += bounds.x + EXPANDED.getBounds().width + HIDE_BORDER;
        rect.y += bounds.y + TOP_MARGIN + HIDE_BORDER;
        rect.width -= 2 * HIDE_BORDER;
        rect.height -= 2 * HIDE_BORDER;
        for (int i = 0; i < categories.size(); i++) {
            if (rect.contains(e.x, e.y)) {
                return categories.get(i);
            }
            rect.y += HEIGHT;
        }
        return null;
    }

    private List<String> getVisibleCategories() {
        List<String> categories = new ArrayList<>(fCategories);
        categories.retainAll(fMarkers.keySet());
        return categories;
    }
}
