/*
 * Copyright (C) 2015 The Android Open Source Project
 *
 * 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.android.documentsui.selection;

import static com.android.documentsui.selection.GridModel.NOT_SET;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;

import android.graphics.Point;
import android.graphics.Rect;
import android.support.test.filters.SmallTest;
import android.support.test.runner.AndroidJUnit4;
import android.support.v7.widget.RecyclerView.OnScrollListener;

import com.android.documentsui.selection.BandSelectionHelper.BandHost;
import com.android.documentsui.selection.testing.SelectionPredicates;
import com.android.documentsui.selection.testing.TestAdapter;
import com.android.documentsui.selection.testing.TestStableIdProvider;

import org.junit.After;
import org.junit.Test;
import org.junit.runner.RunWith;

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

import javax.annotation.Nullable;

@RunWith(AndroidJUnit4.class)
@SmallTest
public class GridModelTest {

    private static final int VIEW_PADDING_PX = 5;
    private static final int CHILD_VIEW_EDGE_PX = 100;
    private static final int VIEWPORT_HEIGHT = 500;

    private GridModel mModel;
    private TestHost mHost;
    private TestAdapter mAdapter;
    private Set<String> mLastSelection;
    private int mViewWidth;

    // TLDR: Don't call model.{start|resize}Selection; use the local #startSelection and
    // #resizeSelection methods instead.
    //
    // The reason for this is that selection is stateful and involves operations that take the
    // current UI state (e.g scrolling) into account. This test maintains its own copy of the
    // selection bounds as control data for verifying selections. Keep this data in sync by calling
    // #startSelection and
    // #resizeSelection.
    private Point mSelectionOrigin;
    private Point mSelectionPoint;

    @After
    public void tearDown() {
        mModel = null;
        mHost = null;
        mLastSelection = null;
    }

    @Test
    public void testSelectionLeftOfItems() {
        initData(20, 5);
        startSelection(new Point(0, 10));
        resizeSelection(new Point(1, 11));
        assertNoSelection();
        assertEquals(NOT_SET, mModel.getPositionNearestOrigin());
    }

    @Test
    public void testSelectionRightOfItems() {
        initData(20, 4);
        startSelection(new Point(mViewWidth - 1, 10));
        resizeSelection(new Point(mViewWidth - 2, 11));
        assertNoSelection();
        assertEquals(NOT_SET, mModel.getPositionNearestOrigin());
    }

    @Test
    public void testSelectionAboveItems() {
        initData(20, 4);
        startSelection(new Point(10, 0));
        resizeSelection(new Point(11, 1));
        assertNoSelection();
        assertEquals(NOT_SET, mModel.getPositionNearestOrigin());
    }

    @Test
    public void testSelectionBelowItems() {
        initData(5, 4);
        startSelection(new Point(10, VIEWPORT_HEIGHT - 1));
        resizeSelection(new Point(11, VIEWPORT_HEIGHT - 2));
        assertNoSelection();
        assertEquals(NOT_SET, mModel.getPositionNearestOrigin());
    }

    @Test
    public void testVerticalSelectionBetweenItems() {
        initData(20, 4);
        startSelection(new Point(106, 0));
        resizeSelection(new Point(107, 200));
        assertNoSelection();
        assertEquals(NOT_SET, mModel.getPositionNearestOrigin());
    }

    @Test
    public void testHorizontalSelectionBetweenItems() {
        initData(20, 4);
        startSelection(new Point(0, 105));
        resizeSelection(new Point(200, 106));
        assertNoSelection();
        assertEquals(NOT_SET, mModel.getPositionNearestOrigin());
    }

    @Test
    public void testGrowingAndShrinkingSelection() {
        initData(20, 4);
        startSelection(new Point(0, 0));

        resizeSelection(new Point(5, 5));
        verifySelection();

        resizeSelection(new Point(109, 109));
        verifySelection();

        resizeSelection(new Point(110, 109));
        verifySelection();

        resizeSelection(new Point(110, 110));
        verifySelection();

        resizeSelection(new Point(214, 214));
        verifySelection();

        resizeSelection(new Point(215, 214));
        verifySelection();

        resizeSelection(new Point(214, 214));
        verifySelection();

        resizeSelection(new Point(110, 110));
        verifySelection();

        resizeSelection(new Point(110, 109));
        verifySelection();

        resizeSelection(new Point(109, 109));
        verifySelection();

        resizeSelection(new Point(5, 5));
        verifySelection();

        resizeSelection(new Point(0, 0));
        verifySelection();

        assertEquals(NOT_SET, mModel.getPositionNearestOrigin());
    }

    @Test
    public void testSelectionMovingAroundOrigin() {
        initData(16, 4);

        startSelection(new Point(210, 210));
        resizeSelection(new Point(mViewWidth - 1, 0));
        verifySelection();

        resizeSelection(new Point(0, 0));
        verifySelection();

        resizeSelection(new Point(0, 420));
        verifySelection();

        resizeSelection(new Point(mViewWidth - 1, 420));
        verifySelection();

        // This is manually figured and will need to be adjusted if the separator position is
        // changed.
        assertEquals(7, mModel.getPositionNearestOrigin());
    }

    @Test
    public void testScrollingBandSelect() {
        initData(40, 4);

        startSelection(new Point(0, 0));
        resizeSelection(new Point(100, VIEWPORT_HEIGHT - 1));
        verifySelection();

        scroll(CHILD_VIEW_EDGE_PX);
        verifySelection();

        resizeSelection(new Point(200, VIEWPORT_HEIGHT - 1));
        verifySelection();

        scroll(CHILD_VIEW_EDGE_PX);
        verifySelection();

        scroll(-2 * CHILD_VIEW_EDGE_PX);
        verifySelection();

        resizeSelection(new Point(100, VIEWPORT_HEIGHT - 1));
        verifySelection();

        assertEquals(0, mModel.getPositionNearestOrigin());
    }

    private void initData(final int numChildren, int numColumns) {
        mHost = new TestHost(numChildren, numColumns);
        mAdapter = new TestAdapter() {
            @Override
            public String getStableId(int position) {
                return Integer.toString(position);
            }

            @Override
            public int getItemCount() {
                return numChildren;
            }
        };

        mViewWidth = VIEW_PADDING_PX + numColumns * (VIEW_PADDING_PX + CHILD_VIEW_EDGE_PX);

        mModel = new GridModel(
                mHost,
                new TestStableIdProvider(mAdapter),
                SelectionPredicates.CAN_SET_ANYTHING);

        mModel.addOnSelectionChangedListener(
                new GridModel.SelectionObserver() {
                    @Override
                    public void onSelectionChanged(Set<String> updatedSelection) {
                        mLastSelection = updatedSelection;
                    }
                });
    }

    /** Returns the current selection area as a Rect. */
    private Rect getSelectionArea() {
        // Construct a rect from the two selection points.
        Rect selectionArea = new Rect(
                mSelectionOrigin.x, mSelectionOrigin.y, mSelectionOrigin.x, mSelectionOrigin.y);
        selectionArea.union(mSelectionPoint.x, mSelectionPoint.y);
        // Rect intersection tests are exclusive of bounds, while the MSM's selection code is
        // inclusive. Expand the rect by 1 pixel in all directions to account for this.
        selectionArea.inset(-1, -1);

        return selectionArea;
    }

    /** Asserts that the selection is currently empty. */
    private void assertNoSelection() {
        assertEquals("Unexpected items " + mLastSelection + " in selection " + getSelectionArea(),
                0, mLastSelection.size());
    }

    /** Verifies the selection using actual bbox checks. */
    private void verifySelection() {
        Rect selectionArea = getSelectionArea();
        for (TestHost.Item item: mHost.items) {
            if (Rect.intersects(selectionArea, item.rect)) {
                assertTrue("Expected item " + item + " was not in selection " + selectionArea,
                        mLastSelection.contains(item.name));
            } else {
                assertFalse("Unexpected item " + item + " in selection" + selectionArea,
                        mLastSelection.contains(item.name));
            }
        }
    }

    private void startSelection(Point p) {
        mModel.startCapturing(p);
        mSelectionOrigin = mHost.createAbsolutePoint(p);
    }

    private void resizeSelection(Point p) {
        mModel.resizeSelection(p);
        mSelectionPoint = mHost.createAbsolutePoint(p);
    }

    private void scroll(int dy) {
        assertTrue(mHost.verticalOffset + VIEWPORT_HEIGHT + dy <= mHost.getTotalHeight());
        mHost.verticalOffset += dy;
        // Correct the cached selection point as well.
        mSelectionPoint.y += dy;
        mHost.mScrollListener.onScrolled(null, 0, dy);
    }

    private static final class TestHost extends BandHost {

        private final int mNumColumns;
        private final int mNumRows;
        private final int mNumChildren;
        private final int mSeparatorPosition;

        public int horizontalOffset = 0;
        public int verticalOffset = 0;
        private List<Item> items = new ArrayList<>();

        // Installed by GridModel on construction.
        private @Nullable OnScrollListener mScrollListener;

        public TestHost(int numChildren, int numColumns) {
            mNumChildren = numChildren;
            mNumColumns = numColumns;
            mSeparatorPosition = mNumColumns + 1;
            mNumRows = setupGrid();
        }

        private int setupGrid() {
            // Split the input set into folders and documents. Do this such that there is a
            // partially-populated row in the middle of the grid, to test corner cases in layout
            // code.
            int y = VIEW_PADDING_PX;
            int i = 0;
            int numRows = 0;
            while (i < mNumChildren) {
                int top = y;
                int height = CHILD_VIEW_EDGE_PX;
                int width = CHILD_VIEW_EDGE_PX;
                for (int j = 0; j < mNumColumns && i < mNumChildren; j++) {
                    int left = VIEW_PADDING_PX + (j * (width + VIEW_PADDING_PX));
                    items.add(new Item(
                            Integer.toString(i),
                            new Rect(
                                    left,
                                    top,
                                    left + width - 1,
                                    top + height - 1)));

                    // Create a partially populated row at the separator position.
                    if (++i == mSeparatorPosition) {
                        break;
                    }
                }
                y += height + VIEW_PADDING_PX;
                numRows++;
            }

            return numRows;
        }

        private int getTotalHeight() {
            return CHILD_VIEW_EDGE_PX * mNumRows + VIEW_PADDING_PX * (mNumRows + 1);
        }

        private int getFirstVisibleRowIndex() {
            return verticalOffset / (CHILD_VIEW_EDGE_PX + VIEW_PADDING_PX);
        }

        private int getLastVisibleRowIndex() {
            int lastVisibleRowUncapped =
                    (VIEWPORT_HEIGHT + verticalOffset - 1) / (CHILD_VIEW_EDGE_PX + VIEW_PADDING_PX);
            return Math.min(lastVisibleRowUncapped, mNumRows - 1);
        }

        private int getNumItemsInRow(int index) {
            assertTrue(index >= 0 && index < mNumRows);
            int mod = mSeparatorPosition % mNumColumns;
            if (index == (mSeparatorPosition / mNumColumns)) {
                // The row containing the separator may be incomplete
                return mod > 0 ? mod : mNumColumns;
            }
            // Account for the partial separator row in the final row tally.
            if (index == mNumRows - 1) {
                // The last row may be incomplete
                int finalRowCount = (mNumChildren - mod) % mNumColumns;
                return finalRowCount > 0 ? finalRowCount : mNumColumns;
            }

            return mNumColumns;
        }

        @Override
        public void addOnScrollListener(OnScrollListener listener) {
            mScrollListener = listener;
        }

        @Override
        public void removeOnScrollListener(OnScrollListener listener) {}

        @Override
        public Point createAbsolutePoint(Point relativePoint) {
            return new Point(
                    relativePoint.x + horizontalOffset, relativePoint.y + verticalOffset);
        }

        @Override
        public int getVisibleChildCount() {
            int childCount = 0;
            for (int i = getFirstVisibleRowIndex(); i <= getLastVisibleRowIndex(); i++) {
                childCount += getNumItemsInRow(i);
            }
            return childCount;
        }

        @Override
        public int getAdapterPositionAt(int index) {
            // Account for partial rows by actually tallying up the items in hidden rows.
            int hiddenCount = 0;
            for (int i = 0; i < getFirstVisibleRowIndex(); i++) {
                hiddenCount += getNumItemsInRow(i);
            }
            return index + hiddenCount;
        }

        @Override
        public Rect getAbsoluteRectForChildViewAt(int index) {
            int adapterPosition = getAdapterPositionAt(index);
            return items.get(adapterPosition).rect;
        }

        @Override
        public int getChildCount() {
            return mNumChildren;
        }

        @Override
        public int getColumnCount() {
            return mNumColumns;
        }

        @Override
        public void showBand(Rect rect) {
            throw new UnsupportedOperationException();
        }

        @Override
        public void hideBand() {
            throw new UnsupportedOperationException();
        }

        @Override
        public void scrollBy(int dy) {
            throw new UnsupportedOperationException();
        }

        @Override
        public int getHeight() {
            throw new UnsupportedOperationException();
        }

        @Override
        public void invalidateView() {
            throw new UnsupportedOperationException();
        }

        @Override
        public void runAtNextFrame(Runnable r) {
            throw new UnsupportedOperationException();
        }

        @Override
        public void removeCallback(Runnable r) {
            throw new UnsupportedOperationException();
        }

        @Override
        public boolean hasView(int adapterPosition) {
            return true;
        }

        public static final class Item {
            public String name;
            public Rect rect;

            public Item(String n, Rect r) {
                name = n;
                rect = r;
            }

            @Override
            public String toString() {
                return name + ": " + rect;
            }
        }
    }
}
