/*
 * Copyright 2000-2016 Vaadin Ltd.
 *
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
 * use this file except in compliance with the License. You may obtain a copy of
 * the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations under
 * the License.
 */
package com.vaadin.client.widget.grid.selection;

import java.util.Collection;
import java.util.HashSet;

import com.google.gwt.animation.client.AnimationScheduler;
import com.google.gwt.animation.client.AnimationScheduler.AnimationCallback;
import com.google.gwt.animation.client.AnimationScheduler.AnimationHandle;
import com.google.gwt.core.client.GWT;
import com.google.gwt.dom.client.BrowserEvents;
import com.google.gwt.dom.client.Element;
import com.google.gwt.dom.client.NativeEvent;
import com.google.gwt.dom.client.TableElement;
import com.google.gwt.dom.client.TableRowElement;
import com.google.gwt.dom.client.TableSectionElement;
import com.google.gwt.event.dom.client.ClickEvent;
import com.google.gwt.event.dom.client.ClickHandler;
import com.google.gwt.event.dom.client.MouseDownEvent;
import com.google.gwt.event.dom.client.MouseDownHandler;
import com.google.gwt.event.dom.client.TouchStartEvent;
import com.google.gwt.event.dom.client.TouchStartHandler;
import com.google.gwt.event.shared.HandlerRegistration;
import com.google.gwt.user.client.Event;
import com.google.gwt.user.client.Event.NativePreviewEvent;
import com.google.gwt.user.client.Event.NativePreviewHandler;
import com.google.gwt.user.client.ui.CheckBox;
import com.vaadin.client.WidgetUtil;
import com.vaadin.client.renderers.ClickableRenderer;
import com.vaadin.client.widget.grid.CellReference;
import com.vaadin.client.widget.grid.RendererCellReference;
import com.vaadin.client.widget.grid.events.GridEnabledHandler;
import com.vaadin.client.widget.grid.events.GridSelectionAllowedEvent;
import com.vaadin.client.widget.grid.events.GridSelectionAllowedHandler;
import com.vaadin.client.widgets.Escalator.AbstractRowContainer;
import com.vaadin.client.widgets.Grid;

/**
 * Renderer showing multi selection check boxes.
 *
 * @author Vaadin Ltd
 * @param <T>
 *            the type of the associated grid
 * @since 7.4
 */
public class MultiSelectionRenderer<T>
        extends ClickableRenderer<Boolean, CheckBox> {

    private static final String SELECTION_CHECKBOX_CLASSNAME = "-selection-checkbox";

    /** The size of the autoscroll area, both top and bottom. */
    private static final int SCROLL_AREA_GRADIENT_PX = 100;

    /** The maximum number of pixels per second to autoscroll. */
    private static final int SCROLL_TOP_SPEED_PX_SEC = 500;

    /**
     * The minimum area where the grid doesn't scroll while the pointer is
     * pressed.
     */
    private static final int MIN_NO_AUTOSCROLL_AREA_PX = 50;

    /**
     * Handler for MouseDown and TouchStart events for selection checkboxes.
     *
     * @since 7.5
     */
    private final class CheckBoxEventHandler
            implements MouseDownHandler, TouchStartHandler, ClickHandler,
            GridEnabledHandler, GridSelectionAllowedHandler {
        private final CheckBox checkBox;

        /**
         * @param checkBox
         *            checkbox widget for this handler
         */
        private CheckBoxEventHandler(CheckBox checkBox) {
            this.checkBox = checkBox;
        }

        @Override
        public void onMouseDown(MouseDownEvent event) {
            if (checkBox.isEnabled()) {
                if (event.getNativeButton() == NativeEvent.BUTTON_LEFT) {
                    startDragSelect(event.getNativeEvent(),
                            checkBox.getElement());
                }
            }
        }

        @Override
        public void onTouchStart(TouchStartEvent event) {
            if (checkBox.isEnabled()) {
                startDragSelect(event.getNativeEvent(), checkBox.getElement());
            }
        }

        @Override
        public void onClick(ClickEvent event) {
            // Clicking is already handled with MultiSelectionRenderer
            event.preventDefault();
            event.stopPropagation();
        }

        @Override
        public void onEnabled(boolean enabled) {
            updateEnable();
        }

        @Override
        public void onSelectionAllowed(GridSelectionAllowedEvent event) {
            updateEnable();
        }

        private void updateEnable() {
            checkBox.setEnabled(grid.isEnabled()
                    && grid.getSelectionModel().isSelectionAllowed());
        }
    }

    /**
     * This class's main objective is to listen when to stop autoscrolling, and
     * make sure everything stops accordingly.
     */
    private class TouchEventHandler implements NativePreviewHandler {
        @Override
        public void onPreviewNativeEvent(final NativePreviewEvent event) {
            switch (event.getTypeInt()) {
            case Event.ONTOUCHSTART: {
                if (event.getNativeEvent().getTouches().length() == 1) {
                    /*
                     * Something has dropped a touchend/touchcancel and the
                     * scroller is most probably running amok. Let's cancel it
                     * and pretend that everything's going as expected
                     *
                     * Because this is a preview, this code is run before the
                     * event handler in MultiSelectionRenderer.onBrowserEvent.
                     * Therefore, we can simply kill everything and let that
                     * method restart things as they should.
                     */
                    autoScrollHandler.stop();

                    /*
                     * Related TODO: investigate why iOS seems to ignore a
                     * touchend/touchcancel when frames are dropped, and/or if
                     * something can be done about that.
                     */
                }
                break;
            }

            case Event.ONTOUCHMOVE:
                event.cancel();
                break;

            case Event.ONTOUCHEND:
            case Event.ONTOUCHCANCEL:
                /*
                 * Remember: targetElement is always where touchstart started,
                 * not where the finger is pointing currently.
                 */
                final Element targetElement = Element
                        .as(event.getNativeEvent().getEventTarget());
                if (isInFirstColumn(targetElement)) {
                    removeNativeHandler();
                    event.cancel();
                }
                break;
            }
        }

        private boolean isInFirstColumn(final Element element) {
            if (element == null) {
                return false;
            }
            final Element tbody = getTbodyElement();

            if (tbody == null || !tbody.isOrHasChild(element)) {
                return false;
            }

            /*
             * The null-parent in the while clause is in the case where element
             * is an immediate tr child in the tbody. Should never happen in
             * internal code, but hey...
             */
            Element cursor = element;
            while (cursor.getParentElement() != null
                    && cursor.getParentElement().getParentElement() != tbody) {
                cursor = cursor.getParentElement();
            }

            final Element tr = cursor.getParentElement();
            return tr.getFirstChildElement().equals(cursor);
        }
    }

    /**
     * This class's responsibility is to
     * <ul>
     * <li>scroll the table while a pointer is kept in a scrolling zone and
     * <li>select rows whenever a pointer is "activated" on a selection cell
     * </ul>
     * <p>
     * <em>Techical note:</em> This class is an AnimationCallback because we
     * need a timer: when the finger is kept in place while the grid scrolls, we
     * still need to be able to make new selections. So, instead of relying on
     * events (which won't be fired, since the pointer isn't necessarily
     * moving), we do this check on each frame while the pointer is "active"
     * (mouse is pressed, finger is on screen).
     */
    private class AutoScrollerAndSelector implements AnimationCallback {

        /**
         * If the acceleration gradient area is smaller than this, autoscrolling
         * will be disabled (it becomes too quick to accelerate to be usable).
         */
        private static final int GRADIENT_MIN_THRESHOLD_PX = 10;

        /**
         * The speed at which the gradient area recovers, once scrolling in that
         * direction has started.
         */
        private static final int SCROLL_AREA_REBOUND_PX_PER_SEC = 1;
        private static final double SCROLL_AREA_REBOUND_PX_PER_MS = SCROLL_AREA_REBOUND_PX_PER_SEC
                / 1000.0d;

        /**
         * The lowest y-coordinate on the {@link Event#getClientY() client} from
         * where we need to start scrolling towards the top.
         */
        private int topBound = -1;

        /**
         * The highest y-coordinate on the {@link Event#getClientY() client}
         * from where we need to scrolling towards the bottom.
         */
        private int bottomBound = -1;

        /**
         * <code>true</code> if the pointer is selecting, <code>false</code> if
         * the pointer is deselecting.
         */
        private final boolean selectionPaint;

        /**
         * The area where the selection acceleration takes place. If &lt;
         * {@link #GRADIENT_MIN_THRESHOLD_PX}, autoscrolling is disabled
         */
        private final int gradientArea;

        /**
         * The number of pixels per seconds we currently are scrolling (negative
         * is towards the top, positive is towards the bottom).
         */
        private double scrollSpeed = 0;

        private double prevTimestamp = 0;

        /**
         * This field stores fractions of pixels to scroll, to make sure that
         * we're able to scroll less than one px per frame.
         */
        private double pixelsToScroll = 0.0d;

        /** Should this animator be running. */
        private boolean running = false;

        /** The handle in which this instance is running. */
        private AnimationHandle handle;

        /** The pointer's pageX coordinate of the first click. */
        private int initialPageX = -1;

        /** The pointer's pageY coordinate. */
        private int pageY;

        /** The logical index of the row that was most recently modified. */
        private int lastModifiedLogicalRow = -1;

        /** @see #doScrollAreaChecks(int) */
        private int finalTopBound;

        /** @see #doScrollAreaChecks(int) */
        private int finalBottomBound;

        private boolean scrollAreaShouldRebound = false;

        private final int bodyAbsoluteTop;
        private final int bodyAbsoluteBottom;

        public AutoScrollerAndSelector(final int topBound,
                final int bottomBound, final int gradientArea,
                final boolean selectionPaint) {
            finalTopBound = topBound;
            finalBottomBound = bottomBound;
            this.gradientArea = gradientArea;
            this.selectionPaint = selectionPaint;

            bodyAbsoluteTop = getBodyClientTop();
            bodyAbsoluteBottom = getBodyClientBottom();
        }

        @Override
        public void execute(final double timestamp) {
            final double timeDiff = timestamp - prevTimestamp;
            prevTimestamp = timestamp;

            reboundScrollArea(timeDiff);

            pixelsToScroll += scrollSpeed * (timeDiff / 1000.0d);
            final int intPixelsToScroll = (int) pixelsToScroll;
            pixelsToScroll -= intPixelsToScroll;

            if (intPixelsToScroll != 0) {
                grid.setScrollTop(grid.getScrollTop() + intPixelsToScroll);
            }

            int constrainedPageY = Math.max(bodyAbsoluteTop,
                    Math.min(bodyAbsoluteBottom, pageY));
            int logicalRow = getLogicalRowIndex(grid, WidgetUtil
                    .getElementFromPoint(initialPageX, constrainedPageY));

            int incrementOrDecrement = (logicalRow > lastModifiedLogicalRow) ? 1
                    : -1;

            /*
             * Both pageY and initialPageX have their initialized (and
             * unupdated) values while the cursor hasn't moved since the first
             * invocation. This will lead to logicalRow being -1, until the
             * pointer has been moved.
             */
            while (logicalRow != -1 && lastModifiedLogicalRow != logicalRow) {
                lastModifiedLogicalRow += incrementOrDecrement;
                setSelected(lastModifiedLogicalRow, selectionPaint);
            }

            reschedule();
        }

        /**
         * If the scroll are has been offset by the pointer starting out there,
         * move it back a bit
         */
        private void reboundScrollArea(double timeDiff) {
            if (!scrollAreaShouldRebound) {
                return;
            }

            int reboundPx = (int) Math
                    .ceil(SCROLL_AREA_REBOUND_PX_PER_MS * timeDiff);
            if (topBound < finalTopBound) {
                topBound += reboundPx;
                topBound = Math.min(topBound, finalTopBound);
                updateScrollSpeed(pageY);
            } else if (bottomBound > finalBottomBound) {
                bottomBound -= reboundPx;
                bottomBound = Math.max(bottomBound, finalBottomBound);
                updateScrollSpeed(pageY);
            }
        }

        private void updateScrollSpeed(final int pointerPageY) {

            final double ratio;
            if (pointerPageY < topBound) {
                final double distance = pointerPageY - topBound;
                ratio = Math.max(-1, distance / gradientArea);
            } else if (pointerPageY > bottomBound) {
                final double distance = pointerPageY - bottomBound;
                ratio = Math.min(1, distance / gradientArea);
            } else {
                ratio = 0;
            }

            scrollSpeed = ratio * SCROLL_TOP_SPEED_PX_SEC;
        }

        public void start(int logicalRowIndex) {
            running = true;
            setSelected(logicalRowIndex, selectionPaint);
            lastModifiedLogicalRow = logicalRowIndex;
            reschedule();
        }

        public void stop() {
            running = false;

            if (handle != null) {
                handle.cancel();
                handle = null;
            }
        }

        private void reschedule() {
            if (running && gradientArea >= GRADIENT_MIN_THRESHOLD_PX) {
                handle = AnimationScheduler.get().requestAnimationFrame(this,
                        grid.getElement());
            }
        }

        public void updatePointerCoords(int pageX, int pageY) {
            doScrollAreaChecks(pageY);
            updateScrollSpeed(pageY);
            this.pageY = pageY;

            if (initialPageX == -1) {
                initialPageX = pageX;
            }
        }

        /**
         * This method checks whether the first pointer event started in an area
         * that would start scrolling immediately, and does some actions
         * accordingly.
         * <p>
         * If it is, that scroll area will be offset "beyond" the pointer (above
         * if pointer is towards the top, otherwise below).
         * <p>
         * <span style="font-size:smaller">*) This behavior will change in
         * future patches (henrik paul 2.7.2014)</span>
         */
        private void doScrollAreaChecks(int pageY) {
            /*
             * The first run makes sure that neither scroll position is
             * underneath the finger, but offset to either direction from
             * underneath the pointer.
             */
            if (topBound == -1) {
                topBound = Math.min(finalTopBound, pageY);
                bottomBound = Math.max(finalBottomBound, pageY);
            } else {
                /*
                 * Subsequent runs make sure that the scroll area grows (but doesn't
                 * shrink) with the finger, but no further than the final bound.
                 */
                int oldTopBound = topBound;
                if (topBound < finalTopBound) {
                    topBound = Math.max(topBound,
                            Math.min(finalTopBound, pageY));
                }

                int oldBottomBound = bottomBound;
                if (bottomBound > finalBottomBound) {
                    bottomBound = Math.min(bottomBound,
                            Math.max(finalBottomBound, pageY));
                }

                final boolean topDidNotMove = oldTopBound == topBound;
                final boolean bottomDidNotMove = oldBottomBound == bottomBound;
                final boolean wasVerticalMovement = pageY != this.pageY;
                scrollAreaShouldRebound = (topDidNotMove && bottomDidNotMove
                        && wasVerticalMovement);
            }
        }
    }

    /**
     * This class makes sure that pointer movemenets are registered and
     * delegated to the autoscroller so that it can:
     * <ul>
     * <li>modify the speed in which we autoscroll.
     * <li>"paint" a new row with the selection.
     * </ul>
     * Essentially, when a pointer is pressed on the selection column, a native
     * preview handler is registered (so that selection gestures can happen
     * outside of the selection column). The handler itself makes sure that it's
     * detached when the pointer is "lifted".
     */
    private class AutoScrollHandler {
        private AutoScrollerAndSelector autoScroller;

        /** The registration info for {@link #scrollPreviewHandler} */
        private HandlerRegistration handlerRegistration;

        private final NativePreviewHandler scrollPreviewHandler = event -> {
            if (autoScroller == null) {
                stop();
                return;
            }

            final NativeEvent nativeEvent = event.getNativeEvent();
            int pageY = 0;
            int pageX = 0;
            switch (event.getTypeInt()) {
            case Event.ONMOUSEMOVE:
            case Event.ONTOUCHMOVE:
                pageY = WidgetUtil.getTouchOrMouseClientY(nativeEvent);
                pageX = WidgetUtil.getTouchOrMouseClientX(nativeEvent);
                autoScroller.updatePointerCoords(pageX, pageY);
                break;
            case Event.ONMOUSEUP:
            case Event.ONTOUCHEND:
            case Event.ONTOUCHCANCEL:
                stop();
                break;
            }
        };

        /**
         * The top bound, as calculated from the {@link Event#getClientY()
         * client} coordinates.
         */
        private int topBound = -1;

        /**
         * The bottom bound, as calculated from the {@link Event#getClientY()
         * client} coordinates.
         */
        private int bottomBound = -1;

        /** The size of the autoscroll acceleration area. */
        private int gradientArea;

        public void start(int logicalRowIndex) {
            /*
             * bounds are updated whenever the autoscroll cycle starts, to make
             * sure that the widget hasn't changed in size, moved around, or
             * whatnot.
             */
            updateScrollBounds();

            assert handlerRegistration == null : "handlerRegistration was not null";
            assert autoScroller == null : "autoScroller was not null";
            handlerRegistration = Event
                    .addNativePreviewHandler(scrollPreviewHandler);

            autoScroller = new AutoScrollerAndSelector(topBound, bottomBound,
                    gradientArea, !isSelected(logicalRowIndex));
            autoScroller.start(logicalRowIndex);
        }

        private void updateScrollBounds() {
            final int topBorder = getBodyClientTop();
            final int bottomBorder = getBodyClientBottom();

            topBound = topBorder + SCROLL_AREA_GRADIENT_PX;
            bottomBound = bottomBorder - SCROLL_AREA_GRADIENT_PX;
            gradientArea = SCROLL_AREA_GRADIENT_PX;

            // modify bounds if they're too tightly packed
            if (bottomBound - topBound < MIN_NO_AUTOSCROLL_AREA_PX) {
                int adjustment = MIN_NO_AUTOSCROLL_AREA_PX
                        - (bottomBound - topBound);
                topBound -= adjustment / 2;
                bottomBound += adjustment / 2;
                gradientArea -= adjustment / 2;
            }
        }

        public void stop() {
            if (handlerRegistration != null) {
                handlerRegistration.removeHandler();
                handlerRegistration = null;
            }

            if (autoScroller != null) {
                autoScroller.stop();
                autoScroller = null;
            }

            removeNativeHandler();
        }
    }

    private final Grid<T> grid;
    private HandlerRegistration nativePreviewHandlerRegistration;

    private final AutoScrollHandler autoScrollHandler = new AutoScrollHandler();

    public MultiSelectionRenderer(final Grid<T> grid) {
        this.grid = grid;
    }

    @Override
    public void destroy() {
        if (nativePreviewHandlerRegistration != null) {
            removeNativeHandler();
        }
    }

    @Override
    public CheckBox createWidget() {
        final CheckBox checkBox = GWT.create(CheckBox.class);
        checkBox.setStylePrimaryName(
                grid.getStylePrimaryName() + SELECTION_CHECKBOX_CLASSNAME);

        CheckBoxEventHandler handler = new CheckBoxEventHandler(checkBox);

        // label of checkbox should only be visible for assistive devices
        checkBox.addStyleName("v-assistive-device-only-label");

        // Sink events
        checkBox.sinkBitlessEvent(BrowserEvents.MOUSEDOWN);
        checkBox.sinkBitlessEvent(BrowserEvents.TOUCHSTART);
        checkBox.sinkBitlessEvent(BrowserEvents.CLICK);

        // Add handlers
        checkBox.addMouseDownHandler(handler);
        checkBox.addTouchStartHandler(handler);
        checkBox.addClickHandler(handler);
        grid.addEnabledHandler(handler);
        grid.addSelectionAllowedHandler(handler);

        return checkBox;
    }

    @Override
    public void render(final RendererCellReference cell, final Boolean data,
            CheckBox checkBox) {
        checkBox.setValue(data, false);
        // this should be a temp fix.
        checkBox.setText("Selects row number " + getDOMRowIndex(cell) + ".");
        checkBox.setEnabled(grid.isEnabled() && !grid.isEditorActive());
    }

    private int getDOMRowIndex(RendererCellReference cell){
        // getRowIndex starts with zero, that's why we add an additional 1.
        // getDOMRowIndex should include getHeaderRows as well, this number
        // should be equals to aria-rowindex.
        return cell.getGrid().getHeaderRowCount() + cell.getRowIndex() + 1;
    }

    @Override
    public Collection<String> getConsumedEvents() {
        final HashSet<String> events = new HashSet<>();

        /*
         * this column's first interest is only to attach a NativePreventHandler
         * that does all the magic. These events are the beginning of that
         * cycle.
         */
        events.add(BrowserEvents.MOUSEDOWN);
        events.add(BrowserEvents.TOUCHSTART);

        return events;
    }

    @Override
    public boolean onBrowserEvent(final CellReference<?> cell,
            final NativeEvent event) {
        if (BrowserEvents.TOUCHSTART.equals(event.getType())
                || (BrowserEvents.MOUSEDOWN.equals(event.getType())
                        && event.getButton() == NativeEvent.BUTTON_LEFT)) {
            startDragSelect(event, Element.as(event.getEventTarget()));
            return true;
        } else {
            throw new IllegalStateException(
                    "received unexpected event: " + event.getType());
        }
    }

    private void startDragSelect(NativeEvent event, final Element target) {
        injectNativeHandler();
        int logicalRowIndex = getLogicalRowIndex(grid, target);
        autoScrollHandler.start(logicalRowIndex);
        event.preventDefault();
        event.stopPropagation();
    }

    private void injectNativeHandler() {
        removeNativeHandler();
        nativePreviewHandlerRegistration = Event
                .addNativePreviewHandler(new TouchEventHandler());
    }

    private void removeNativeHandler() {
        if (nativePreviewHandlerRegistration != null) {
            nativePreviewHandlerRegistration.removeHandler();
            nativePreviewHandlerRegistration = null;
        }
    }

    private int getLogicalRowIndex(Grid<T> grid, final Element target) {
        if (target == null) {
            return -1;
        }

        /*
         * We can't simply go backwards until we find a <tr> first element,
         * because of the table-in-table scenario. We need to, unfortunately, go
         * up from our known root.
         */
        final Element tbody = getTbodyElement();
        Element tr = tbody.getFirstChildElement();
        while (tr != null) {
            if (tr.isOrHasChild(target)) {
                final Element td = tr.getFirstChildElement();
                assert td != null : "Cell has disappeared";

                final Element checkbox = td.getFirstChildElement();
                assert checkbox != null : "Checkbox has disappeared";

                return ((AbstractRowContainer) grid.getEscalator().getBody())
                        .getLogicalRowIndex((TableRowElement) tr);
            }
            tr = tr.getNextSiblingElement();
        }
        return -1;
    }

    private TableElement getTableElement() {
        final Element root = grid.getElement();
        final Element tablewrapper = Element.as(root.getChild(2));
        if (tablewrapper != null) {
            return TableElement.as(tablewrapper.getFirstChildElement());
        } else {
            return null;
        }
    }

    private TableSectionElement getTbodyElement() {
        TableElement table = getTableElement();
        if (table != null) {
            return table.getTBodies().getItem(0);
        } else {
            return null;
        }
    }

    private TableSectionElement getTheadElement() {
        TableElement table = getTableElement();
        if (table != null) {
            return table.getTHead();
        } else {
            return null;
        }
    }

    private TableSectionElement getTfootElement() {
        TableElement table = getTableElement();
        if (table != null) {
            return table.getTFoot();
        } else {
            return null;
        }
    }

    /** Get the "top" of an element in relation to "client" coordinates. */
    private int getClientTop(final Element e) {
        return e.getAbsoluteTop();
    }

    private int getBodyClientBottom() {
        return getClientTop(getTfootElement()) - 1;
    }

    private int getBodyClientTop() {
        // Off by one pixel miscalculation. possibly border related.
        return getClientTop(grid.getElement())
                + getTheadElement().getOffsetHeight() + 1;
    }

    protected boolean isSelected(final int logicalRow) {
        return grid.isSelected(grid.getDataSource().getRow(logicalRow));
    }

    protected void setSelected(final int logicalRow, final boolean select) {
        T row = grid.getDataSource().getRow(logicalRow);
        if (select) {
            grid.select(row);
        } else {
            grid.deselect(row);
        }
    }
}
