/*
 * Modern UI.
 * Copyright (C) 2019-2023 BloCamLimb. All rights reserved.
 *
 * Modern UI is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public
 * License as published by the Free Software Foundation; either
 * version 3 of the License, or (at your option) any later version.
 *
 * Modern UI is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with Modern UI. If not, see <https://www.gnu.org/licenses/>.
 */

package icyllis.modernui.widget;

/*
 * Implementation notes:
 *
 * <p>
 * Terminology:
 * <li> flPos - Flat list position, the position used by ListView
 * <li> gPos - Group position, the position of a group among all the groups
 * <li> cPos - Child position, the position of a child among all the children
 * in a group
 */

import icyllis.modernui.annotation.NonNull;
import icyllis.modernui.core.Core;
import icyllis.modernui.util.*;
import icyllis.modernui.view.View;
import icyllis.modernui.view.ViewGroup;

import javax.annotation.Nullable;
import java.util.*;

/**
 * A {@link BaseAdapter} that provides data/Views in an expandable list (offers
 * features such as collapsing/expanding groups containing children). By
 * itself, this adapter has no data and is a connector to a
 * {@link ExpandableListAdapter} which provides the data.
 * <p>
 * Internally, this connector translates the flat list position that the
 * ListAdapter expects to/from group and child positions that the ExpandableListAdapter
 * expects.
 */
// Modified from Android
class ExpandableListConnector extends BaseAdapter implements Filterable {
    /**
     * The ExpandableListAdapter to fetch the data/Views for this expandable list
     */
    private ExpandableListAdapter mExpandableListAdapter;

    /**
     * List of metadata for the currently expanded groups. The metadata consists
     * of data essential for efficiently translating between flat list positions
     * and group/child positions. See {@link GroupMetadata}.
     */
    private ArrayList<GroupMetadata> mExpGroupMetadataList;

    /**
     * The number of children from all currently expanded groups
     */
    private int mTotalExpChildrenCount;

    /**
     * The maximum number of allowable expanded groups. Defaults to 'no limit'
     */
    private int mMaxExpGroupCount = Integer.MAX_VALUE;

    /**
     * Change observer used to have ExpandableListAdapter changes pushed to us
     */
    private final DataSetObserver mDataSetObserver = new MyDataSetObserver();

    /**
     * Constructs the connector
     */
    public ExpandableListConnector(ExpandableListAdapter expandableListAdapter) {
        mExpGroupMetadataList = new ArrayList<GroupMetadata>();

        setExpandableListAdapter(expandableListAdapter);
    }

    /**
     * Point to the {@link ExpandableListAdapter} that will give us data/Views
     *
     * @param expandableListAdapter the adapter that supplies us with data/Views
     */
    public void setExpandableListAdapter(ExpandableListAdapter expandableListAdapter) {
        if (mExpandableListAdapter != null) {
            mExpandableListAdapter.unregisterDataSetObserver(mDataSetObserver);
        }

        mExpandableListAdapter = expandableListAdapter;
        expandableListAdapter.registerDataSetObserver(mDataSetObserver);
    }

    /**
     * Translates a flat list position to either a) group pos if the specified
     * flat list position corresponds to a group, or b) child pos if it
     * corresponds to a child.  Performs a binary search on the expanded
     * groups list to find the flat list pos if it is an exp group, otherwise
     * finds where the flat list pos fits in between the exp groups.
     *
     * @param flPos the flat list position to be translated
     * @return the group position or child position of the specified flat list
     * position encompassed in a {@link PositionMetadata} object
     * that contains additional useful info for insertion, etc.
     */
    PositionMetadata getUnflattenedPos(final int flPos) {
        /* Keep locally since frequent use */
        final ArrayList<GroupMetadata> egml = mExpGroupMetadataList;
        final int numExpGroups = egml.size();

        /* Binary search variables */
        int leftExpGroupIndex = 0;
        int rightExpGroupIndex = numExpGroups - 1;
        int midExpGroupIndex = 0;
        GroupMetadata midExpGm;

        if (numExpGroups == 0) {
            /*
             * There aren't any expanded groups (hence no visible children
             * either), so flPos must be a group and its group pos will be the
             * same as its flPos
             */
            return PositionMetadata.obtain(flPos, ExpandableListPosition.GROUP, flPos,
                    -1, null, 0);
        }

        /*
         * Binary search over the expanded groups to find either the exact
         * expanded group (if we're looking for a group) or the group that
         * contains the child we're looking for. If we are looking for a
         * collapsed group, we will not have a direct match here, but we will
         * find the expanded group just before the group we're searching for (so
         * then we can calculate the group position of the group we're searching
         * for). If there isn't an expanded group prior to the group being
         * searched for, then the group being searched for's group position is
         * the same as the flat list position (since there are no children before
         * it, and all groups before it are collapsed).
         */
        while (leftExpGroupIndex <= rightExpGroupIndex) {
            midExpGroupIndex =
                    (rightExpGroupIndex - leftExpGroupIndex) / 2
                            + leftExpGroupIndex;
            midExpGm = egml.get(midExpGroupIndex);

            if (flPos > midExpGm.lastChildFlPos) {
                /*
                 * The flat list position is after the current middle group's
                 * last child's flat list position, so search right
                 */
                leftExpGroupIndex = midExpGroupIndex + 1;
            } else if (flPos < midExpGm.flPos) {
                /*
                 * The flat list position is before the current middle group's
                 * flat list position, so search left
                 */
                rightExpGroupIndex = midExpGroupIndex - 1;
            } else if (flPos == midExpGm.flPos) {
                /*
                 * The flat list position is this middle group's flat list
                 * position, so we've found an exact hit
                 */
                return PositionMetadata.obtain(flPos, ExpandableListPosition.GROUP,
                        midExpGm.gPos, -1, midExpGm, midExpGroupIndex);
            } else if (flPos <= midExpGm.lastChildFlPos
                /* && flPos > midGm.flPos as deduced from previous
                 * conditions */) {
                /* The flat list position is a child of the middle group */

                /*
                 * Subtract the first child's flat list position from the
                 * specified flat list pos to get the child's position within
                 * the group
                 */
                final int childPos = flPos - (midExpGm.flPos + 1);
                return PositionMetadata.obtain(flPos, ExpandableListPosition.CHILD,
                        midExpGm.gPos, childPos, midExpGm, midExpGroupIndex);
            }
        }

        /*
         * If we've reached here, it means the flat list position must be a
         * group that is not expanded, since otherwise we would have hit it
         * in the above search.
         */


        /*
         * If we are to expand this group later, where would it go in the
         * mExpGroupMetadataList ?
         */
        int insertPosition = 0;

        /* What is its group position in the list of all groups? */
        int groupPos = 0;

        /*
         * To figure out exact insertion and prior group positions, we need to
         * determine how we broke out of the binary search.  We backtrack
         * to see this.
         */
        if (leftExpGroupIndex > midExpGroupIndex) {

            /*
             * This would occur in the first conditional, so the flat list
             * insertion position is after the left group. Also, the
             * leftGroupPos is one more than it should be (since that broke out
             * of our binary search), so we decrement it.
             */
            final GroupMetadata leftExpGm = egml.get(leftExpGroupIndex - 1);

            insertPosition = leftExpGroupIndex;

            /*
             * Sums the number of groups between the prior exp group and this
             * one, and then adds it to the prior group's group pos
             */
            groupPos =
                    (flPos - leftExpGm.lastChildFlPos) + leftExpGm.gPos;
        } else if (rightExpGroupIndex < midExpGroupIndex) {

            /*
             * This would occur in the second conditional, so the flat list
             * insertion position is before the right group. Also, the
             * rightGroupPos is one less than it should be, so increment it.
             */
            final GroupMetadata rightExpGm = egml.get(++rightExpGroupIndex);

            insertPosition = rightExpGroupIndex;

            /*
             * Subtracts this group's flat list pos from the group after's flat
             * list position to find out how many groups are in between the two
             * groups. Then, subtracts that number from the group after's group
             * pos to get this group's pos.
             */
            groupPos = rightExpGm.gPos - (rightExpGm.flPos - flPos);
        } else {
            // TODO: clean exit
            throw new RuntimeException("Unknown state");
        }

        return PositionMetadata.obtain(flPos, ExpandableListPosition.GROUP, groupPos, -1,
                null, insertPosition);
    }

    /**
     * Translates either a group pos or a child pos (+ group it belongs to) to a
     * flat list position.  If searching for a child and its group is not expanded, this will
     * return null since the child isn't being shown in the ListView, and hence it has no
     * position.
     *
     * @param pos a {@link ExpandableListPosition} representing either a group position
     *            or child position
     * @return the flat list position encompassed in a {@link PositionMetadata}
     * object that contains additional useful info for insertion, etc., or null.
     */
    PositionMetadata getFlattenedPos(final ExpandableListPosition pos) {
        final ArrayList<GroupMetadata> egml = mExpGroupMetadataList;
        final int numExpGroups = egml.size();

        /* Binary search variables */
        int leftExpGroupIndex = 0;
        int rightExpGroupIndex = numExpGroups - 1;
        int midExpGroupIndex = 0;
        GroupMetadata midExpGm;

        if (numExpGroups == 0) {
            /*
             * There aren't any expanded groups, so flPos must be a group and
             * its flPos will be the same as its group pos.  The
             * insert position is 0 (since the list is empty).
             */
            return PositionMetadata.obtain(pos.groupPos, pos.type,
                    pos.groupPos, pos.childPos, null, 0);
        }

        /*
         * Binary search over the expanded groups to find either the exact
         * expanded group (if we're looking for a group) or the group that
         * contains the child we're looking for.
         */
        while (leftExpGroupIndex <= rightExpGroupIndex) {
            midExpGroupIndex = (rightExpGroupIndex - leftExpGroupIndex) / 2 + leftExpGroupIndex;
            midExpGm = egml.get(midExpGroupIndex);

            if (pos.groupPos > midExpGm.gPos) {
                /*
                 * It's after the current middle group, so search right
                 */
                leftExpGroupIndex = midExpGroupIndex + 1;
            } else if (pos.groupPos < midExpGm.gPos) {
                /*
                 * It's before the current middle group, so search left
                 */
                rightExpGroupIndex = midExpGroupIndex - 1;
            } else if (pos.groupPos == midExpGm.gPos) {
                /*
                 * It's this middle group, exact hit
                 */

                if (pos.type == ExpandableListPosition.GROUP) {
                    /* If it's a group, give them this matched group's flPos */
                    return PositionMetadata.obtain(midExpGm.flPos, pos.type,
                            pos.groupPos, pos.childPos, midExpGm, midExpGroupIndex);
                } else if (pos.type == ExpandableListPosition.CHILD) {
                    /* If it's a child, calculate the flat list pos */
                    return PositionMetadata.obtain(midExpGm.flPos + pos.childPos
                                    + 1, pos.type, pos.groupPos, pos.childPos,
                            midExpGm, midExpGroupIndex);
                } else {
                    return null;
                }
            }
        }

        /*
         * If we've reached here, it means there was no match in the expanded
         * groups, so it must be a collapsed group that they're search for
         */
        if (pos.type != ExpandableListPosition.GROUP) {
            /* If it isn't a group, return null */
            return null;
        }

        /*
         * To figure out exact insertion and prior group positions, we need to
         * determine how we broke out of the binary search. We backtrack to see
         * this.
         */
        if (leftExpGroupIndex > midExpGroupIndex) {

            /*
             * This would occur in the first conditional, so the flat list
             * insertion position is after the left group.
             *
             * The leftGroupPos is one more than it should be (from the binary
             * search loop) so we subtract 1 to get the actual left group.  Since
             * the insertion point is AFTER the left group, we keep this +1
             * value as the insertion point
             */
            final GroupMetadata leftExpGm = egml.get(leftExpGroupIndex - 1);
            final int flPos =
                    leftExpGm.lastChildFlPos
                            + (pos.groupPos - leftExpGm.gPos);

            return PositionMetadata.obtain(flPos, pos.type, pos.groupPos,
                    pos.childPos, null, leftExpGroupIndex);
        } else if (rightExpGroupIndex < midExpGroupIndex) {

            /*
             * This would occur in the second conditional, so the flat list
             * insertion position is before the right group. Also, the
             * rightGroupPos is one less than it should be (from binary search
             * loop), so we increment to it.
             */
            final GroupMetadata rightExpGm = egml.get(++rightExpGroupIndex);
            final int flPos =
                    rightExpGm.flPos
                            - (rightExpGm.gPos - pos.groupPos);
            return PositionMetadata.obtain(flPos, pos.type, pos.groupPos,
                    pos.childPos, null, rightExpGroupIndex);
        } else {
            return null;
        }
    }

    @Override
    public boolean areAllItemsEnabled() {
        return mExpandableListAdapter.areAllItemsEnabled();
    }

    @Override
    public boolean isEnabled(int flatListPos) {
        final PositionMetadata metadata = getUnflattenedPos(flatListPos);
        final ExpandableListPosition pos = metadata.position;

        boolean retValue;
        if (pos.type == ExpandableListPosition.CHILD) {
            retValue = mExpandableListAdapter.isChildSelectable(pos.groupPos, pos.childPos);
        } else {
            // Groups are always selectable
            retValue = true;
        }

        metadata.recycle();

        return retValue;
    }

    @Override
    public int getCount() {
        /*
         * Total count for the list view is the number groups plus the
         * number of children from currently expanded groups (a value we keep
         * cached in this class)
         */
        return mExpandableListAdapter.getGroupCount() + mTotalExpChildrenCount;
    }

    @Override
    public Object getItem(int flatListPos) {
        final PositionMetadata posMetadata = getUnflattenedPos(flatListPos);

        Object retValue;
        if (posMetadata.position.type == ExpandableListPosition.GROUP) {
            retValue = mExpandableListAdapter
                    .getGroup(posMetadata.position.groupPos);
        } else if (posMetadata.position.type == ExpandableListPosition.CHILD) {
            retValue = mExpandableListAdapter.getChild(posMetadata.position.groupPos,
                    posMetadata.position.childPos);
        } else {
            // TODO: clean exit
            throw new RuntimeException("Flat list position is of unknown type");
        }

        posMetadata.recycle();

        return retValue;
    }

    @Override
    public long getItemId(int flatListPos) {
        final PositionMetadata posMetadata = getUnflattenedPos(flatListPos);
        final long groupId = mExpandableListAdapter.getGroupId(posMetadata.position.groupPos);

        long retValue;
        if (posMetadata.position.type == ExpandableListPosition.GROUP) {
            retValue = mExpandableListAdapter.getCombinedGroupId(groupId);
        } else if (posMetadata.position.type == ExpandableListPosition.CHILD) {
            final long childId = mExpandableListAdapter.getChildId(posMetadata.position.groupPos,
                    posMetadata.position.childPos);
            retValue = mExpandableListAdapter.getCombinedChildId(groupId, childId);
        } else {
            // TODO: clean exit
            throw new RuntimeException("Flat list position is of unknown type");
        }

        posMetadata.recycle();

        return retValue;
    }

    @Override
    public View getView(int flatListPos, @Nullable View convertView, @NonNull ViewGroup parent) {
        final PositionMetadata posMetadata = getUnflattenedPos(flatListPos);

        View retValue;
        if (posMetadata.position.type == ExpandableListPosition.GROUP) {
            retValue = mExpandableListAdapter.getGroupView(posMetadata.position.groupPos,
                    posMetadata.isExpanded(), convertView, parent);
        } else if (posMetadata.position.type == ExpandableListPosition.CHILD) {
            final boolean isLastChild = posMetadata.groupMetadata.lastChildFlPos == flatListPos;

            retValue = mExpandableListAdapter.getChildView(posMetadata.position.groupPos,
                    posMetadata.position.childPos, isLastChild, convertView, parent);
        } else {
            // TODO: clean exit
            throw new RuntimeException("Flat list position is of unknown type");
        }

        posMetadata.recycle();

        return retValue;
    }

    @Override
    public int getItemViewType(int flatListPos) {
        final PositionMetadata metadata = getUnflattenedPos(flatListPos);
        final ExpandableListPosition pos = metadata.position;

        int retValue;
        if (mExpandableListAdapter instanceof HeterogeneousExpandableList adapter) {
            if (pos.type == ExpandableListPosition.GROUP) {
                retValue = adapter.getGroupType(pos.groupPos);
            } else {
                final int childType = adapter.getChildType(pos.groupPos, pos.childPos);
                retValue = adapter.getGroupTypeCount() + childType;
            }
        } else {
            if (pos.type == ExpandableListPosition.GROUP) {
                retValue = 0;
            } else {
                retValue = 1;
            }
        }

        metadata.recycle();

        return retValue;
    }

    @Override
    public int getViewTypeCount() {
        if (mExpandableListAdapter instanceof HeterogeneousExpandableList adapter) {
            return adapter.getGroupTypeCount() + adapter.getChildTypeCount();
        } else {
            return 2;
        }
    }

    @Override
    public boolean hasStableIds() {
        return mExpandableListAdapter.hasStableIds();
    }

    /**
     * Traverses the expanded group metadata list and fills in the flat list
     * positions.
     *
     * @param forceChildrenCountRefresh Forces refreshing of the children count
     *                                  for all expanded groups.
     * @param syncGroupPositions        Whether to search for the group positions
     *                                  based on the group IDs. This should only be needed when calling
     *                                  this from an onChanged callback.
     */
    @SuppressWarnings("unchecked")
    private void refreshExpGroupMetadataList(boolean forceChildrenCountRefresh,
                                             boolean syncGroupPositions) {
        final ArrayList<GroupMetadata> egml = mExpGroupMetadataList;
        int egmlSize = egml.size();
        int curFlPos = 0;

        /* Update child count as we go through */
        mTotalExpChildrenCount = 0;

        if (syncGroupPositions) {
            // We need to check whether any groups have moved positions
            boolean positionsChanged = false;

            for (int i = egmlSize - 1; i >= 0; i--) {
                GroupMetadata curGm = egml.get(i);
                int newGPos = findGroupPosition(curGm.gId, curGm.gPos);
                if (newGPos != curGm.gPos) {
                    if (newGPos == AdapterView.INVALID_POSITION) {
                        // Doh, just remove it from the list of expanded groups
                        egml.remove(i);
                        egmlSize--;
                    }

                    curGm.gPos = newGPos;
                    if (!positionsChanged) positionsChanged = true;
                }
            }

            if (positionsChanged) {
                // At least one group changed positions, so re-sort
                Collections.sort(egml);
            }
        }

        int gChildrenCount;
        int lastGPos = 0;
        for (int i = 0; i < egmlSize; i++) {
            /* Store in local variable since we'll access freq */
            GroupMetadata curGm = egml.get(i);

            /*
             * Get the number of children, try to refrain from calling
             * another class's method unless we have to (so do a subtraction)
             */
            if ((curGm.lastChildFlPos == GroupMetadata.REFRESH) || forceChildrenCountRefresh) {
                gChildrenCount = mExpandableListAdapter.getChildrenCount(curGm.gPos);
            } else {
                /* Num children for this group is its last child's fl pos minus
                 * the group's fl pos
                 */
                gChildrenCount = curGm.lastChildFlPos - curGm.flPos;
            }

            /* Update */
            mTotalExpChildrenCount += gChildrenCount;

            /*
             * This skips the collapsed groups and increments the flat list
             * position (for subsequent exp groups) by accounting for the collapsed
             * groups
             */
            curFlPos += (curGm.gPos - lastGPos);
            lastGPos = curGm.gPos;

            /* Update the flat list positions, and the current flat list pos */
            curGm.flPos = curFlPos;
            curFlPos += gChildrenCount;
            curGm.lastChildFlPos = curFlPos;
        }
    }

    /**
     * Collapse a group in the grouped list view
     *
     * @param groupPos position of the group to collapse
     */
    boolean collapseGroup(int groupPos) {
        ExpandableListPosition elGroupPos = ExpandableListPosition.obtain(
                ExpandableListPosition.GROUP, groupPos, -1, -1);
        PositionMetadata pm = getFlattenedPos(elGroupPos);
        elGroupPos.recycle();
        if (pm == null) return false;

        boolean retValue = collapseGroup(pm);
        pm.recycle();
        return retValue;
    }

    boolean collapseGroup(PositionMetadata posMetadata) {
        /*
         * Collapsing requires removal from mExpGroupMetadataList
         */

        /*
         * If it is null, it must be already collapsed. This group metadata
         * object should have been set from the search that returned the
         * position metadata object.
         */
        if (posMetadata.groupMetadata == null) return false;

        // Remove the group from the list of expanded groups
        mExpGroupMetadataList.remove(posMetadata.groupMetadata);

        // Refresh the metadata
        refreshExpGroupMetadataList(false, false);

        // Notify of change
        notifyDataSetChanged();

        // Give the callback
        mExpandableListAdapter.onGroupCollapsed(posMetadata.groupMetadata.gPos);

        return true;
    }

    /**
     * Expand a group in the grouped list view
     *
     * @param groupPos the group to be expanded
     */
    boolean expandGroup(int groupPos) {
        ExpandableListPosition elGroupPos = ExpandableListPosition.obtain(
                ExpandableListPosition.GROUP, groupPos, -1, -1);
        PositionMetadata pm = getFlattenedPos(elGroupPos);
        elGroupPos.recycle();
        boolean retValue = expandGroup(pm);
        pm.recycle();
        return retValue;
    }

    boolean expandGroup(PositionMetadata posMetadata) {
        /*
         * Expanding requires insertion into the mExpGroupMetadataList
         */

        if (posMetadata.position.groupPos < 0) {
            // TODO clean exit
            throw new RuntimeException("Need group");
        }

        if (mMaxExpGroupCount == 0) return false;

        // Check to see if it's already expanded
        if (posMetadata.groupMetadata != null) return false;

        /* Restrict number of expanded groups to mMaxExpGroupCount */
        if (mExpGroupMetadataList.size() >= mMaxExpGroupCount) {
            /* Collapse a group */
            // TODO: Collapse something not on the screen instead of the first one?
            // TODO: Could write overloaded function to take GroupMetadata to collapse
            GroupMetadata collapsedGm = mExpGroupMetadataList.get(0);

            int collapsedIndex = mExpGroupMetadataList.indexOf(collapsedGm);

            collapseGroup(collapsedGm.gPos);

            /* Decrement index if it is after the group we removed */
            if (posMetadata.groupInsertIndex > collapsedIndex) {
                posMetadata.groupInsertIndex--;
            }
        }

        GroupMetadata expandedGm = GroupMetadata.obtain(
                GroupMetadata.REFRESH,
                GroupMetadata.REFRESH,
                posMetadata.position.groupPos,
                mExpandableListAdapter.getGroupId(posMetadata.position.groupPos));

        mExpGroupMetadataList.add(posMetadata.groupInsertIndex, expandedGm);

        // Refresh the metadata
        refreshExpGroupMetadataList(false, false);

        // Notify of change
        notifyDataSetChanged();

        // Give the callback
        mExpandableListAdapter.onGroupExpanded(expandedGm.gPos);

        return true;
    }

    /**
     * Whether the given group is currently expanded.
     *
     * @param groupPosition The group to check.
     * @return Whether the group is currently expanded.
     */
    public boolean isGroupExpanded(int groupPosition) {
        GroupMetadata groupMetadata;
        for (int i = mExpGroupMetadataList.size() - 1; i >= 0; i--) {
            groupMetadata = mExpGroupMetadataList.get(i);

            if (groupMetadata.gPos == groupPosition) {
                return true;
            }
        }

        return false;
    }

    /**
     * Set the maximum number of groups that can be expanded at any given time
     */
    public void setMaxExpGroupCount(int maxExpGroupCount) {
        mMaxExpGroupCount = maxExpGroupCount;
    }

    ExpandableListAdapter getAdapter() {
        return mExpandableListAdapter;
    }

    public Filter getFilter() {
        ExpandableListAdapter adapter = getAdapter();
        if (adapter instanceof Filterable) {
            return ((Filterable) adapter).getFilter();
        } else {
            return null;
        }
    }

    ArrayList<GroupMetadata> getExpandedGroupMetadataList() {
        return mExpGroupMetadataList;
    }

    void setExpandedGroupMetadataList(ArrayList<GroupMetadata> expandedGroupMetadataList) {

        if ((expandedGroupMetadataList == null) || (mExpandableListAdapter == null)) {
            return;
        }

        // Make sure our current data set is big enough for the previously
        // expanded groups, if not, ignore this request
        int numGroups = mExpandableListAdapter.getGroupCount();
        for (int i = expandedGroupMetadataList.size() - 1; i >= 0; i--) {
            if (expandedGroupMetadataList.get(i).gPos >= numGroups) {
                // Doh, for some reason the client doesn't have some of the groups
                return;
            }
        }

        mExpGroupMetadataList = expandedGroupMetadataList;
        refreshExpGroupMetadataList(true, false);
    }

    @Override
    public boolean isEmpty() {
        ExpandableListAdapter adapter = getAdapter();
        return adapter != null ? adapter.isEmpty() : true;
    }

    /**
     * Searches the expandable list adapter for a group position matching the
     * given group ID. The search starts at the given seed position and then
     * alternates between moving up and moving down until 1) we find the right
     * position, or 2) we run out of time, or 3) we have looked at every
     * position
     *
     * @return Position of the row that matches the given row ID, or
     * {@link AdapterView#INVALID_POSITION} if it can't be found
     * @see AdapterView#findSyncPosition()
     */
    int findGroupPosition(long groupIdToMatch, int seedGroupPosition) {
        int count = mExpandableListAdapter.getGroupCount();

        if (count == 0) {
            return AdapterView.INVALID_POSITION;
        }

        // If there isn't a selection don't hunt for it
        if (groupIdToMatch == AdapterView.INVALID_ROW_ID) {
            return AdapterView.INVALID_POSITION;
        }

        // Pin seed to reasonable values
        seedGroupPosition = Math.max(0, seedGroupPosition);
        seedGroupPosition = Math.min(count - 1, seedGroupPosition);

        long endTime = Core.timeMillis() + AdapterView.SYNC_MAX_DURATION_MILLIS;

        long rowId;

        // first position scanned so far
        int first = seedGroupPosition;

        // last position scanned so far
        int last = seedGroupPosition;

        // True if we should move down on the next iteration
        boolean next = false;

        // True when we have looked at the first item in the data
        boolean hitFirst;

        // True when we have looked at the last item in the data
        boolean hitLast;

        // Get the item ID locally (instead of getItemIdAtPosition), so
        // we need the adapter
        ExpandableListAdapter adapter = getAdapter();
        if (adapter == null) {
            return AdapterView.INVALID_POSITION;
        }

        while (Core.timeMillis() <= endTime) {
            rowId = adapter.getGroupId(seedGroupPosition);
            if (rowId == groupIdToMatch) {
                // Found it!
                return seedGroupPosition;
            }

            hitLast = last == count - 1;
            hitFirst = first == 0;

            if (hitLast && hitFirst) {
                // Looked at everything
                break;
            }

            if (hitFirst || (next && !hitLast)) {
                // Either we hit the top, or we are trying to move down
                last++;
                seedGroupPosition = last;
                // Try going up next time
                next = false;
            } else if (hitLast || (!next && !hitFirst)) {
                // Either we hit the bottom, or we are trying to move up
                first--;
                seedGroupPosition = first;
                // Try going down next time
                next = true;
            }

        }

        return AdapterView.INVALID_POSITION;
    }

    protected class MyDataSetObserver implements DataSetObserver {
        @Override
        public void onChanged() {
            refreshExpGroupMetadataList(true, true);

            notifyDataSetChanged();
        }

        @Override
        public void onInvalidated() {
            refreshExpGroupMetadataList(true, true);

            notifyDataSetInvalidated();
        }
    }

    /**
     * Metadata about an expanded group to help convert from a flat list
     * position to either a) group position for groups, or b) child position for
     * children
     */
    static class GroupMetadata implements Parcelable, Comparable<GroupMetadata> {
        final static int REFRESH = -1;

        /**
         * This group's flat list position
         */
        int flPos;

        /* firstChildFlPos isn't needed since it's (flPos + 1) */

        /**
         * This group's last child's flat list position, so basically
         * the range of this group in the flat list
         */
        int lastChildFlPos;

        /**
         * This group's group position
         */
        int gPos;

        /**
         * This group's id
         */
        long gId;

        private GroupMetadata() {
        }

        static GroupMetadata obtain(int flPos, int lastChildFlPos, int gPos, long gId) {
            GroupMetadata gm = new GroupMetadata();
            gm.flPos = flPos;
            gm.lastChildFlPos = lastChildFlPos;
            gm.gPos = gPos;
            gm.gId = gId;
            return gm;
        }

        @Override
        public int compareTo(@NonNull GroupMetadata other) {
            return gPos - other.gPos;
        }

        @Override
        public void writeToParcel(@NonNull Parcel dest, int flags) {
            dest.writeInt(flPos);
            dest.writeInt(lastChildFlPos);
            dest.writeInt(gPos);
            dest.writeLong(gId);
        }

        @SuppressWarnings("unused")
        public static final Parcelable.Creator<GroupMetadata> CREATOR =
                in -> GroupMetadata.obtain(
                        in.readInt(),
                        in.readInt(),
                        in.readInt(),
                        in.readLong());

    }

    /**
     * Data type that contains an expandable list position (can refer to either a group
     * or child) and some extra information regarding referred item (such as
     * where to insert into the flat list, etc.)
     */
    static class PositionMetadata {

        private static final int MAX_POOL_SIZE = 5;
        private static final ArrayDeque<PositionMetadata> sPool =
                new ArrayDeque<>(MAX_POOL_SIZE);

        /**
         * Data type to hold the position and its type (child/group)
         */
        public ExpandableListPosition position;

        /**
         * Link back to the expanded GroupMetadata for this group. Useful for
         * removing the group from the list of expanded groups inside the
         * connector when we collapse the group, and also as a check to see if
         * the group was expanded or collapsed (this will be null if the group
         * is collapsed since we don't keep that group's metadata)
         */
        public GroupMetadata groupMetadata;

        /**
         * For groups that are collapsed, we use this as the index (in
         * mExpGroupMetadataList) to insert this group when we are expanding
         * this group.
         */
        public int groupInsertIndex;

        private void resetState() {
            if (position != null) {
                position.recycle();
                position = null;
            }
            groupMetadata = null;
            groupInsertIndex = 0;
        }

        /**
         * Use {@link #obtain(int, int, int, int, GroupMetadata, int)}
         */
        private PositionMetadata() {
        }

        static PositionMetadata obtain(int flatListPos, int type, int groupPos,
                                       int childPos, GroupMetadata groupMetadata, int groupInsertIndex) {
            PositionMetadata pm = getRecycledOrCreate();
            pm.position = ExpandableListPosition.obtain(type, groupPos, childPos, flatListPos);
            pm.groupMetadata = groupMetadata;
            pm.groupInsertIndex = groupInsertIndex;
            return pm;
        }

        private static PositionMetadata getRecycledOrCreate() {
            PositionMetadata pm;
            synchronized (sPool) {
                if (!sPool.isEmpty()) {
                    pm = sPool.pop();
                } else {
                    return new PositionMetadata();
                }
            }
            pm.resetState();
            return pm;
        }

        public void recycle() {
            resetState();
            synchronized (sPool) {
                if (sPool.size() < MAX_POOL_SIZE) {
                    sPool.add(this);
                }
            }
        }

        /**
         * Checks whether the group referred to in this object is expanded,
         * or not (at the time this object was created)
         *
         * @return whether the group at groupPos is expanded or not
         */
        public boolean isExpanded() {
            return groupMetadata != null;
        }
    }
}
