chromium/chrome/browser/tab_group/java/src/org/chromium/chrome/browser/tasks/tab_groups/TabGroupModelFilter.java

// Copyright 2019 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.tasks.tab_groups;

import android.util.Pair;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.collection.ArraySet;

import org.chromium.base.Callback;
import org.chromium.base.MathUtils;
import org.chromium.base.ObserverList;
import org.chromium.base.Token;
import org.chromium.base.cached_flags.BooleanCachedFieldTrialParameter;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.base.shared_preferences.SharedPreferencesManager;
import org.chromium.base.supplier.LazyOneshotSupplier;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.preferences.ChromePreferenceKeys;
import org.chromium.chrome.browser.preferences.ChromeSharedPreferences;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabLaunchType;
import org.chromium.chrome.browser.tab.TabStateAttributes;
import org.chromium.chrome.browser.tab_group_sync.TabGroupSyncFeatures;
import org.chromium.chrome.browser.tabmodel.TabClosureParams;
import org.chromium.chrome.browser.tabmodel.TabList;
import org.chromium.chrome.browser.tabmodel.TabModel;
import org.chromium.chrome.browser.tabmodel.TabModelFilter;
import org.chromium.chrome.browser.tabmodel.TabModelUtils;
import org.chromium.chrome.browser.tasks.tab_groups.TabGroupModelFilterObserver.DidRemoveTabGroupReason;
import org.chromium.components.tab_groups.TabGroupColorId;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;

/**
 * An implementation of {@link TabModelFilter} that puts {@link Tab}s into a group structure.
 *
 * <p>A group is a collection of {@link Tab}s that share a common ancestor {@link Tab}. This filter
 * is also a {@link TabList} that contains the last shown {@link Tab} from every group.
 *
 * <p>Note this class is in the process of migrating from root ID to TabGroupId. All references to
 * root ID refer to the old ID system. References to tab group ID will refer to the new system. See
 * https://crbug.com/1523745. Update July 2024: the flag for the new TabGroupId system has been
 * removed and it is now launched. This class (and any clients) still need to be migrated off of
 * root ID.
 */
public class TabGroupModelFilter extends TabModelFilter {
    // This is not a great place for this, but due to dependency issues it cannot go in
    // TabUiFeatureUtilities so this is the best place since we need it here.
    private static final String SKIP_TAB_GROUP_CREATION_DIALOG_PARAM =
            "skip_tab_group_creation_dialog";
    public static final BooleanCachedFieldTrialParameter SKIP_TAB_GROUP_CREATION_DIALOG =
            ChromeFeatureList.newBooleanCachedFieldTrialParameter(
                    ChromeFeatureList.TAB_GROUP_PARITY_ANDROID,
                    SKIP_TAB_GROUP_CREATION_DIALOG_PARAM,
                    true);

    public static final String SHOW_TAB_GROUP_CREATION_DIALOG_SETTING_PARAM =
            "show_tab_group_creation_dialog_setting";
    public static final BooleanCachedFieldTrialParameter SHOW_TAB_GROUP_CREATION_DIALOG_SETTING =
            ChromeFeatureList.newBooleanCachedFieldTrialParameter(
                    ChromeFeatureList.TAB_GROUP_CREATION_DIALOG_ANDROID,
                    SHOW_TAB_GROUP_CREATION_DIALOG_SETTING_PARAM,
                    false);

    /**
     * Class to hold metadata while fixRootIds still exists. Delete when rootId is removed.
     * Instanced to allow easy setting of fields in constructor.
     */
    private class TabGroupMetadata {
        public final String title;
        public final int color;
        public final boolean isCollapsed;

        public TabGroupMetadata(int rootId) {
            title = getTabGroupTitle(rootId);
            color = getTabGroupColorWithFallback(rootId);
            isCollapsed = getTabGroupCollapsed(rootId);
        }
    }

    private ObserverList<TabGroupModelFilterObserver> mGroupFilterObserver = new ObserverList<>();
    private Map<Integer, Integer> mRootIdToGroupIndexMap = new HashMap<>();
    private Map<Integer, TabGroup> mRootIdToGroupMap = new HashMap<>();

    /**
     * The set of tab group IDs that are currently hiding. This cannot be stored on {@link TabGroup}
     * as for undoable closures that object will already be gone before tab closures are finished.
     */
    private Set<Token> mHidingTabGroups = new HashSet<>();

    private int mCurrentGroupIndex = TabList.INVALID_TAB_INDEX;
    private Tab mAbsentSelectedTab;
    private boolean mShouldRecordUma = true;
    private boolean mIsResetting;
    private boolean mIsUndoing;

    public TabGroupModelFilter(TabModel tabModel) {
        super(tabModel);
    }

    /**
     * This method adds a {@link TabGroupModelFilterObserver} to be notified on {@link
     * TabGroupModelFilter} changes.
     *
     * @param observer The {@link TabGroupModelFilterObserver} to add.
     */
    public void addTabGroupObserver(TabGroupModelFilterObserver observer) {
        mGroupFilterObserver.addObserver(observer);
    }

    /**
     * This method removes a {@link TabGroupModelFilterObserver}.
     *
     * @param observer The {@link TabGroupModelFilterObserver} to remove.
     */
    public void removeTabGroupObserver(TabGroupModelFilterObserver observer) {
        mGroupFilterObserver.removeObserver(observer);
    }

    /** Returns the number of {@link TabGroup}s. */
    public int getTabGroupCount() {
        if (!isTabModelRestored() || mIsResetting) return -1;

        TabModel model = getTabModel();
        int count = 0;
        for (TabGroup group : mRootIdToGroupMap.values()) {
            if (isTabInTabGroup(model.getTabById(group.getLastShownTabId()))) {
                count++;
            }
        }
        return count;
    }

    /**
     * @return The position of the given {@link Tab} in its group.
     */
    public int getIndexOfTabInGroup(Tab tab) {
        TabGroup tabGroup = mRootIdToGroupMap.get(tab.getRootId());
        if (tabGroup == null) return TabGroup.INVALID_POSITION_IN_GROUP;
        return tabGroup.getPositionOfTab(tab);
    }

    /**
     * This method moves the TabGroup which contains the Tab with TabId {@code id} to {@code
     * newIndex} in TabModel.
     *
     * @param id The id of the tab whose related tabs are being moved.
     * @param newIndex The new index in TabModel that these tabs are being moved to.
     */
    public void moveRelatedTabs(int id, int newIndex) {
        List<Tab> tabs = getRelatedTabList(id);
        TabModel tabModel = getTabModel();
        newIndex = MathUtils.clamp(newIndex, 0, tabModel.getCount());
        int curIndex = TabModelUtils.getTabIndexById(tabModel, tabs.get(0).getId());

        if (curIndex == INVALID_TAB_INDEX || curIndex == newIndex) {
            return;
        }

        for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
            observer.willMoveTabGroup(curIndex, newIndex);
        }

        int offset = 0;
        for (Tab tab : tabs) {
            if (tabModel.indexOf(tab) == -1) {
                assert false : "Tried to close a tab from another model!";
                continue;
            }
            tabModel.moveTab(tab.getId(), newIndex >= curIndex ? newIndex : newIndex + offset++);
        }
    }

    /**
     * This method checks if an impending group merge action will result in a new group creation.
     *
     * @param tabsToMerge The list of tabs to be merged including all source and destination tabs.
     */
    public boolean willMergingCreateNewGroup(List<Tab> tabsToMerge) {
        for (Tab tab : tabsToMerge) {
            if (isTabInTabGroup(tab)) {
                return false;
            }
        }
        return true;
    }

    /** Creates a tab group containing a single tab. */
    public void createSingleTabGroup(int tabId, boolean notify) {
        createSingleTabGroup(getTabModel().getTabById(tabId), notify);
    }

    /** Creates a tab group containing a single tab. */
    public void createSingleTabGroup(Tab tab, boolean notify) {
        assert tab.getTabGroupId() == null;

        for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
            observer.willMergeTabToGroup(tab, tab.getRootId());
        }

        tab.setTabGroupId(Token.createRandom());

        // If this is a new tab group creation that will show a dialog, do not trigger a snackbar.
        if (ChromeFeatureList.sTabGroupParityAndroid.isEnabled()
                && !shouldSkipGroupCreationDialog(
                        shouldShowGroupCreationDialogViaSettingsSwitch())) {
            notify = false;
        }

        for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
            observer.didCreateNewGroup(tab, this);
        }

        for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
            observer.didMergeTabToGroup(tab, tab.getId());
        }

        if (notify) {
            int index = TabModelUtils.getTabIndexById(getTabModel(), tab.getId());
            for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
                observer.didCreateGroup(
                        Collections.singletonList(tab),
                        Collections.singletonList(index),
                        Collections.singletonList(tab.getRootId()),
                        Collections.singletonList(null),
                        null,
                        TabGroupColorUtils.INVALID_COLOR_ID,
                        /* destinationGroupTitleCollapsed= */ false);
            }
        }
    }

    /**
     * This method merges the source group that contains the {@code sourceTabId} to the destination
     * group that contains the {@code destinationTabId}. This method only operates if two groups are
     * in the same {@code TabModel}.
     *
     * @param sourceTabId The id of the {@link Tab} to get the source group.
     * @param destinationTabId The id of a {@link Tab} to get the destination group.
     */
    public void mergeTabsToGroup(int sourceTabId, int destinationTabId) {
        mergeTabsToGroup(sourceTabId, destinationTabId, false);
    }

    /**
     * This method merges the source group that contains the {@code sourceTabId} to the destination
     * group that contains the {@code destinationTabId}. This method only operates if two groups are
     * in the same {@code TabModel}.
     *
     * @param sourceTabId The id of the {@link Tab} to get the source group.
     * @param destinationTabId The id of a {@link Tab} to get the destination group.
     * @param skipUpdateTabModel True if updating the tab model will be handled elsewhere (e.g. by
     *     the tab strip).
     */
    public void mergeTabsToGroup(
            int sourceTabId, int destinationTabId, boolean skipUpdateTabModel) {
        Tab sourceTab = getTabModel().getTabById(sourceTabId);
        Tab destinationTab = getTabModel().getTabById(destinationTabId);

        assert sourceTab != null
                        && destinationTab != null
                        && sourceTab.isIncognito() == destinationTab.isIncognito()
                : "Attempting to merge groups from different model";

        List<Tab> tabsToMerge = getRelatedTabList(sourceTabId);
        int destinationIndexInTabModel = getTabModelDestinationIndex(destinationTab);

        if (!skipUpdateTabModel && needToUpdateTabModel(tabsToMerge, destinationIndexInTabModel)) {
            mergeListOfTabsToGroup(tabsToMerge, destinationTab, !skipUpdateTabModel);
        } else {
            int destinationRootId = destinationTab.getRootId();
            List<Tab> tabsIncludingDestination = new ArrayList<>();
            List<Integer> originalIndexes = new ArrayList<>();
            List<Integer> originalRootIds = new ArrayList<>();
            List<Token> originalTabGroupIds = new ArrayList<>();
            Set<Pair<Integer, Token>> removedGroups = new HashSet<>();
            String destinationGroupTitle = TabGroupTitleUtils.getTabGroupTitle(destinationRootId);
            int destinationGroupColorId = TabGroupColorUtils.INVALID_COLOR_ID;
            boolean willMergingCreateNewGroup =
                    willMergingCreateNewGroup(List.of(sourceTab, destinationTab));

            if (ChromeFeatureList.sTabGroupParityAndroid.isEnabled()) {
                destinationGroupColorId = TabGroupColorUtils.getTabGroupColor(destinationRootId);
            }

            final boolean destinationGroupTitleCollapsed;
            if (ChromeFeatureList.sTabStripGroupCollapse.isEnabled()) {
                destinationGroupTitleCollapsed = getTabGroupCollapsed(destinationRootId);
            } else {
                destinationGroupTitleCollapsed = false;
            }

            if (!skipUpdateTabModel) {
                tabsIncludingDestination.add(destinationTab);
                originalIndexes.add(
                        TabModelUtils.getTabIndexById(getTabModel(), destinationTab.getId()));
                originalRootIds.add(destinationRootId);
                originalTabGroupIds.add(destinationTab.getTabGroupId());
            }

            Token destinationTabGroupId =
                    getOrCreateTabGroupIdWithDefault(destinationTab, sourceTab.getTabGroupId());

            for (int i = 0; i < tabsToMerge.size(); i++) {
                Tab tab = tabsToMerge.get(i);
                for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
                    observer.willMergeTabToGroup(tab, destinationRootId);
                }

                // Skip unnecessary work of populating the lists if logic is skipped below.
                if (!skipUpdateTabModel) {
                    int index = TabModelUtils.getTabIndexById(getTabModel(), tab.getId());
                    assert index != TabModel.INVALID_TAB_INDEX;
                    tabsIncludingDestination.add(tab);
                    originalIndexes.add(index);
                    originalRootIds.add(tab.getRootId());
                    originalTabGroupIds.add(tab.getTabGroupId());
                }
                @Nullable Token tabGroupId = tab.getTabGroupId();
                if (tabGroupId != null) {
                    @Nullable
                    Token oldTabGroupId =
                            tabGroupId.equals(destinationTabGroupId) ? null : tabGroupId;
                    removedGroups.add(Pair.create(tab.getRootId(), oldTabGroupId));
                }

                setBothGroupIds(tab, destinationRootId, destinationTabGroupId);
            }
            resetFilterState();

            Tab lastMergedTab = tabsToMerge.get(tabsToMerge.size() - 1);
            TabGroup group = mRootIdToGroupMap.get(lastMergedTab.getRootId());
            for (int i = 0; i < tabsToMerge.size(); i++) {
                Tab tab = tabsToMerge.get(i);
                for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
                    observer.didMergeTabToGroup(tab, group.getLastShownTabId());
                }
            }

            // TODO(b/339480989): Resequence this so that we iterate over observers multiple times
            // and emit one event per loop to be consistent with other usages.
            for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
                if (willMergingCreateNewGroup) {
                    observer.didCreateNewGroup(destinationTab, this);

                    // If this is a new tab group creation that will show a dialog, do not trigger a
                    // snackbar.
                    if (ChromeFeatureList.sTabGroupParityAndroid.isEnabled()
                            && !shouldSkipGroupCreationDialog(
                                    shouldShowGroupCreationDialogViaSettingsSwitch())) {
                        continue;
                    }
                }

                // Since the undo group merge logic is unsupported when called from the tab strip,
                // skip notifying the UndoGroupSnackbarController observer which shows the snackbar.
                if (!skipUpdateTabModel) {
                    observer.didCreateGroup(
                            tabsIncludingDestination,
                            originalIndexes,
                            originalRootIds,
                            originalTabGroupIds,
                            destinationGroupTitle,
                            destinationGroupColorId,
                            destinationGroupTitleCollapsed);
                }

                for (Pair<Integer, Token> removedGroup : removedGroups) {
                    observer.didRemoveTabGroup(
                            removedGroup.first, removedGroup.second, DidRemoveTabGroupReason.MERGE);
                }
            }
        }
    }

    /**
     * This method appends a list of {@link Tab}s to the destination group that contains the {@code}
     * destinationTab. The {@link TabModel} ordering of the tabs in the given list is not preserved.
     * After calling this method, the {@link TabModel} ordering of these tabs would become the
     * ordering of {@code tabs}.
     *
     * @param tabs List of {@link Tab}s to be appended.
     * @param destinationTab The destination {@link Tab} to be append to.
     * @param notify Whether or not to notify observers about the merging events.
     */
    public void mergeListOfTabsToGroup(List<Tab> tabs, Tab destinationTab, boolean notify) {
        // Check whether the destination tab is in a tab group before getOrCreateTabGroupId so we
        // send the correct signal for whether a tab group was newly created.
        List<Tab> tabsToMerge = new ArrayList<>();
        tabsToMerge.addAll(tabs);
        tabsToMerge.add(destinationTab);
        boolean willMergingCreateNewGroup = willMergingCreateNewGroup(tabsToMerge);

        List<Tab> mergedTabs = new ArrayList<>();
        List<Integer> originalIndexes = new ArrayList<>();
        List<Integer> originalRootIds = new ArrayList<>();
        List<Token> originalTabGroupIds = new ArrayList<>();
        Set<Pair<Integer, Token>> removedGroups = new HashSet<>();

        // Include the destination tab in the undo list so that it gets back a null tab group ID
        // upon undo if it didn't have one.
        int destinationRootId = destinationTab.getRootId();
        int destinationTabIndex =
                TabModelUtils.getTabIndexById(getTabModel(), destinationTab.getId());
        assert destinationTabIndex != TabModel.INVALID_TAB_INDEX;
        mergedTabs.add(destinationTab);
        originalIndexes.add(destinationTabIndex);
        originalRootIds.add(destinationRootId);
        originalTabGroupIds.add(destinationTab.getTabGroupId());

        Token destinationTabGroupId;
        if (isTabInTabGroup(destinationTab)) {
            destinationTabGroupId = destinationTab.getTabGroupId();
        } else {
            Token mergedTabGroupId = null;
            for (Tab tab : tabs) {
                mergedTabGroupId = tab.getTabGroupId();
                if (mergedTabGroupId != null) break;
            }
            destinationTabGroupId =
                    getOrCreateTabGroupIdWithDefault(destinationTab, mergedTabGroupId);
        }
        int destinationIndexInTabModel = getTabModelDestinationIndex(destinationTab);
        String destinationGroupTitle = TabGroupTitleUtils.getTabGroupTitle(destinationRootId);
        int destinationGroupColorId = TabGroupColorUtils.INVALID_COLOR_ID;
        if (ChromeFeatureList.sTabGroupParityAndroid.isEnabled()) {
            destinationGroupColorId = TabGroupColorUtils.getTabGroupColor(destinationRootId);
        }

        final boolean destinationGroupTitleCollapsed;
        if (ChromeFeatureList.sTabStripGroupCollapse.isEnabled()) {
            destinationGroupTitleCollapsed = getTabGroupCollapsed(destinationRootId);
        } else {
            destinationGroupTitleCollapsed = false;
        }

        // Iterate through all tabs to set the proper new group creation status.
        for (int i = 0; i < tabs.size(); i++) {
            Tab tab = tabs.get(i);

            for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
                observer.willMergeTabToGroup(tab, destinationRootId);
            }

            if (tab.getId() == destinationTab.getId()) continue;

            int index = TabModelUtils.getTabIndexById(getTabModel(), tab.getId());
            assert index != TabModel.INVALID_TAB_INDEX;

            mergedTabs.add(tab);
            originalIndexes.add(index);
            originalRootIds.add(tab.getRootId());
            originalTabGroupIds.add(tab.getTabGroupId());
            @Nullable Token tabGroupId = tab.getTabGroupId();
            if (tabGroupId != null) {
                @Nullable
                Token oldTabGroupId = tabGroupId.equals(destinationTabGroupId) ? null : tabGroupId;
                removedGroups.add(Pair.create(tab.getRootId(), oldTabGroupId));
            }
            boolean isMergingBackward = index < destinationIndexInTabModel;

            setBothGroupIds(tab, destinationRootId, destinationTabGroupId);
            if (index == destinationIndexInTabModel || index + 1 == destinationIndexInTabModel) {
                // If the tab is not moved TabModelImpl will not invoke
                // TabModelObserver#didMoveTab() and update events will not be triggered. Call the
                // event manually.
                int destinationIndex =
                        MathUtils.clamp(
                                isMergingBackward
                                        ? destinationIndexInTabModel
                                        : destinationIndexInTabModel++,
                                0,
                                getTabModel().getCount());
                didMoveTab(tab, isMergingBackward ? destinationIndex - 1 : destinationIndex, index);
            } else {
                getTabModel()
                        .moveTab(
                                tab.getId(),
                                isMergingBackward
                                        ? destinationIndexInTabModel
                                        : destinationIndexInTabModel++);
            }
        }

        for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
            if (willMergingCreateNewGroup) {
                observer.didCreateNewGroup(destinationTab, this);
            }

            // If this is a new tab group creation that will show a dialog, do not trigger a
            // snackbar.
            boolean skipSnackbarForCreation =
                    willMergingCreateNewGroup
                            && ChromeFeatureList.sTabGroupParityAndroid.isEnabled()
                            && !shouldSkipGroupCreationDialog(
                                    shouldShowGroupCreationDialogViaSettingsSwitch());
            if (notify && !skipSnackbarForCreation) {
                observer.didCreateGroup(
                        mergedTabs,
                        originalIndexes,
                        originalRootIds,
                        originalTabGroupIds,
                        destinationGroupTitle,
                        destinationGroupColorId,
                        destinationGroupTitleCollapsed);
            }

            for (Pair<Integer, Token> removedGroup : removedGroups) {
                observer.didRemoveTabGroup(
                        removedGroup.first, removedGroup.second, DidRemoveTabGroupReason.MERGE);
            }
        }
    }

    /**
     * This method moves Tab with id as {@code sourceTabId} out of the group it belongs to in the
     * specified direction.
     *
     * @param sourceTabId The id of the {@link Tab} to get the source group.
     * @param trailing True if the tab should be placed after the tab group when removed. False if
     *     it should be placed before.
     */
    public void moveTabOutOfGroupInDirection(int sourceTabId, boolean trailing) {
        TabModel tabModel = getTabModel();
        Tab sourceTab = tabModel.getTabById(sourceTabId);
        int sourceIndex = tabModel.indexOf(sourceTab);
        int oldRootId = sourceTab.getRootId();
        TabGroup sourceTabGroup = mRootIdToGroupMap.get(oldRootId);

        if (sourceTabGroup.size() == 1) {
            int prevFilterIndex = mRootIdToGroupIndexMap.get(oldRootId);
            for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
                observer.willMoveTabOutOfGroup(sourceTab, oldRootId);
            }
            // When moving the last tab out of a tab group of size 1 we should decrement the number
            // of tab groups.
            if (sourceTab.getTabGroupId() != null) {
                for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
                    observer.didRemoveTabGroup(
                            oldRootId, sourceTab.getTabGroupId(), DidRemoveTabGroupReason.UNGROUP);
                }
            }
            sourceTab.setTabGroupId(null);
            for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
                observer.didMoveTabOutOfGroup(sourceTab, prevFilterIndex);
            }
            return;
        }

        int targetIndex;
        if (trailing) {
            Tab lastTabInSourceGroup = tabModel.getTabById(sourceTabGroup.getTabIdOfLastTab());
            targetIndex = tabModel.indexOf(lastTabInSourceGroup);
        } else {
            Tab firstTabInSourceGroup = tabModel.getTabById(sourceTabGroup.getTabIdOfFirstTab());
            targetIndex = tabModel.indexOf(firstTabInSourceGroup);
        }
        assert targetIndex != TabModel.INVALID_TAB_INDEX;

        boolean sourceTabIdWasRootId = sourceTab.getId() == oldRootId;
        int newRootId = oldRootId;
        if (sourceTabIdWasRootId) {
            // If moving tab's id is the root id of the group, find a new root id.
            if (sourceIndex != 0 && tabModel.getTabAt(sourceIndex - 1).getRootId() == newRootId) {
                newRootId = tabModel.getTabAt(sourceIndex - 1).getId();
            } else if (sourceIndex != tabModel.getCount() - 1
                    && tabModel.getTabAt(sourceIndex + 1).getRootId() == newRootId) {
                newRootId = tabModel.getTabAt(sourceIndex + 1).getId();
            }
        }
        assert newRootId != Tab.INVALID_TAB_ID;

        for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
            observer.willMoveTabOutOfGroup(sourceTab, newRootId);
        }

        TabStateAttributes tabStateAttributes = TabStateAttributes.from(sourceTab);
        tabStateAttributes.beginBatchEdit();
        sourceTab.setTabGroupId(null);
        if (sourceTabIdWasRootId) {
            for (int tabId : sourceTabGroup.getTabIdList()) {
                Tab tab = tabModel.getTabById(tabId);
                // One of these iterations will update the rootId of the moved tab. This seems
                // surprising/unnecessary, but is actually critical for #isMoveTabOutOfGroup to work
                // correctly.
                tab.setRootId(newRootId);
            }
            resetFilterState();
        }
        sourceTab.setRootId(sourceTab.getId());
        tabStateAttributes.endBatchEdit();

        if (sourceTabIdWasRootId) {
            // Must be done here instead of lower down, as the GTS currently does not listen to
            // metadata changes that will trigger from this, and will otherwise poll group data too
            // early.
            for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
                observer.didChangeGroupRootId(oldRootId, newRootId);
            }
        }

        // If moving tab is already in the target index in tab model, no move in tab model.
        if (sourceIndex == targetIndex) {
            resetFilterState();
            // Find the group that the tab was in that now has newRootId.
            int prevFilterIndex = mRootIdToGroupIndexMap.get(newRootId);
            for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
                observer.didMoveTabOutOfGroup(sourceTab, prevFilterIndex);
            }
        } else {
            // Plus one as offset because we are moving backwards in tab model.
            tabModel.moveTab(sourceTab.getId(), trailing ? targetIndex + 1 : targetIndex);
        }
    }

    /**
     * This method moves Tab with id as {@code sourceTabId} out of the group it belongs to.
     *
     * @param sourceTabId The id of the {@link Tab} to get the source group.
     */
    public void moveTabOutOfGroup(int sourceTabId) {
        moveTabOutOfGroupInDirection(sourceTabId, true);
    }

    private int getTabModelDestinationIndex(Tab destinationTab) {
        List<Integer> destinationGroupedTabIds =
                mRootIdToGroupMap.get(destinationTab.getRootId()).getTabIdList();
        int destinationTabIndex =
                TabModelUtils.getTabIndexById(
                        getTabModel(),
                        destinationGroupedTabIds.get(destinationGroupedTabIds.size() - 1));

        return destinationTabIndex + 1;
    }

    private boolean needToUpdateTabModel(List<Tab> tabsToMerge, int destinationIndexInTabModel) {
        assert tabsToMerge.size() > 0;

        int firstTabIndexInTabModel =
                TabModelUtils.getTabIndexById(getTabModel(), tabsToMerge.get(0).getId());
        return firstTabIndexInTabModel != destinationIndexInTabModel;
    }

    /**
     * This method undo the given grouped {@link Tab}.
     *
     * @param tab undo this grouped {@link Tab}.
     * @param originalIndex The tab index before grouped.
     * @param originalRootId The rootId before grouped.
     * @param originalTabGroupId The tabGroupId before grouped.
     */
    public void undoGroupedTab(
            Tab tab, int originalIndex, int originalRootId, @Nullable Token originalTabGroupId) {
        if (!tab.isInitialized()) return;

        int currentIndex = TabModelUtils.getTabIndexById(getTabModel(), tab.getId());
        assert currentIndex != TabModel.INVALID_TAB_INDEX;

        boolean isChangingRootIds = tab.getRootId() != originalRootId;
        boolean isChangingStableIds = !Objects.equals(tab.getTabGroupId(), originalTabGroupId);
        boolean isChangingGroups = isChangingRootIds || isChangingStableIds;
        boolean isChangingIndex = currentIndex != originalIndex;

        // We need to explicitly trigger `didMoveTabOutOfGroup` if the tab is changing groups so
        // that the old group is aware the tab is no longer in the group. The detection logic in
        // `didMoveTab` fails to correctly handle this case if the tab is moving between tab
        // groups because it lacks enough context, hence the `mIsUndoing` variable is used as a
        // bodge to communicate this. Then we signal `didMergeTabToGroup` separately afterwards
        // so long as the tab is actually becoming part of a tab group.
        mIsUndoing = isChangingGroups;

        setBothGroupIds(tab, originalRootId, originalTabGroupId);
        if (isChangingIndex) {
            if (currentIndex < originalIndex) originalIndex++;
            getTabModel().moveTab(tab.getId(), originalIndex);
        } else if (isChangingGroups) {
            didMoveTab(tab, originalIndex, currentIndex);
        }
        // Else we can ignore tabs that remain at the same index if they are not changing root IDs.

        mIsUndoing = false;

        // If undoing results in restoring a tab into a different group then notify observers it was
        // added.
        // TODO(b/b/339480464): Emit a matching willMergeTabToGroup somewhere upstream.
        if (isChangingGroups && isTabInTabGroup(tab)) {
            TabGroup group = mRootIdToGroupMap.get(originalRootId);
            // Last shown tab IDs are not preserved across an undo.
            for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
                observer.didMergeTabToGroup(tab, group.getLastShownTabId());
            }
        }
    }

    // TabModelFilter implementation.
    @NonNull
    @Override
    public List<Tab> getRelatedTabList(int id) {
        Tab tab = getTabModel().getTabById(id);
        if (tab == null) return super.getRelatedTabList(id);

        int rootId = tab.getRootId();
        TabGroup group = mRootIdToGroupMap.get(rootId);
        if (group == null) return super.getRelatedTabList(TabModel.INVALID_TAB_INDEX);
        return getRelatedTabList(group.getTabIdList());
    }

    @Override
    public List<Integer> getRelatedTabIds(int tabId) {
        Tab tab = getTabModel().getTabById(tabId);
        if (tab == null) return super.getRelatedTabIds(tabId);

        int rootId = tab.getRootId();
        TabGroup group = mRootIdToGroupMap.get(rootId);
        if (group == null) return super.getRelatedTabIds(TabModel.INVALID_TAB_INDEX);
        return Collections.unmodifiableList(group.getTabIdList());
    }

    /**
     * This method returns all tabs in a tab group with reference to {@code tabRootId} as root id.
     *
     * @param tabRootId The tab root id that is used to find the related group.
     * @return An unmodifiable list of {@link Tab} that relate with the given tab root id.
     */
    public List<Tab> getRelatedTabListForRootId(int tabRootId) {
        if (tabRootId == Tab.INVALID_TAB_ID) return super.getRelatedTabList(tabRootId);
        TabGroup group = mRootIdToGroupMap.get(tabRootId);
        if (group == null) return super.getRelatedTabList(TabModel.INVALID_TAB_INDEX);
        return getRelatedTabList(group.getTabIdList());
    }

    /**
     * This method returns the number of tabs in a tab group with reference to {@code tabRootId} as
     * root id.
     *
     * @param tabRootId The tab root id that is used to find the related group.
     * @return The number of related tabs.
     */
    public int getRelatedTabCountForRootId(int tabRootId) {
        if (tabRootId == Tab.INVALID_TAB_ID) return 1;
        TabGroup group = mRootIdToGroupMap.get(tabRootId);
        if (group == null) return 1;
        return group.size();
    }

    @Override
    public boolean isTabInTabGroup(Tab tab) {
        int rootId = tab.getRootId();
        TabGroup group = mRootIdToGroupMap.get(rootId);
        boolean isInGroup = group != null && group.contains(tab.getId());
        return isInGroup && tab.getTabGroupId() != null;
    }

    private List<Tab> getRelatedTabList(List<Integer> ids) {
        List<Tab> tabs = new ArrayList<>();
        for (Integer id : ids) {
            Tab tab = getTabModel().getTabById(id);
            // TODO(crbug.com/40245624): If this is called during a TabModelObserver observer
            // iterator it is possible a sequencing issue can occur where the tab is gone from the
            // TabModel, but still exists in the TabGroup. Avoid returning null by skipping the tab
            // if it doesn't exist in the TabModel.
            if (tab == null) continue;
            tabs.add(tab);
        }
        return Collections.unmodifiableList(tabs);
    }

    private boolean shouldUseParentIds(Tab tab) {
        return isTabModelRestored()
                && !mIsResetting
                && ((tab.getLaunchType() == TabLaunchType.FROM_TAB_GROUP_UI
                        || tab.getLaunchType()
                                == TabLaunchType.FROM_LONGPRESS_BACKGROUND_IN_GROUP));
    }

    private Tab getParentTab(Tab tab) {
        return getTabModel().getTabById(tab.getParentId());
    }

    @Override
    protected void addTab(Tab tab, boolean fromUndo) {
        if (tab.isIncognito() != isIncognito()) {
            throw new IllegalStateException("Attempting to open tab in the wrong model");
        }

        boolean willMergingCreateNewGroup = false;
        if (!fromUndo && shouldUseParentIds(tab)) {
            Tab parentTab = getParentTab(tab);
            if (parentTab != null) {
                Token oldTabGroupId = parentTab.getTabGroupId();
                Token newTabGroupId = getOrCreateTabGroupId(parentTab);
                if (!Objects.equals(oldTabGroupId, newTabGroupId)) {
                    willMergingCreateNewGroup = true;
                }
                for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
                    observer.willMergeTabToGroup(tab, parentTab.getRootId());
                }
                tab.setRootId(parentTab.getRootId());
                tab.setTabGroupId(newTabGroupId);
            }
        }

        int rootId = tab.getRootId();
        if (mRootIdToGroupMap.containsKey(rootId)) {
            mRootIdToGroupMap.get(rootId).addTab(tab.getId(), getTabModel());

            if (willMergingCreateNewGroup) {
                // TODO(crbug.com/40173284): Update UMA for Context menu creation.
                if (tab.getLaunchType() == TabLaunchType.FROM_LONGPRESS_BACKGROUND_IN_GROUP) {
                    if (mShouldRecordUma) {
                        RecordUserAction.record("TabGroup.Created.OpenInNewTab");
                    }

                    // When creating a tab group with the context menu longpress, this action runs.
                    for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
                        observer.didCreateNewGroup(tab, this);
                    }
                }
            }
        } else {
            TabGroup tabGroup = new TabGroup(tab.getRootId());
            tabGroup.addTab(tab.getId(), getTabModel());
            mRootIdToGroupMap.put(rootId, tabGroup);
            if (mIsResetting || getTabModel().indexOf(tab) == getTabModel().getCount() - 1) {
                // During a reset tabs are iterated over in TabModel order so it is safe to assume
                // group ordering matches tab ordering. Same is true if the new tab is the last tab
                // in the model.
                mRootIdToGroupIndexMap.put(rootId, mRootIdToGroupIndexMap.size());
            } else {
                // When adding a new tab that isn't at the end of the TabModel the new group's
                // index should be based on tab model order. This will offset all other groups
                // resulting in the index map needing to be regenerated.
                resetRootIdToGroupIndexMap();
            }
        }

        if (mAbsentSelectedTab != null) {
            Tab absentSelectedTab = mAbsentSelectedTab;
            mAbsentSelectedTab = null;
            selectTab(absentSelectedTab);
        }
    }

    private void resetRootIdToGroupIndexMap() {
        mRootIdToGroupIndexMap.clear();
        TabModel tabModel = getTabModel();
        for (int i = 0; i < tabModel.getCount(); i++) {
            Tab tab = tabModel.getTabAt(i);
            int rootId = tab.getRootId();
            if (!mRootIdToGroupIndexMap.containsKey(rootId)) {
                mRootIdToGroupIndexMap.put(rootId, mRootIdToGroupIndexMap.size());
            }
        }
    }

    @Override
    protected void closeTab(Tab tab) {
        int rootId = tab.getRootId();
        if (tab.isIncognito() != isIncognito()
                || mRootIdToGroupMap.get(rootId) == null
                || !mRootIdToGroupMap.get(rootId).contains(tab.getId())) {
            throw new IllegalStateException("Attempting to close tab in the wrong model");
        }

        TabGroup group = mRootIdToGroupMap.get(rootId);
        group.removeTab(tab.getId());

        // If the removed tab's id was the root id, we need to select a new root id.
        if (tab.getRootId() == tab.getId()) {
            int nextRootId = group.getLastShownTabId();
            if (nextRootId != INVALID_TAB_INDEX && nextRootId != rootId) {
                // Use the comprehensive model to ensure undoable closures that happened before our
                // current closure also get updated, so they'll restore into the same group.
                TabList comprehensiveModel = getTabModel().getComprehensiveModel();
                int comprehensiveCount = comprehensiveModel.getCount();
                for (int i = 0; i < comprehensiveCount; ++i) {
                    Tab comprehensiveTab = comprehensiveModel.getTabAt(i);
                    if (comprehensiveTab.getRootId() == rootId) {
                        comprehensiveTab.setRootId(nextRootId);
                    }
                }
                mRootIdToGroupIndexMap.put(nextRootId, mRootIdToGroupIndexMap.remove(rootId));
                mRootIdToGroupMap.put(nextRootId, mRootIdToGroupMap.remove(rootId));
                for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
                    observer.didChangeGroupRootId(rootId, nextRootId);
                }
            }
        }

        boolean didRemoveGroup = false;
        if (group.size() == 0) {
            didRemoveGroup = true;
        }
        if (group.size() == 0) {
            updateRootIdToGroupIndexMapAfterGroupClosed(rootId);
            mRootIdToGroupIndexMap.remove(rootId);
            mRootIdToGroupMap.remove(rootId);
        }

        if (didRemoveGroup) {
            for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
                observer.didRemoveTabGroup(
                        rootId, tab.getTabGroupId(), DidRemoveTabGroupReason.CLOSE);
            }
        }
    }

    private void updateRootIdToGroupIndexMapAfterGroupClosed(int rootId) {
        int indexToRemove = mRootIdToGroupIndexMap.get(rootId);
        Set<Integer> rootIdSet = mRootIdToGroupIndexMap.keySet();
        for (Integer rootIdKey : rootIdSet) {
            int groupIndex = mRootIdToGroupIndexMap.get(rootIdKey);
            if (groupIndex > indexToRemove) {
                mRootIdToGroupIndexMap.put(rootIdKey, groupIndex - 1);
            }
        }
    }

    @Override
    protected void selectTab(Tab tab) {
        assert mAbsentSelectedTab == null;
        int rootId = tab.getRootId();
        if (mRootIdToGroupMap.get(rootId) == null) {
            mAbsentSelectedTab = tab;
        } else {
            mRootIdToGroupMap.get(rootId).setLastShownTabId(tab.getId());
            mCurrentGroupIndex = mRootIdToGroupIndexMap.get(rootId);
        }
    }

    @Override
    protected void reorder() {
        mRootIdToGroupIndexMap.clear();
        TabModel tabModel = getTabModel();
        for (int i = 0; i < tabModel.getCount(); i++) {
            Tab tab = tabModel.getTabAt(i);
            int rootId = tab.getRootId();
            if (!mRootIdToGroupIndexMap.containsKey(rootId)) {
                mRootIdToGroupIndexMap.put(rootId, mRootIdToGroupIndexMap.size());
            }
            mRootIdToGroupMap.get(rootId).moveToEndInGroup(tab.getId());
        }

        if (tabModel.index() == TabModel.INVALID_TAB_INDEX) {
            mCurrentGroupIndex = TabModel.INVALID_TAB_INDEX;
        } else {
            selectTab(tabModel.getTabAt(tabModel.index()));
        }

        assert mRootIdToGroupIndexMap.size() == mRootIdToGroupMap.size();
    }

    @Override
    protected void resetFilterStateInternal() {
        mRootIdToGroupIndexMap.clear();
        mRootIdToGroupMap.clear();
    }

    @Override
    protected void removeTab(Tab tab) {
        closeTab(tab);
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
    @Override
    public void resetFilterState() {
        mShouldRecordUma = false;
        mIsResetting = true;
        Map<Integer, Integer> rootIdToGroupLastShownTabId = new HashMap<>();
        for (int rootId : mRootIdToGroupMap.keySet()) {
            rootIdToGroupLastShownTabId.put(
                    rootId, mRootIdToGroupMap.get(rootId).getLastShownTabId());
        }

        super.resetFilterState();

        // Restore previous last shown tab ids after resetting filter state.
        for (int rootId : mRootIdToGroupMap.keySet()) {
            // This happens when group with new rootId is formed after resetting filter state, i.e.
            // when ungroup happens. Restoring last shown id of newly generated group is ignored.
            if (!rootIdToGroupLastShownTabId.containsKey(rootId)) continue;
            int lastShownId = rootIdToGroupLastShownTabId.get(rootId);
            // This happens during continuous resetFilterState() calls caused by merging multiple
            // tabs. Ignore the calls where the merge is not completed but the last shown tab has
            // already been merged to new group.
            if (!mRootIdToGroupMap.get(rootId).contains(lastShownId)) continue;
            mRootIdToGroupMap.get(rootId).setLastShownTabId(lastShownId);
        }
        TabModel tabModel = getTabModel();
        if (tabModel.index() == TabModel.INVALID_TAB_INDEX) {
            mCurrentGroupIndex = TabModel.INVALID_TAB_INDEX;
        } else {
            selectTab(tabModel.getTabAt(tabModel.index()));
        }
        mShouldRecordUma = true;
        mIsResetting = false;
    }

    @Override
    protected boolean shouldNotifyObserversOnSetIndex() {
        return mAbsentSelectedTab == null;
    }

    @Override
    @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
    public void markTabStateInitialized() {
        super.markTabStateInitialized();
        boolean correctOrder = isOrderValid();
        RecordHistogram.recordBooleanHistogram("Tabs.Tasks.OrderValidOnStartup", correctOrder);

        int fixedRootIdCount = fixRootIds();
        RecordHistogram.recordCount1000Histogram(
                "TabGroups.NumberOfRootIdsFixed", fixedRootIdCount);

        // There's an assertion being hit where archived/restored tabs are being counted as part of
        // a tab group. See crbug.com/356330532 for more details.
        resetFilterState();
        addTabGroupIdsForAllTabGroups();
    }

    @VisibleForTesting
    void addTabGroupIdsForAllTabGroups() {
        TabModel model = getTabModel();
        @Nullable Token lastTabGroupId = null;
        int lastRootId = TabGroup.INVALID_ROOT_ID;

        // Assume all tab groups are contiguous.
        for (int i = 0; i < model.getCount(); i++) {
            Tab tab = model.getTabAt(i);
            int rootId = tab.getRootId();
            Token tabGroupId = tab.getTabGroupId();
            TabGroup group = mRootIdToGroupMap.get(rootId);

            if (rootId == lastRootId) {
                // The tab is part of previous tab's group it should get the tab group ID from it.
                assert lastTabGroupId != null
                        : String.format(
                                Locale.getDefault(),
                                "Expected tab group id for matching root id=%d, tab index=%d, tab"
                                        + " count=%d",
                                rootId,
                                i,
                                model.getCount());
                tabGroupId = lastTabGroupId;
                tab.setTabGroupId(tabGroupId);
            } else if (tabGroupId == null && group.size() > 1) {
                // The tab does not have a tab group ID, but is part of a > 1 size group. Assign it
                // a new tab group ID.
                tabGroupId = Token.createRandom();
                tab.setTabGroupId(tabGroupId);
            }
            // Remaining cases:
            // * A tab group of size 1 is not migrated. It either has a null ID or tab group ID.
            // * A tab group > size 1 that already has a tab group ID should not change IDs.

            lastRootId = rootId;
            lastTabGroupId = tabGroupId;
        }
    }

    /**
     * Checks whether the order of the tabs in the {@link TabModel} respects the invariant of {@link
     * TabGroupModelFilter} that tabs within a group must be contiguous.
     *
     * <p>Valid order:
     *
     * <ul>
     *   <li>Tab 1, Group A
     *   <li>Tab 2, Group A
     *   <li>Tab 3, Group B
     * </ul>
     *
     * <p>Invalid order:
     *
     * <ul>
     *   <li>Tab 1, Group A
     *   <li>Tab 2, Group B
     *   <li>Tab 3, Group A
     * </ul>
     */
    @VisibleForTesting
    public boolean isOrderValid() {
        HashSet<Integer> processedRootIds = new HashSet<>();
        int lastRootId = Tab.INVALID_TAB_ID;
        // Iterate over tab model and check that all tabs with the same rootId are next to one
        // another. If at any time a rootId is repeated without the prior tab having the same rootId
        // then the invariant is violated.
        for (int i = 0; i < getTabModel().getCount(); i++) {
            int rootId = getTabModel().getTabAt(i).getRootId();
            if (rootId == lastRootId) continue;

            if (processedRootIds.contains(rootId)) return false;

            processedRootIds.add(lastRootId);
            lastRootId = rootId;
        }
        return true;
    }

    /**
     * Fixes root identifiers to guarantee a group's root identifier is the tab id of one of its
     * tabs.
     *
     * @return the number of groups that had to be fixed.
     */
    @VisibleForTesting
    public int fixRootIds() {
        int fixedRootIdCount = 0;
        TabModel model = getTabModel();

        // Cannot simply move metadata when we change root ids. It's possible what used to be one
        // group has become two groups, in which case we try to copy the metadata into both ids.
        // It's possible that we shift around multiple chains of root ids, even in a circle. To get
        // around these edge cases, hold all metadata in memory, and only start writing metadata
        // after reading everything. Then do a second pass to remove any old metadata that no longer
        // has matching root ids.
        // Note: It was fairly arbitrarily chosen that when we split a group, we duplicate metadata.
        // If we have a reason to change this behavior, we can.
        Map<Integer, Integer> oldToNewRootIds = new HashMap<>();
        Map<Integer, TabGroupMetadata> oldRootIdsToMetadata = new HashMap<>();

        for (Map.Entry<Integer, TabGroup> entry : mRootIdToGroupMap.entrySet()) {
            int rootId = entry.getKey();
            TabGroup group = entry.getValue();
            if (group.contains(rootId)) continue;

            int fixedRootId = group.getTabIdOfFirstTab();
            for (int tabId : group.getTabIdList()) {
                Tab tab = model.getTabById(tabId);
                tab.setRootId(fixedRootId);
            }

            oldRootIdsToMetadata.put(rootId, new TabGroupMetadata(rootId));
            oldToNewRootIds.put(rootId, fixedRootId);
            fixedRootIdCount++;
        }

        if (fixedRootIdCount != 0) {
            for (Entry<Integer, Integer> oldToNew : oldToNewRootIds.entrySet()) {
                int oldRootId = oldToNew.getKey();
                int newRootId = oldToNew.getValue();
                TabGroupMetadata metadata = oldRootIdsToMetadata.get(oldRootId);
                if (metadata.title != null) setTabGroupTitle(newRootId, metadata.title);
                if (metadata.color != TabGroupColorUtils.INVALID_COLOR_ID) {
                    setTabGroupColor(newRootId, metadata.color);
                }
                if (ChromeFeatureList.sTabStripGroupCollapse.isEnabled()) {
                    if (metadata.isCollapsed) setTabGroupCollapsed(newRootId, true);
                }
            }

            resetFilterState();

            for (Entry<Integer, Integer> oldToNew : oldToNewRootIds.entrySet()) {
                int oldRootId = oldToNew.getKey();
                if (!mRootIdToGroupMap.containsKey(oldRootId)) {
                    TabGroupTitleUtils.deleteTabGroupTitle(oldRootId);
                    TabGroupColorUtils.deleteTabGroupColor(oldRootId);
                    if (ChromeFeatureList.sTabStripGroupCollapse.isEnabled()) {
                        deleteTabGroupCollapsed(oldRootId);
                    }
                }
            }
        }
        return fixedRootIdCount;
    }

    @Override
    public int getValidPosition(Tab tab, int proposedPosition) {
        int rootId = Tab.INVALID_TAB_ID;
        if (shouldUseParentIds(tab)) {
            Tab parentTab = getParentTab(tab);
            if (parentTab != null) {
                rootId = parentTab.getRootId();
            }
        } else {
            rootId = tab.getRootId();
        }
        int newPosition = proposedPosition;
        // If the tab is not in the model and won't be part of a group ensure it is positioned
        // outside any other groups.
        if (rootId == Tab.INVALID_TAB_ID || !mRootIdToGroupMap.containsKey(rootId)) {
            newPosition = getValidPositionOfUngroupedTab(proposedPosition);
        } else {
            // The tab is or will be part of a group. Ensure it will be positioned with other
            // members of its group.
            TabGroup group = mRootIdToGroupMap.get(rootId);
            newPosition = getValidPositionOfGroupedTab(group, proposedPosition);
        }
        RecordHistogram.recordBooleanHistogram(
                "Tabs.Tasks.TabAddedWithValidProposedPosition", newPosition == proposedPosition);
        return newPosition;
    }

    /**
     * Gets a valid position of a tab that will be part of a group. If proposedPosition is within
     * the range of the group's location it is used. Otherwise the tab is placed at the end of the
     * group.
     * @param group The group the tab belongs with.
     * @param proposedPosition The requested position of the tab.
     */
    private int getValidPositionOfGroupedTab(TabGroup group, int proposedPosition) {
        List<Integer> ids = new ArrayList<>();
        ids.addAll(group.getTabIdList());
        assert ids.size() >= 1;

        int firstGroupIndex = TabModelUtils.getTabIndexById(getTabModel(), ids.get(0));
        int defaultDestinationIndex = firstGroupIndex + ids.size();
        if (proposedPosition < firstGroupIndex) {
            return firstGroupIndex;
        }
        if (proposedPosition < defaultDestinationIndex) {
            return proposedPosition;
        }
        return defaultDestinationIndex;
    }

    /**
     * Gets a valid position of a tab that is not part of a group. If proposedPosition is not inside
     * any existing group it is used. Otherwise the tab is placed after the group it would have been
     * placed inside of.
     * @param proposedPosition The requested position of the tab.
     */
    private int getValidPositionOfUngroupedTab(int proposedPosition) {
        final int tabCount = getTabModel().getCount();
        if (proposedPosition <= 0 || proposedPosition >= tabCount) {
            // Downstream should clamp this value appropriately. Adding at the ends will never be a
            // problem.
            return proposedPosition;
        }

        int moveToIndex = proposedPosition;
        // Find a spot where the tabs on either side of the new tab are not part of the same group.
        while (moveToIndex != tabCount
                && getTabModel().getTabAt(moveToIndex - 1).getRootId()
                        == getTabModel().getTabAt(moveToIndex).getRootId()) {
            moveToIndex++;
        }
        return moveToIndex;
    }

    @Override
    public void didMoveTab(Tab tab, int newIndex, int curIndex) {
        // Ignore didMoveTab calls in tab restoring stage.
        if (!isTabModelRestored()) return;
        // Need to cache the flags before resetting the internal data map.
        boolean isMergeTabToGroup = isMergeTabToGroup(tab);
        boolean isMoveTabOutOfGroup = isMoveTabOutOfGroup(tab) || mIsUndoing;
        int rootIdBeforeMove = getRootIdBeforeMove(tab, isMergeTabToGroup || isMoveTabOutOfGroup);
        assert rootIdBeforeMove != TabGroup.INVALID_ROOT_ID;

        if (isMoveTabOutOfGroup) {
            resetFilterState();

            int prevFilterIndex = mRootIdToGroupIndexMap.get(rootIdBeforeMove);
            for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
                observer.didMoveTabOutOfGroup(tab, prevFilterIndex);
            }
        } else if (isMergeTabToGroup) {
            resetFilterState();

            TabGroup group = mRootIdToGroupMap.get(tab.getRootId());
            for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
                observer.didMergeTabToGroup(tab, group.getLastShownTabId());
            }
        } else {
            reorder();
            if (isMoveWithinGroup(tab, curIndex, newIndex)) {
                for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
                    observer.didMoveWithinGroup(tab, curIndex, newIndex);
                }
            } else {
                if (!hasFinishedMovingGroup(tab, newIndex)) return;
                for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
                    observer.didMoveTabGroup(tab, curIndex, newIndex);
                }
            }
        }

        super.didMoveTab(tab, newIndex, curIndex);
    }

    /** Get all tab group root ids that are associated with tab groups. */
    public Set<Integer> getAllTabGroupRootIds() {
        Set<Integer> uniqueTabGroupRootIds = new ArraySet<>();
        forEachTabInTabGroup((tab) -> uniqueTabGroupRootIds.add(tab.getRootId()));
        return uniqueTabGroupRootIds;
    }

    /** Get all tab group IDs that are associated with tab groups. */
    public Set<Token> getAllTabGroupIds() {
        Set<Token> uniqueTabGroupIds = new ArraySet<>();
        forEachTabInTabGroup((tab) -> uniqueTabGroupIds.add(tab.getTabGroupId()));
        return uniqueTabGroupIds;
    }

    private void forEachTabInTabGroup(Callback<Tab> callback) {
        TabList tabList = getTabModel();
        for (int i = 0; i < tabList.getCount(); i++) {
            Tab tab = tabList.getTabAt(i);
            if (isTabInTabGroup(tab)) {
                callback.onResult(tab);
            }
        }
    }

    private boolean isMoveTabOutOfGroup(Tab movedTab) {
        return !mRootIdToGroupMap.containsKey(movedTab.getRootId());
    }

    private boolean isMergeTabToGroup(Tab tab) {
        int rootId = tab.getRootId();
        if (!mRootIdToGroupMap.containsKey(rootId)) return false;
        TabGroup tabGroup = mRootIdToGroupMap.get(rootId);
        return !tabGroup.contains(tab.getId());
    }

    private int getRootIdBeforeMove(Tab tabToMove, boolean isMoveToDifferentGroup) {
        if (!isMoveToDifferentGroup) return tabToMove.getRootId();

        Set<Integer> rootIdSet = mRootIdToGroupMap.keySet();
        for (Integer rootIdKey : rootIdSet) {
            if (mRootIdToGroupMap.get(rootIdKey).contains(tabToMove.getId())) {
                return rootIdKey;
            }
        }

        return TabGroup.INVALID_ROOT_ID;
    }

    private boolean isMoveWithinGroup(
            Tab movedTab, int oldIndexInTabModel, int newIndexInTabModel) {
        int startIndex = Math.min(oldIndexInTabModel, newIndexInTabModel);
        int endIndex = Math.max(oldIndexInTabModel, newIndexInTabModel);
        for (int i = startIndex; i <= endIndex; i++) {
            if (getTabModel().getTabAt(i).getRootId() != movedTab.getRootId()) return false;
        }
        return true;
    }

    private boolean hasFinishedMovingGroup(Tab movedTab, int newIndexInTabModel) {
        TabGroup tabGroup = mRootIdToGroupMap.get(movedTab.getRootId());
        int offsetIndex = newIndexInTabModel - tabGroup.size() + 1;
        if (offsetIndex < 0) return false;

        for (int i = newIndexInTabModel; i >= offsetIndex; i--) {
            if (getTabModel().getTabAt(i).getRootId() != movedTab.getRootId()) return false;
        }
        return true;
    }

    // TabList implementation.
    @Override
    public boolean isIncognito() {
        return getTabModel().isIncognito();
    }

    @Override
    public boolean isOffTheRecord() {
        return getTabModel().isOffTheRecord();
    }

    @Override
    public boolean isIncognitoBranded() {
        return getTabModel().isIncognitoBranded();
    }

    @Override
    public int index() {
        return mCurrentGroupIndex;
    }

    /**
     * @return count of @{@link TabGroup}s in model.
     */
    @Override
    public int getCount() {
        return mRootIdToGroupMap.size();
    }

    @Override
    public Tab getTabAt(int index) {
        if (index < 0 || index >= getCount()) return null;
        int rootId = Tab.INVALID_TAB_ID;
        Set<Integer> rootIdSet = mRootIdToGroupIndexMap.keySet();
        for (Integer rootIdKey : rootIdSet) {
            if (mRootIdToGroupIndexMap.get(rootIdKey) == index) {
                rootId = rootIdKey;
                break;
            }
        }
        if (rootId == Tab.INVALID_TAB_ID) return null;

        return getTabModel().getTabById(mRootIdToGroupMap.get(rootId).getLastShownTabId());
    }

    @Override
    public int indexOf(Tab tab) {
        if (tab == null
                || tab.isIncognito() != isIncognito()
                || getTabModel().indexOf(tab) == TabList.INVALID_TAB_INDEX) {
            return TabList.INVALID_TAB_INDEX;
        }

        int rootId = tab.getRootId();
        if (!mRootIdToGroupIndexMap.containsKey(rootId)) return TabList.INVALID_TAB_INDEX;
        return mRootIdToGroupIndexMap.get(rootId);
    }

    /**
     * @param rootId The rootId of the group to lookup.
     * @return the last shown tab in that group or Tab.INVALID_TAB_ID otherwise.
     */
    public int getGroupLastShownTabId(int rootId) {
        TabGroup group = mRootIdToGroupMap.get(rootId);
        return group == null ? Tab.INVALID_TAB_ID : group.getLastShownTabId();
    }

    /**
     * @param rootId The rootId of the group to lookup.
     * @return the last shown tab in that group or null otherwise.
     */
    public @Nullable Tab getGroupLastShownTab(int rootId) {
        TabGroup group = mRootIdToGroupMap.get(rootId);
        if (group == null) return null;

        int lastShownId = group.getLastShownTabId();
        if (lastShownId == Tab.INVALID_TAB_ID) return null;

        return getTabModel().getTabById(lastShownId);
    }

    /**
     * @param rootId The root identifier of the tab group.
     * @return Whether the given rootId has any tab group associated with it.
     */
    public boolean tabGroupExistsForRootId(int rootId) {
        TabGroup group = mRootIdToGroupMap.get(rootId);
        return group != null;
    }

    /** Returns the current title of the tab group. */
    public String getTabGroupTitle(int rootId) {
        return TabGroupTitleUtils.getTabGroupTitle(rootId);
    }

    /** Stores the given title for the tab group. */
    public void setTabGroupTitle(int rootId, String title) {
        TabGroupTitleUtils.storeTabGroupTitle(rootId, title);
        for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
            observer.didChangeTabGroupTitle(rootId, title);
        }
    }

    /** Deletes the stored title for the tab group, defaulting it back to "N tabs." */
    public void deleteTabGroupTitle(int rootId) {
        TabGroupTitleUtils.deleteTabGroupTitle(rootId);
        for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
            observer.didChangeTabGroupTitle(rootId, null);
        }
    }

    /**
     * This method fetches tab group colors id for the specified tab group. It will be a {@link
     * TabGroupColorId} if found, otherwise a {@link TabGroupTitleUtils.INVALID_COLOR_ID} if there
     * is no color entry for the group.
     */
    public int getTabGroupColor(int rootId) {
        return TabGroupColorUtils.getTabGroupColor(rootId);
    }

    /**
     * This method fetches tab group colors for the related tab group root ID. If the color does not
     * exist, then GREY will be returned. This method is intended to be used by UI surfaces that
     * want to show a color, and they need the color returned to be valid.
     *
     * @param rootId The tab root ID whose related tab group color will be fetched if found.
     * @return The color that should be used for this group.
     */
    public @TabGroupColorId int getTabGroupColorWithFallback(int rootId) {
        assert rootId != Tab.INVALID_TAB_ID;
        int color = getTabGroupColor(rootId);
        return color == TabGroupColorUtils.INVALID_COLOR_ID ? TabGroupColorId.GREY : color;
    }

    /** Stores the given color for the tab group. */
    public void setTabGroupColor(int rootId, @TabGroupColorId int color) {
        TabGroupColorUtils.storeTabGroupColor(rootId, color);
        for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
            observer.didChangeTabGroupColor(rootId, color);
        }
    }

    /** Deletes the color that was recorded for the group. */
    public void deleteTabGroupColor(int rootId) {
        TabGroupColorUtils.deleteTabGroupColor(rootId);
        for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
            observer.didChangeTabGroupColor(rootId, TabGroupColorId.GREY);
        }
    }

    /** Sets whether the tab group is expanded or collapsed. */
    public void setTabGroupCollapsed(int rootId, boolean isCollapsed) {
        TabGroupCollapsedUtils.storeTabGroupCollapsed(rootId, isCollapsed);
        for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
            observer.didChangeTabGroupCollapsed(rootId, isCollapsed);
        }
    }

    /** Deletes the record that the group is collapsed, setting it to expanded. */
    public void deleteTabGroupCollapsed(int rootId) {
        TabGroupCollapsedUtils.deleteTabGroupCollapsed(rootId);
        for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
            observer.didChangeTabGroupCollapsed(rootId, false);
        }
    }

    /** Returns whether the tab group is expanded or collapsed. */
    public boolean getTabGroupCollapsed(int rootId) {
        return TabGroupCollapsedUtils.getTabGroupCollapsed(rootId);
    }

    /** Returns the sync ID associated with the tab group. */
    public String getTabGroupSyncId(int rootId) {
        return TabGroupSyncIdUtils.getTabGroupSyncId(rootId);
    }

    /** Stores the sync ID associated with the tab group. */
    public void setTabGroupSyncId(int rootId, String syncId) {
        TabGroupSyncIdUtils.putTabGroupSyncId(rootId, syncId);
    }

    /**
     * Given a tab group's stable ID, finds out the root ID, or {@link Tab.INVALID_TAB_ID} if the
     * tab group doesn't exist in the model.
     *
     * @param stableId The stable ID of the tab group.
     * @return The root ID of the tab group or {@link Tab.INVALID_TAB_ID} if the group isn't found
     *     in the tab model.
     */
    public int getRootIdFromStableId(@NonNull Token stableId) {
        for (int i = 0; i < getTabModel().getCount(); i++) {
            Tab tab = getTabModel().getTabAt(i);
            if (stableId.equals(tab.getTabGroupId())) return tab.getRootId();
        }
        return Tab.INVALID_TAB_ID;
    }

    /**
     * Given a tab group's root ID, finds out the stable ID, or null if the tab group doesn't exist
     * in the model.
     *
     * @param rootId The root ID of the tab group.
     * @return The stable ID of the tab group or null if the group isn't found in the tab model.
     */
    public @Nullable Token getStableIdFromRootId(int rootId) {
        TabGroup tabGroup = mRootIdToGroupMap.get(rootId);
        if (tabGroup == null) return null;

        Tab tab = getTabModel().getTabById(tabGroup.getLastShownTabId());
        if (tab == null) return null;

        return tab.getTabGroupId();
    }

    /**
     * A wrapper around {@link TabModel#closeTabs} that sets hiding state for tab groups correctly.
     *
     * @param tabClosureParams The params to use when closing tabs.
     */
    public boolean closeTabs(TabClosureParams tabClosureParams) {
        TabModel tabModel = getTabModel();
        if (tabClosureParams.hideTabGroups && canHideTabGroups()) {
            if (tabClosureParams.isAllTabs) {
                for (Token token : getAllTabGroupIds()) {
                    setTabGroupHiding(token);
                }
            } else {
                Set<Integer> closingTabIds =
                        tabClosureParams.tabs.stream().map(Tab::getId).collect(Collectors.toSet());
                for (int rootId : getAllTabGroupRootIds()) {
                    TabGroup group = mRootIdToGroupMap.get(rootId);
                    if (group == null) continue;

                    if (closingTabIds.containsAll(group.getTabIdList())) {
                        Tab tab = tabModel.getTabById(group.getLastShownTabId());
                        setTabGroupHiding(tab.getTabGroupId());
                    }
                }
            }
        }
        return tabModel.closeTabs(tabClosureParams);
    }

    /** Returns whether the tab group is being hidden. */
    public boolean isTabGroupHiding(@Nullable Token tabGroupId) {
        if (tabGroupId == null) return false;

        return mHidingTabGroups.contains(tabGroupId);
    }

    private boolean canHideTabGroups() {
        Profile profile = getTabModel().getProfile();
        if (profile == null || !profile.isNativeInitialized()) return false;

        return !isIncognito() && TabGroupSyncFeatures.isTabGroupSyncEnabled(profile);
    }

    /** Sets that the tab group is hiding rather than being deleted. */
    private void setTabGroupHiding(@Nullable Token tabGroupId) {
        if (tabGroupId == null) return;

        mHidingTabGroups.add(tabGroupId);
    }

    @Override
    public void tabClosureUndone(Tab tab) {
        super.tabClosureUndone(tab);
        @Nullable Token tabGroupId = tab.getTabGroupId();
        if (tabGroupId != null) {
            mHidingTabGroups.remove(tabGroupId);
        }
    }

    @Override
    public void onFinishingMultipleTabClosure(List<Tab> tabs, boolean canRestore) {
        super.onFinishingMultipleTabClosure(tabs, canRestore);
        Set<Token> processedTabGroups = new HashSet<>();
        LazyOneshotSupplier<Set<Token>> tabGroupIdsInComprehensiveModel =
                getLazyAllTabGroupIdsInComprehensiveModel(tabs);
        for (Tab tab : tabs) {
            @Nullable Token tabGroupId = tab.getTabGroupId();
            if (tabGroupId == null) continue;

            boolean alreadyProcessed = !processedTabGroups.add(tabGroupId);
            if (alreadyProcessed) continue;

            // If the tab group still exists in the comprehensive tab model then we shouldn't signal
            // that it is finished closing.
            if (tabGroupIdsInComprehensiveModel.get().contains(tabGroupId)) continue;

            boolean wasHiding = mHidingTabGroups.remove(tabGroupId);
            for (TabGroupModelFilterObserver observer : mGroupFilterObserver) {
                observer.committedTabGroupClosure(tabGroupId, wasHiding);
            }
        }
    }

    /**
     * Returns a lazy oneshot supplier that generates all the tab group IDs including those pending
     * closure except those requested to be excluded.
     *
     * @param tabsToExclude The list of tabs to exclude.
     * @return A lazy oneshot supplier containing all the tab group IDs including those pending
     *     closure.
     */
    public LazyOneshotSupplier<Set<Token>> getLazyAllTabGroupIdsInComprehensiveModel(
            List<Tab> tabsToExclude) {
        return LazyOneshotSupplier.fromSupplier(
                () -> {
                    Set<Token> tabGroupIds = new HashSet<>();
                    forEachTabInComprehensiveModelExcept(
                            tabsToExclude,
                            tab -> {
                                @Nullable Token tabGroupId = tab.getTabGroupId();
                                if (tabGroupId != null) {
                                    tabGroupIds.add(tabGroupId);
                                }
                            });
                    return tabGroupIds;
                });
    }

    /**
     * Returns a lazy oneshot supplier that generates all the root IDs including those pending
     * closure except those requested to be excluded.
     *
     * @param tabsToExclude The list of tabs to exclude.
     * @return A lazy oneshot supplier containing all the root IDs including those pending closure.
     */
    public LazyOneshotSupplier<Set<Integer>> getLazyAllRootIdsInComprehensiveModel(
            List<Tab> tabsToExclude) {
        return LazyOneshotSupplier.fromSupplier(
                () -> {
                    Set<Integer> rootIds = new HashSet<>();
                    forEachTabInComprehensiveModelExcept(
                            tabsToExclude,
                            tab -> {
                                rootIds.add(tab.getRootId());
                            });
                    return rootIds;
                });
    }

    private void forEachTabInComprehensiveModelExcept(
            List<Tab> tabsToExclude, Callback<Tab> callback) {
        Set<Tab> tabsToExcludeSet = new HashSet<>(tabsToExclude);
        TabList tabList = getTabModel().getComprehensiveModel();
        for (int i = 0; i < tabList.getCount(); i++) {
            Tab tab = tabList.getTabAt(i);
            if (tabsToExcludeSet.contains(tab)) continue;

            callback.onResult(tab);
        }
    }

    private static Token getOrCreateTabGroupId(@NonNull Tab tab) {
        return getOrCreateTabGroupIdWithDefault(tab, null);
    }

    private static Token getOrCreateTabGroupIdWithDefault(
            @NonNull Tab tab, @Nullable Token defaultTabGroupId) {
        Token tabGroupId = tab.getTabGroupId();
        if (tabGroupId == null) {
            tabGroupId = (defaultTabGroupId == null) ? Token.createRandom() : defaultTabGroupId;
            tab.setTabGroupId(tabGroupId);
        }
        return tabGroupId;
    }

    private static void setBothGroupIds(Tab tab, int rootId, Token tabGroupId) {
        TabStateAttributes tabStateAttributes = TabStateAttributes.from(tab);
        tabStateAttributes.beginBatchEdit();
        tab.setRootId(rootId);
        tab.setTabGroupId(tabGroupId);
        tabStateAttributes.endBatchEdit();
    }

    private static boolean shouldSkipGroupCreationDialog(boolean shouldShow) {
        if (ChromeFeatureList.sTabGroupCreationDialogAndroid.isEnabled()) {
            return !shouldShow;
        } else {
            return SKIP_TAB_GROUP_CREATION_DIALOG.getValue();
        }
    }

    /**
     * Returns whether the group creation dialog should be shown based on the setting switch for
     * auto showing under tab settings. If it is not enabled, return true since that is the default
     * case for all callsites.
     */
    public static boolean shouldShowGroupCreationDialogViaSettingsSwitch() {
        if (SHOW_TAB_GROUP_CREATION_DIALOG_SETTING.getValue()) {
            SharedPreferencesManager prefsManager = ChromeSharedPreferences.getInstance();
            return prefsManager.readBoolean(
                    ChromePreferenceKeys.SHOW_TAB_GROUP_CREATION_DIALOG, true);
        } else {
            return true;
        }
    }
}