// Copyright 2015 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.chrome.browser.tabmodel;

import androidx.test.filters.MediumTest;
import androidx.test.platform.app.InstrumentationRegistry;

import org.hamcrest.Matchers;
import org.junit.Assert;
import org.junit.Before;
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;

import org.chromium.base.ApplicationStatus;
import org.chromium.base.test.util.Batch;
import org.chromium.base.test.util.CallbackHelper;
import org.chromium.base.test.util.CommandLineFlags;
import org.chromium.base.test.util.Criteria;
import org.chromium.base.test.util.CriteriaHelper;
import org.chromium.base.test.util.MinAndroidSdkLevel;
import org.chromium.base.test.util.Restriction;
import org.chromium.base.test.util.UrlUtils;
import org.chromium.chrome.browser.ChromeTabbedActivity2;
import org.chromium.chrome.browser.app.tabmodel.TabModelOrchestrator;
import org.chromium.chrome.browser.flags.ChromeSwitches;
import org.chromium.chrome.browser.multiwindow.MultiWindowTestHelper;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabLaunchType;
import org.chromium.chrome.test.ChromeJUnit4ClassRunner;
import org.chromium.chrome.test.ChromeTabbedActivityTestRule;
import org.chromium.chrome.test.batch.BlankCTATabInitialStateRule;
import org.chromium.chrome.test.util.ChromeTabUtils;
import org.chromium.content_public.browser.LoadUrlParams;
import org.chromium.content_public.browser.test.util.TestThreadUtils;
import org.chromium.ui.test.util.UiRestriction;

import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;

/**
 * Tests undo and restoring of tabs in a {@link TabModel}. These tests require native initialization
 * or multiple activities. For additional tests see {@link UndoTabModelUnitTest}.
 */
@RunWith(ChromeJUnit4ClassRunner.class)
@CommandLineFlags.Add({ChromeSwitches.DISABLE_FIRST_RUN_EXPERIENCE})
@Batch(Batch.PER_CLASS)
public class UndoTabModelTest {
    @ClassRule
    public static ChromeTabbedActivityTestRule sActivityTestRule =
            new ChromeTabbedActivityTestRule();

    @Rule
    public BlankCTATabInitialStateRule mInitialStateRule =
            new BlankCTATabInitialStateRule(sActivityTestRule, false);

    @Before
    public void setUp() throws InterruptedException {
        // Disable snackbars from the {@link UndoBarController} which can break this test.
        sActivityTestRule.getActivity().getSnackbarManager().disableForTesting();
    }

    private static final Tab[] EMPTY = new Tab[] {};
    private static final String TEST_URL_0 = UrlUtils.encodeHtmlDataUri("<html>test_url_0.</html>");
    private static final String TEST_URL_1 = UrlUtils.encodeHtmlDataUri("<html>test_url_1.</html>");

    private void checkState(
            final TabModel model, final Tab[] tabsList, final Tab selectedTab,
            final Tab[] closingTabs, final Tab[] fullTabsList,
            final Tab fullSelectedTab) {
        // Keeping these checks on the test thread so the stacks are useful for identifying
        // failures.

        // Check the selected tab.
        Assert.assertEquals("Wrong selected tab", selectedTab, TabModelUtils.getCurrentTab(model));

        // Check the list of tabs.
        Assert.assertEquals("Incorrect number of tabs", tabsList.length, model.getCount());
        for (int i = 0; i < tabsList.length; i++) {
            Assert.assertEquals(
                    "Unexpected tab at " + i, tabsList[i].getId(), model.getTabAt(i).getId());
        }

        // Check the list of tabs we expect to be closing.
        for (int i = 0; i < closingTabs.length; i++) {
            int id = closingTabs[i].getId();
            Assert.assertTrue("Tab " + id + " not in closing list", model.isClosurePending(id));
        }

        TabList fullModel = model.getComprehensiveModel();

        // Check the comprehensive selected tab.
        Assert.assertEquals(
                "Wrong selected tab", fullSelectedTab, TabModelUtils.getCurrentTab(fullModel));

        // Check the comprehensive list of tabs.
        Assert.assertEquals("Incorrect number of tabs", fullTabsList.length, fullModel.getCount());
        for (int i = 0; i < fullModel.getCount(); i++) {
            int id = fullModel.getTabAt(i).getId();
            Assert.assertEquals("Unexpected tab at " + i, fullTabsList[i].getId(), id);
        }
    }

    private void createTabOnUiThread(final ChromeTabCreator tabCreator) {
        TestThreadUtils.runOnUiThreadBlocking(() -> {
            tabCreator.createNewTab(
                    new LoadUrlParams("about:blank"), TabLaunchType.FROM_CHROME_UI, null);
        });
    }

    private void closeTabOnUiThread(final TabModel model, final Tab tab, final boolean undoable)
            throws TimeoutException {
        // Check preconditions.
        Assert.assertFalse(tab.isClosing());
        Assert.assertTrue(tab.isInitialized());
        Assert.assertFalse(model.isClosurePending(tab.getId()));
        Assert.assertNotNull(TabModelUtils.getTabById(model, tab.getId()));

        final CallbackHelper didReceivePendingClosureHelper = new CallbackHelper();
        TestThreadUtils.runOnUiThreadBlocking(() -> {
            model.addObserver(new TabModelObserver() {
                @Override
                public void tabPendingClosure(Tab tab) {
                    didReceivePendingClosureHelper.notifyCalled();
                }
            });

            // Take action.
            model.closeTab(tab, true, false, undoable);
        });

        boolean didMakePending = undoable && model.supportsPendingClosures();

        // Make sure the TabModel throws a tabPendingClosure callback if necessary.
        if (didMakePending) didReceivePendingClosureHelper.waitForCallback(0);

        // Check post conditions
        Assert.assertEquals(didMakePending, model.isClosurePending(tab.getId()));
        Assert.assertNull(TabModelUtils.getTabById(model, tab.getId()));
        Assert.assertTrue(tab.isClosing());
        Assert.assertEquals(didMakePending, tab.isInitialized());
    }

    private void saveStateOnUiThread(final TabModelOrchestrator orchestrator) {
        TestThreadUtils.runOnUiThreadBlocking(() -> { orchestrator.saveState(); });

        TabModelSelector selector = orchestrator.getTabModelSelector();
        for (int i = 0; i < selector.getModels().size(); i++) {
            TabModel model = selector.getModels().get(i);
            TabList tabs = model.getComprehensiveModel();
            for (int j = 0; j < tabs.getCount(); j++) {
                Assert.assertFalse(model.isClosurePending(tabs.getTabAt(j).getId()));
            }
        }
    }

    private void openMostRecentlyClosedTabOnUiThread(final TabModelSelector selector) {
        TestThreadUtils.runOnUiThreadBlocking(
                () -> { selector.getCurrentModel().openMostRecentlyClosedEntry(); });
    }

    // Helper class that notifies after the tab is closed, and a tab restore service entry has been
    // created in tab restore service.
    private static class TabClosedObserver implements TabModelObserver {
        private CallbackHelper mTabClosedCallback;

        public TabClosedObserver(CallbackHelper closedCallback) {
            mTabClosedCallback = closedCallback;
        }

        @Override
        public void onFinishingTabClosure(Tab tab) {
            mTabClosedCallback.notifyCalled();
        }
    }

    /**
     * Test calling {@link TabModelOrchestrator#saveState()} commits all pending closures:
     *     Action                     Model List         Close List        Comprehensive List
     * 1.  Initial State              [ 0 1s ]           -                 [ 0 1s ]
     * 2.  CloseTab(0, allow undo)    [ 1s ]             [ 0 ]             [ 0 1s ]
     * 3.  SaveState                  [ 1s ]             -                 [ 1s ]
     */
    @Test
    @MediumTest
    @Restriction(UiRestriction.RESTRICTION_TYPE_PHONE) // See crbug.com/633607
    public void testSaveStateCommitsUndos() throws TimeoutException, ExecutionException {
        TabModelOrchestrator orchestrator = TestThreadUtils.runOnUiThreadBlocking(
                () -> sActivityTestRule.getActivity().getTabModelOrchestratorSupplier().get());
        TabModelSelector selector = orchestrator.getTabModelSelector();
        TabModel model = selector.getModel(false);
        ChromeTabCreator tabCreator = TestThreadUtils.runOnUiThreadBlockingNoException(
                () -> sActivityTestRule.getActivity().getTabCreator(false));
        createTabOnUiThread(tabCreator);

        Tab tab0 = model.getTabAt(0);
        Tab tab1 = model.getTabAt(1);

        Tab[] fullList = new Tab[] { tab0, tab1 };

        // 1.
        checkState(model, new Tab[] { tab0, tab1 }, tab1, EMPTY, fullList, tab1);

        // 2.
        closeTabOnUiThread(model, tab0, true);
        checkState(model, new Tab[] { tab1 }, tab1, EMPTY, fullList, tab1);

        // 3.
        saveStateOnUiThread(orchestrator);
        fullList = new Tab[] { tab1 };
        checkState(model, new Tab[] {tab1}, tab1, EMPTY, fullList, tab1);
        Assert.assertTrue(tab0.isClosing());
        Assert.assertFalse(tab0.isInitialized());
    }

    /**
     * Test opening recently closed tab using native tab restore service.
     */
    @Test
    @MediumTest
    public void testOpenRecentlyClosedTabNative() throws TimeoutException {
        final TabModelSelector selector = sActivityTestRule.getActivity().getTabModelSelector();
        final TabModel model = selector.getModel(false);

        // Create new tab and wait until it's loaded.
        // Native can only successfully recover the tab after a page load has finished and
        // it has navigation history.
        ChromeTabUtils.fullyLoadUrlInNewTab(InstrumentationRegistry.getInstrumentation(),
                sActivityTestRule.getActivity(), TEST_URL_0, false);

        // Close the tab, and commit pending closure.
        Assert.assertEquals(model.getCount(), 2);
        closeTabOnUiThread(model, model.getTabAt(1), false);
        Assert.assertEquals(1, model.getCount());
        Tab tab0 = model.getTabAt(0);
        Tab[] tabs = new Tab[]{tab0};
        checkState(model, tabs, tab0, EMPTY, tabs, tab0);

        // Recover the page.
        openMostRecentlyClosedTabOnUiThread(selector);

        Assert.assertEquals(2, model.getCount());
        tab0 = model.getTabAt(0);
        Tab tab1 = model.getTabAt(1);
        tabs = new Tab[]{tab0, tab1};
        Assert.assertEquals(TEST_URL_0, ChromeTabUtils.getUrlStringOnUiThread(tab1));
        checkState(model, tabs, tab0, EMPTY, tabs, tab0);
    }

    /**
     * Test opening recently closed tab when we have multiple windows.
     * |  Action                    |   Result
     * 1. Create second window.     |
     * 2. Open tab in window 1.     |
     * 3. Open tab in window 2.     |
     * 4. Close tab in window 1.    |
     * 5. Close tab in window 2.    |
     * 6. Restore tab.              | Tab restored in window 2.
     * 7. Restore tab.              | Tab restored in window 1.
     */
    @Test
    @MediumTest
    @CommandLineFlags.Add(ChromeSwitches.DISABLE_TAB_MERGING_FOR_TESTING)
    public void testOpenRecentlyClosedTabMultiWindow() throws TimeoutException {
        final ChromeTabbedActivity2 secondActivity =
                MultiWindowTestHelper.createSecondChromeTabbedActivity(
                        sActivityTestRule.getActivity());

        // Wait for the second window to be fully initialized.
        CriteriaHelper.pollUiThread(
                () -> secondActivity.getTabModelSelector().isTabStateInitialized());
        // First window context.
        final TabModelSelector firstSelector =
                sActivityTestRule.getActivity().getTabModelSelector();
        final TabModel firstModel = firstSelector.getModel(false);

        // Second window context.
        final TabModelSelector secondSelector = secondActivity.getTabModelSelector();
        final TabModel secondModel = secondSelector.getModel(false);

        // Create tabs.
        ChromeTabUtils.fullyLoadUrlInNewTab(InstrumentationRegistry.getInstrumentation(),
                sActivityTestRule.getActivity(), TEST_URL_0, false);
        ChromeTabUtils.fullyLoadUrlInNewTab(
                InstrumentationRegistry.getInstrumentation(), secondActivity, TEST_URL_1, false);

        Assert.assertEquals("Unexpected number of tabs in first window.", 2, firstModel.getCount());
        Assert.assertEquals(
                "Unexpected number of tabs in second window.", 2, secondModel.getCount());

        // Close one tab in the first window.
        closeTabOnUiThread(firstModel, firstModel.getTabAt(1), false);
        Assert.assertEquals("Unexpected number of tabs in first window.", 1, firstModel.getCount());
        Assert.assertEquals(
                "Unexpected number of tabs in second window.", 2, secondModel.getCount());

        // Close one tab in the second window.
        closeTabOnUiThread(secondModel, secondModel.getTabAt(1), false);
        Assert.assertEquals("Unexpected number of tabs in first window.", 1, firstModel.getCount());
        Assert.assertEquals(
                "Unexpected number of tabs in second window.", 1, secondModel.getCount());

        // Restore one tab to the second selector.
        openMostRecentlyClosedTabOnUiThread(secondSelector);
        Assert.assertEquals("Unexpected number of tabs in first window.", 1, firstModel.getCount());
        Assert.assertEquals(
                "Unexpected number of tabs in second window.", 2, secondModel.getCount());

        // Restore one more tab to the first selector.
        openMostRecentlyClosedTabOnUiThread(firstSelector);

        // Check final states of both windows.
        Tab firstModelTab = firstModel.getTabAt(0);
        Tab secondModelTab = secondModel.getTabAt(0);
        Tab[] firstWindowTabs = new Tab[]{firstModelTab, firstModel.getTabAt(1)};
        Tab[] secondWindowTabs = new Tab[]{secondModelTab, secondModel.getTabAt(1)};
        checkState(firstModel, firstWindowTabs, firstModelTab, EMPTY, firstWindowTabs,
                firstModelTab);
        checkState(secondModel, secondWindowTabs, secondModelTab, EMPTY, secondWindowTabs,
                secondModelTab);
        Assert.assertEquals(TEST_URL_0, ChromeTabUtils.getUrlStringOnUiThread(firstWindowTabs[1]));
        Assert.assertEquals(TEST_URL_1, ChromeTabUtils.getUrlStringOnUiThread(secondWindowTabs[1]));

        secondActivity.finishAndRemoveTask();
    }

    /**
     * Test restoring closed tab from a closed window.
     * |  Action                    |   Result
     * 1. Create second window.     |
     * 2. Open tab in window 2.     |
     * 3. Close tab in window 2.    |
     * 4. Close second window.      |
     * 5. Restore tab.              | Tab restored in first window.
     */
    @Test
    @MediumTest
    @MinAndroidSdkLevel(24)
    @CommandLineFlags.Add(ChromeSwitches.DISABLE_TAB_MERGING_FOR_TESTING)
    public void testOpenRecentlyClosedTabMultiWindowFallback() throws TimeoutException {
        final ChromeTabbedActivity2 secondActivity =
                MultiWindowTestHelper.createSecondChromeTabbedActivity(
                        sActivityTestRule.getActivity());
        // Wait for the second window to be fully initialized.
        CriteriaHelper.pollUiThread(
                () -> secondActivity.getTabModelSelector().isTabStateInitialized());

        // First window context.
        final TabModelSelector firstSelector =
                sActivityTestRule.getActivity().getTabModelSelector();
        final TabModel firstModel = firstSelector.getModel(false);

        // Second window context.
        final TabModel secondModel = secondActivity.getTabModelSelector().getModel(false);

        // Create tab on second window.
        ChromeTabUtils.fullyLoadUrlInNewTab(
                InstrumentationRegistry.getInstrumentation(), secondActivity, TEST_URL_1, false);
        Assert.assertEquals("Window 2 should have 2 tab.", 2, secondModel.getCount());

        // Close tab in second window, wait until tab restore service history is created.
        CallbackHelper closedCallback = new CallbackHelper();
        TestThreadUtils.runOnUiThreadBlocking(
                () -> secondModel.addObserver(new TabClosedObserver(closedCallback)));
        closeTabOnUiThread(secondModel, secondModel.getTabAt(1), false);
        closedCallback.waitForCallback(0);

        Assert.assertEquals("Window 2 should have 1 tab.", 1, secondModel.getCount());

        // Closed the second window. Must wait until it's totally closed.
        int numExpectedActivities = ApplicationStatus.getRunningActivities().size() - 1;
        secondActivity.finishAndRemoveTask();
        CriteriaHelper.pollUiThread(() -> {
            Criteria.checkThat(ApplicationStatus.getRunningActivities().size(),
                    Matchers.is(numExpectedActivities));
        });
        Assert.assertEquals("Window 1 should have 1 tab.", 1, firstModel.getCount());

        // Restore closed tab from second window. It should be created in first window.
        openMostRecentlyClosedTabOnUiThread(firstSelector);
        Assert.assertEquals("Closed tab in second window should be restored in the first window.",
                2, firstModel.getCount());
        Tab tab0 = firstModel.getTabAt(0);
        Tab tab1 = firstModel.getTabAt(1);
        Tab[] firstWindowTabs = new Tab[]{tab0, tab1};
        checkState(firstModel, firstWindowTabs, tab0, EMPTY, firstWindowTabs, tab0);
        Assert.assertEquals(TEST_URL_1, ChromeTabUtils.getUrlStringOnUiThread(tab1));
    }
}
