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

package com.android.sdkuilib.internal.repository.core;

import com.android.SdkConstants;
import com.android.sdklib.AndroidVersion;
import com.android.sdklib.IAndroidTarget;
import com.android.sdklib.internal.repository.packages.BuildToolPackage;
import com.android.sdklib.internal.repository.packages.ExtraPackage;
import com.android.sdklib.internal.repository.packages.IAndroidVersionProvider;
import com.android.sdklib.internal.repository.packages.IFullRevisionProvider;
import com.android.sdklib.internal.repository.packages.Package;
import com.android.sdklib.internal.repository.packages.Package.UpdateInfo;
import com.android.sdklib.internal.repository.packages.PlatformPackage;
import com.android.sdklib.internal.repository.packages.PlatformToolPackage;
import com.android.sdklib.internal.repository.packages.SystemImagePackage;
import com.android.sdklib.internal.repository.packages.ToolPackage;
import com.android.sdklib.internal.repository.sources.SdkSource;
import com.android.sdklib.internal.repository.updater.PkgItem;
import com.android.sdklib.internal.repository.updater.PkgItem.PkgState;
import com.android.sdklib.repository.FullRevision;
import com.android.sdklib.repository.FullRevision.PreviewComparison;
import com.android.utils.SparseArray;
import com.android.sdkuilib.internal.repository.SwtUpdaterData;
import com.android.sdkuilib.internal.repository.ui.PackagesPageIcons;
import com.android.utils.Pair;
import com.google.common.collect.Maps;

import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * Helper class that separates the logic of package management from the UI
 * so that we can test it using head-less unit tests.
 */
public class PackagesDiffLogic {
    private final SwtUpdaterData mUpdaterData;
    private boolean mFirstLoadComplete = true;

    public PackagesDiffLogic(SwtUpdaterData swtUpdaterData) {
        mUpdaterData = swtUpdaterData;
    }

    /**
     * Removes all the internal state and resets the object.
     * Useful for testing.
     */
    public void clear() {
        mFirstLoadComplete = true;
        mOpApi.clear();
        mOpSource.clear();
    }

    /** Return mFirstLoadComplete and resets it to false.
     * All following calls will returns false. */
    public boolean isFirstLoadComplete() {
        boolean b = mFirstLoadComplete;
        mFirstLoadComplete = false;
        return b;
    }

    /**
     * Mark all new and update PkgItems as checked.
     *
     * @param selectNew If true, select all new packages (except the rc/preview ones).
     * @param selectUpdates If true, select all update packages.
     * @param selectTop If true, select the top platform.
     *   If the top platform has nothing installed, select all items in it (except the rc/preview);
     *   If it is partially installed, at least select the platform and system images if none of
     *   the system images are installed.
     * @param currentPlatform The {@link SdkConstants#currentPlatform()} value.
     */
    public void checkNewUpdateItems(
            boolean selectNew,
            boolean selectUpdates,
            boolean selectTop,
            int currentPlatform) {
        int maxApi = 0;
        Set<Integer> installedPlatforms = new HashSet<Integer>();
        SparseArray<List<PkgItem>> platformItems = new SparseArray<List<PkgItem>>();

        boolean hasTools = false;
        Map<Class<?>, Pair<PkgItem, FullRevision>> toolsCandidates = Maps.newHashMap();
        toolsCandidates.put(PlatformToolPackage.class, Pair.of((PkgItem)null, (FullRevision)null));
        toolsCandidates.put(BuildToolPackage.class,    Pair.of((PkgItem)null, (FullRevision)null));

        // sort items in platforms... directly deal with new/update items
        List<PkgItem> allItems = getAllPkgItems(true /*byApi*/, true /*bySource*/);
        for (PkgItem item : allItems) {
            if (!item.hasCompatibleArchive()) {
                // Ignore items that have no archive compatible with the current platform.
                continue;
            }

            // Get the main package's API level. We don't need to look at the updates
            // since by definition they should target the same API level.
            int api = 0;
            Package p = item.getMainPackage();
            if (p instanceof IAndroidVersionProvider) {
                api = ((IAndroidVersionProvider) p).getAndroidVersion().getApiLevel();
            }

            if (selectTop && api > 0) {
                // Keep track of the max api seen
                maxApi = Math.max(maxApi, api);

                // keep track of what platform is currently installed (that is, has at least
                // one thing installed.)
                if (item.getState() == PkgState.INSTALLED) {
                    installedPlatforms.add(api);
                }

                // for each platform, collect all its related item for later use below.
                List<PkgItem> items = platformItems.get(api);
                if (items == null) {
                    platformItems.put(api, items = new ArrayList<PkgItem>());
                }
                items.add(item);
            }

            if ((selectUpdates || selectNew) &&
                    item.getState() == PkgState.NEW &&
                    !item.getRevision().isPreview()) {
                boolean sameFound = false;
                Package newPkg = item.getMainPackage();
                if (newPkg instanceof IFullRevisionProvider) {
                    // We have a potential new non-preview package; but this kind of package
                    // supports having previews, which means we want to make sure we're not
                    // offering an older "new" non-preview if there's a newer preview installed.
                    //
                    // We should get into this odd situation only when updating an RC/preview
                    // by a final release pkg.

                    IFullRevisionProvider newPkg2 = (IFullRevisionProvider) newPkg;
                    for (PkgItem item2 : allItems) {
                        if (item2.getState() == PkgState.INSTALLED) {
                            Package installed = item2.getMainPackage();

                            if (installed.getRevision().isPreview() &&
                                    newPkg2.sameItemAs(installed, PreviewComparison.IGNORE)) {
                                sameFound = true;

                                if (installed.canBeUpdatedBy(newPkg) == UpdateInfo.UPDATE) {
                                    item.setChecked(true);
                                    break;
                                }
                            }
                        }
                    }
                }

                if (selectNew && !sameFound) {
                    item.setChecked(true);
                }

            } else if (selectUpdates && item.hasUpdatePkg()) {
                item.setChecked(true);
            }

            // Keep track of the tools and offer to auto-select platform-tools/build-tools.
            if (selectTop) {
                if (p instanceof ToolPackage && p.isLocal()) {
                    hasTools = true; // main tool package is installed.
                } else if (p instanceof PlatformToolPackage || p instanceof BuildToolPackage) {
                    for (Class<?> clazz : toolsCandidates.keySet()) {
                        if (clazz.isInstance(p)) { // allow p to be a mock-derived class
                            if (p.isLocal()) {
                                // There's one such package installed, we don't need candidates.
                                toolsCandidates.remove(clazz);
                            } else if (toolsCandidates.containsKey(clazz)) {
                                Pair<PkgItem, FullRevision> val = toolsCandidates.get(clazz);
                                FullRevision rev = p.getRevision();
                                if (!rev.isPreview()) {
                                    // Don't auto-select previews.
                                    if (val.getSecond() == null ||
                                            rev.compareTo(val.getSecond()) > 0) {
                                        // No revision: set the first candidate.
                                        // Or we found a new higher revision
                                        toolsCandidates.put(clazz, Pair.of(item, rev));
                                    }
                                }
                            }
                            break;
                        }
                    }
                }
            }
        }

        // Select the top platform/build-tool found above if needed.
        if (selectTop && hasTools) {
            for (Pair<PkgItem, FullRevision> candidate : toolsCandidates.values()) {
                PkgItem item = candidate.getFirst();
                if (item != null) {
                    item.setChecked(true);
                }
            }
        }


        // Select top platform items.

        List<PkgItem> items = platformItems.get(maxApi);
        if (selectTop && maxApi > 0 && items != null) {
            if (!installedPlatforms.contains(maxApi)) {
                // If the top platform has nothing installed at all, select everything in it
                for (PkgItem item : items) {
                    if ((item.getState() == PkgState.NEW && !item.getRevision().isPreview()) ||
                            item.hasUpdatePkg()) {
                        item.setChecked(true);
                    }
                }

            } else {
                // The top platform has at least one thing installed.

                // First make sure the platform package itself is installed, or select it.
                for (PkgItem item : items) {
                     Package p = item.getMainPackage();
                     if (p instanceof PlatformPackage &&
                             item.getState() == PkgState.NEW && !item.getRevision().isPreview()) {
                         item.setChecked(true);
                         break;
                     }
                }

                // Check we have at least one system image installed, otherwise select them
                boolean hasSysImg = false;
                for (PkgItem item : items) {
                    Package p = item.getMainPackage();
                    if (p instanceof PlatformPackage && item.getState() == PkgState.INSTALLED) {
                        if (item.hasUpdatePkg() && item.isChecked()) {
                            // If the installed platform is scheduled for update, look for the
                            // system image in the update package, not the current one.
                            p = item.getUpdatePkg();
                            if (p instanceof PlatformPackage) {
                                hasSysImg = ((PlatformPackage) p).getIncludedAbi() != null;
                            }
                        } else {
                            // Otherwise look into the currently installed platform
                            hasSysImg = ((PlatformPackage) p).getIncludedAbi() != null;
                        }
                        if (hasSysImg) {
                            break;
                        }
                    }
                    if (p instanceof SystemImagePackage && item.getState() == PkgState.INSTALLED) {
                        hasSysImg = true;
                        break;
                    }
                }
                if (!hasSysImg) {
                    // No system image installed.
                    // Try whether the current platform or its update would bring one.

                    for (PkgItem item : items) {
                         Package p = item.getMainPackage();
                         if (p instanceof PlatformPackage) {
                             if (item.getState() == PkgState.NEW &&
                                     !item.getRevision().isPreview() &&
                                     ((PlatformPackage) p).getIncludedAbi() != null) {
                                 item.setChecked(true);
                                 hasSysImg = true;
                             } else if (item.hasUpdatePkg()) {
                                 p = item.getUpdatePkg();
                                 if (p instanceof PlatformPackage &&
                                         ((PlatformPackage) p).getIncludedAbi() != null) {
                                     item.setChecked(true);
                                     hasSysImg = true;
                                 }
                             }
                         }
                    }
                }
                if (!hasSysImg) {
                    // No system image in the platform, try a system image package
                    for (PkgItem item : items) {
                        Package p = item.getMainPackage();
                        if (p instanceof SystemImagePackage && item.getState() == PkgState.NEW) {
                            item.setChecked(true);
                        }
                    }
                }
            }
        }

        if (selectTop) {
            for (PkgItem item : getAllPkgItems(true /*byApi*/, true /*bySource*/)) {
                Package p = item.getMainPackage();
                if (p instanceof ExtraPackage &&
                        item.getState() == PkgState.NEW &&
                        !item.getRevision().isPreview()) {
                    ExtraPackage ep = (ExtraPackage) p;

                    // On Windows, we'll also auto-select the USB driver
                    if (currentPlatform == SdkConstants.PLATFORM_WINDOWS) {
                        if (ep.getVendorId().equals("google") &&            //$NON-NLS-1$
                                ep.getPath().equals("usb_driver")) {        //$NON-NLS-1$
                            item.setChecked(true);
                            continue;
                        }
                    }

                    // On all platforms, we'll auto-select the support library.
                    if (ep.getVendorId().equals("android") &&               //$NON-NLS-1$
                            ep.getPath().equals("support")) {               //$NON-NLS-1$
                        item.setChecked(true);
                        continue;
                    }

                }
            }
        }
    }

    /**
     * Mark all PkgItems as not checked.
     */
    public void uncheckAllItems() {
        for (PkgItem item : getAllPkgItems(true /*byApi*/, true /*bySource*/)) {
            item.setChecked(false);
        }
    }

    /**
     * An update operation, customized to either sort by API or sort by source.
     */
    abstract class UpdateOp {
        private final Set<SdkSource> mVisitedSources = new HashSet<SdkSource>();
        private final List<PkgCategory> mCategories = new ArrayList<PkgCategory>();
        private final Set<PkgCategory> mCatsToRemove = new HashSet<PkgCategory>();
        private final Set<PkgItem> mItemsToRemove = new HashSet<PkgItem>();
        private final Map<Package, PkgItem> mUpdatesToRemove = new HashMap<Package, PkgItem>();

        /** Removes all internal state. */
        public void clear() {
            mVisitedSources.clear();
            mCategories.clear();
        }

        /** Retrieve the sorted category list. */
        public List<PkgCategory> getCategories() {
            return mCategories;
        }

        /** Retrieve the category key for the given package, either local or remote. */
        public abstract Object getCategoryKey(Package pkg);

        /** Modified {@code currentCategories} to add default categories. */
        public abstract void addDefaultCategories();

        /** Creates the category for the given key and returns it. */
        public abstract PkgCategory createCategory(Object catKey);
        /** Adjust attributes of an existing category. */
        public abstract void adjustCategory(PkgCategory cat, Object catKey);

        /** Sorts the category list (but not the items within the categories.) */
        public abstract void sortCategoryList();

        /** Called after items of a given category have changed. Used to sort the
         * items and/or adjust the category name. */
        public abstract void postCategoryItemsChanged();

        public void updateStart() {
            mVisitedSources.clear();

            // Note that default categories are created after the unused ones so that
            // the callback can decide whether they should be marked as unused or not.
            mCatsToRemove.clear();
            mItemsToRemove.clear();
            mUpdatesToRemove.clear();
            for (PkgCategory cat : mCategories) {
                mCatsToRemove.add(cat);
                List<PkgItem> items = cat.getItems();
                mItemsToRemove.addAll(items);
                for (PkgItem item : items) {
                    if (item.hasUpdatePkg()) {
                        mUpdatesToRemove.put(item.getUpdatePkg(), item);
                    }
                }
            }

            addDefaultCategories();
        }

        public boolean updateSourcePackages(SdkSource source, Package[] newPackages) {
            mVisitedSources.add(source);
            if (source == null) {
                return processLocals(this, newPackages);
            } else {
                return processSource(this, source, newPackages);
            }
        }

        public boolean updateEnd() {
            boolean hasChanged = false;

            // Remove unused categories & items at the end of the update
            synchronized (mCategories) {
                for (PkgCategory unusedCat : mCatsToRemove) {
                    if (mCategories.remove(unusedCat)) {
                        hasChanged  = true;
                    }
                }
            }

            for (PkgCategory cat : mCategories) {
                for (Iterator<PkgItem> itemIt = cat.getItems().iterator(); itemIt.hasNext(); ) {
                    PkgItem item = itemIt.next();
                    if (mItemsToRemove.contains(item)) {
                        itemIt.remove();
                        hasChanged  = true;
                    } else if (item.hasUpdatePkg() &&
                            mUpdatesToRemove.containsKey(item.getUpdatePkg())) {
                        item.removeUpdate();
                        hasChanged  = true;
                    }
                }
            }

            mCatsToRemove.clear();
            mItemsToRemove.clear();
            mUpdatesToRemove.clear();

            return hasChanged;
        }

        public boolean isKeep(PkgItem item) {
            return !mItemsToRemove.contains(item);
        }

        public void keep(Package pkg) {
            mUpdatesToRemove.remove(pkg);
        }

        public void keep(PkgItem item) {
            mItemsToRemove.remove(item);
        }

        public void keep(PkgCategory cat) {
            mCatsToRemove.remove(cat);
        }

        public void dontKeep(PkgItem item) {
            mItemsToRemove.add(item);
        }

        public void dontKeep(PkgCategory cat) {
            mCatsToRemove.add(cat);
        }
    }

    private final UpdateOpApi    mOpApi    = new UpdateOpApi();
    private final UpdateOpSource mOpSource = new UpdateOpSource();

    public List<PkgCategory> getCategories(boolean displayIsSortByApi) {
        return displayIsSortByApi ? mOpApi.getCategories() : mOpSource.getCategories();
    }

    public List<PkgItem> getAllPkgItems(boolean byApi, boolean bySource) {
        List<PkgItem> items = new ArrayList<PkgItem>();

        if (byApi) {
            List<PkgCategory> cats = getCategories(true /*displayIsSortByApi*/);
            synchronized (cats) {
                for (PkgCategory cat : cats) {
                    items.addAll(cat.getItems());
                }
            }
        }

        if (bySource) {
            List<PkgCategory> cats = getCategories(false /*displayIsSortByApi*/);
            synchronized (cats) {
                for (PkgCategory cat : cats) {
                    items.addAll(cat.getItems());
                }
            }
        }

        return items;
    }

    public void updateStart() {
        mOpApi.updateStart();
        mOpSource.updateStart();
    }

    public boolean updateSourcePackages(
            boolean displayIsSortByApi,
            SdkSource source,
            Package[] newPackages) {

        boolean apiListChanged = mOpApi.updateSourcePackages(source, newPackages);
        boolean sourceListChanged = mOpSource.updateSourcePackages(source, newPackages);
        return displayIsSortByApi ? apiListChanged : sourceListChanged;
    }

    public boolean updateEnd(boolean displayIsSortByApi) {
        boolean apiListChanged = mOpApi.updateEnd();
        boolean sourceListChanged = mOpSource.updateEnd();
        return displayIsSortByApi ? apiListChanged : sourceListChanged;
    }


    /** Process all local packages. Returns true if something changed. */
    private boolean processLocals(UpdateOp op, Package[] packages) {
        boolean hasChanged = false;
        List<PkgCategory> cats = op.getCategories();
        Set<PkgItem> keep = new HashSet<PkgItem>();

        // For all locally installed packages, check they are either listed
        // as installed or create new installed items for them.

        nextPkg: for (Package localPkg : packages) {
            // Check to see if we already have the exact same package
            // (type & revision) marked as installed.
            for (PkgCategory cat : cats) {
                for (PkgItem currItem : cat.getItems()) {
                    if (currItem.getState() == PkgState.INSTALLED &&
                            currItem.isSameMainPackageAs(localPkg)) {
                        // This package is already listed as installed.
                        op.keep(currItem);
                        op.keep(cat);
                        keep.add(currItem);
                        continue nextPkg;
                    }
                }
            }

            // If not found, create a new installed package item
            keep.add(addNewItem(op, localPkg, PkgState.INSTALLED));
            hasChanged = true;
        }

        // Remove installed items that we don't want to keep anymore. They would normally be
        // cleanup up in UpdateOp.updateEnd(); however it's easier to remove them before we
        // run processSource() to avoid merging updates in items that would be removed later.

        for (PkgCategory cat : cats) {
            for (Iterator<PkgItem> itemIt = cat.getItems().iterator(); itemIt.hasNext(); ) {
                PkgItem item = itemIt.next();
                if (item.getState() == PkgState.INSTALLED && !keep.contains(item)) {
                    itemIt.remove();
                    hasChanged = true;
                }
            }
        }

        if (hasChanged) {
            op.postCategoryItemsChanged();
        }

        return hasChanged;
    }

    /**
     * {@link PkgState}s to check in {@link #processSource(UpdateOp, SdkSource, Package[])}.
     * The order matters.
     * When installing the diff will have both the new and the installed item and we
     * need to merge with the installed one before the new one.
     */
    private final static PkgState[] PKG_STATES = { PkgState.INSTALLED, PkgState.NEW };

    /** Process all remote packages. Returns true if something changed. */
    private boolean processSource(UpdateOp op, SdkSource source, Package[] packages) {
        boolean hasChanged = false;
        List<PkgCategory> cats = op.getCategories();

        boolean enablePreviews =
            mUpdaterData.getSettingsController().getSettings().getEnablePreviews();

        nextPkg: for (Package newPkg : packages) {

            if (!enablePreviews && newPkg.getRevision().isPreview()) {
                // This is a preview and previews are not enabled. Ignore the package.
                continue nextPkg;
            }

            for (PkgCategory cat : cats) {
                for (PkgState state : PKG_STATES) {
                    for (Iterator<PkgItem> currItemIt = cat.getItems().iterator();
                                           currItemIt.hasNext(); ) {
                        PkgItem currItem = currItemIt.next();
                        // We need to merge with installed items first. When installing
                        // the diff will have both the new and the installed item and we
                        // need to merge with the installed one before the new one.
                        if (currItem.getState() != state) {
                            continue;
                        }
                        // Only process current items if they represent the same item (but
                        // with a different revision number) than the new package.
                        Package mainPkg = currItem.getMainPackage();
                        if (!mainPkg.sameItemAs(newPkg)) {
                            continue;
                        }

                        // Check to see if we already have the exact same package
                        // (type & revision) marked as main or update package.
                        if (currItem.isSameMainPackageAs(newPkg)) {
                            op.keep(currItem);
                            op.keep(cat);
                            continue nextPkg;
                        } else if (currItem.hasUpdatePkg() &&
                                currItem.isSameUpdatePackageAs(newPkg)) {
                            op.keep(currItem.getUpdatePkg());
                            op.keep(cat);
                            continue nextPkg;
                        }

                        switch (currItem.getState()) {
                        case NEW:
                            if (newPkg.getRevision().compareTo(mainPkg.getRevision()) < 0) {
                                if (!op.isKeep(currItem)) {
                                    // The new item has a lower revision than the current one,
                                    // but the current one hasn't been marked as being kept so
                                    // it's ok to downgrade it.
                                    currItemIt.remove();
                                    addNewItem(op, newPkg, PkgState.NEW);
                                    hasChanged = true;
                                }
                            } else if (newPkg.getRevision().compareTo(mainPkg.getRevision()) > 0) {
                                // We have a more recent new version, remove the current one
                                // and replace by a new one
                                currItemIt.remove();
                                addNewItem(op, newPkg, PkgState.NEW);
                                hasChanged = true;
                            }
                            break;
                        case INSTALLED:
                            // if newPkg.revision<=mainPkg.revision: it's already installed, ignore.
                            if (newPkg.getRevision().compareTo(mainPkg.getRevision()) > 0) {
                                // This is a new update for the main package.
                                if (currItem.mergeUpdate(newPkg)) {
                                    op.keep(currItem.getUpdatePkg());
                                    op.keep(cat);
                                    hasChanged = true;
                                }
                            }
                            break;
                        }
                        continue nextPkg;
                    }
                }
            }
            // If not found, create a new package item
            addNewItem(op, newPkg, PkgState.NEW);
            hasChanged = true;
        }

        if (hasChanged) {
            op.postCategoryItemsChanged();
        }

        return hasChanged;
    }

    private PkgItem addNewItem(UpdateOp op, Package pkg, PkgState state) {
        List<PkgCategory> cats = op.getCategories();
        Object catKey = op.getCategoryKey(pkg);
        PkgCategory cat = findCurrentCategory(cats, catKey);

        if (cat == null) {
            // This is a new category. Create it and add it to the list.
            cat = op.createCategory(catKey);
            synchronized (cats) {
                cats.add(cat);
            }
            op.sortCategoryList();
        } else {
            // Not a new category. Give op a chance to adjust the category attributes
            op.adjustCategory(cat, catKey);
        }

        PkgItem item = new PkgItem(pkg, state);
        op.keep(item);
        cat.getItems().add(item);
        op.keep(cat);
        return item;
    }

    private PkgCategory findCurrentCategory(
            List<PkgCategory> currentCategories,
            Object categoryKey) {
        for (PkgCategory cat : currentCategories) {
            if (cat.getKey().equals(categoryKey)) {
                return cat;
            }
        }
        return null;
    }

    /**
     * {@link UpdateOp} describing the Sort-by-API operation.
     */
    private class UpdateOpApi extends UpdateOp {
        @Override
        public Object getCategoryKey(Package pkg) {
            // Sort by API

            if (pkg instanceof IAndroidVersionProvider) {
                return ((IAndroidVersionProvider) pkg).getAndroidVersion();

            } else if (pkg instanceof ToolPackage ||
                    pkg instanceof PlatformToolPackage ||
                    pkg instanceof BuildToolPackage) {
                if (pkg.getRevision().isPreview()) {
                    return PkgCategoryApi.KEY_TOOLS_PREVIEW;
                } else {
                    return PkgCategoryApi.KEY_TOOLS;
                }
            } else {
                return PkgCategoryApi.KEY_EXTRA;
            }
        }

        @Override
        public void addDefaultCategories() {
            boolean needTools = true;
            boolean needExtras = true;

            List<PkgCategory> cats = getCategories();
            for (PkgCategory cat : cats) {
                if (cat.getKey().equals(PkgCategoryApi.KEY_TOOLS)) {
                    // Mark them as not unused to prevent their removal in updateEnd().
                    keep(cat);
                    needTools = false;
                } else if (cat.getKey().equals(PkgCategoryApi.KEY_EXTRA)) {
                    keep(cat);
                    needExtras = false;
                }
            }

            // Always add the tools & extras categories, even if empty (unlikely anyway)
            if (needTools) {
                PkgCategoryApi acat = new PkgCategoryApi(
                   PkgCategoryApi.KEY_TOOLS,
                   null,
                   mUpdaterData.getImageFactory().getImageByName(PackagesPageIcons.ICON_CAT_OTHER));
                synchronized (cats) {
                    cats.add(acat);
                }
            }

            if (needExtras) {
                PkgCategoryApi acat = new PkgCategoryApi(
                   PkgCategoryApi.KEY_EXTRA,
                   null,
                   mUpdaterData.getImageFactory().getImageByName(PackagesPageIcons.ICON_CAT_OTHER));
                synchronized (cats) {
                    cats.add(acat);
                }
            }
        }

        @Override
        public PkgCategory createCategory(Object catKey) {
            // Create API category.
            PkgCategory cat = null;

            assert catKey instanceof AndroidVersion;
            AndroidVersion key = (AndroidVersion) catKey;

            // We should not be trying to recreate the tools or extra categories.
            assert !key.equals(PkgCategoryApi.KEY_TOOLS) && !key.equals(PkgCategoryApi.KEY_EXTRA);

            // We need a label for the category.
            // If we have an API level, try to get the info from the SDK Manager.
            // If we don't (e.g. when installing a new platform that isn't yet available
            // locally in the SDK Manager), it's OK we'll try to find the first platform
            // package available.
            String platformName = null;
            for (IAndroidTarget target :
                    mUpdaterData.getSdkManager().getTargets()) {
                if (target.isPlatform() && key.equals(target.getVersion())) {
                    platformName = target.getVersionName();
                    break;
                }
            }

            cat = new PkgCategoryApi(
                key,
                platformName,
                mUpdaterData.getImageFactory().getImageByName(PackagesPageIcons.ICON_CAT_PLATFORM));

            return cat;
        }

        @Override
        public void adjustCategory(PkgCategory cat, Object catKey) {
            // Pass. Nothing to do for API-sorted categories
        }

        @Override
        public void sortCategoryList() {
            // Sort the categories list.
            // We always want categories in order tools..platforms..extras.
            // For platform, we compare in descending order (o2-o1).
            // This order is achieved by having the category keys ordered as
            // needed for the sort to just do what we expect.

            synchronized (getCategories()) {
                Collections.sort(getCategories(), new Comparator<PkgCategory>() {
                    @Override
                    public int compare(PkgCategory cat1, PkgCategory cat2) {
                        assert cat1 instanceof PkgCategoryApi;
                        assert cat2 instanceof PkgCategoryApi;
                        assert cat1.getKey() instanceof AndroidVersion;
                        assert cat2.getKey() instanceof AndroidVersion;
                        AndroidVersion v1 = (AndroidVersion) cat1.getKey();
                        AndroidVersion v2 = (AndroidVersion) cat2.getKey();
                        return v2.compareTo(v1);
                    }
                });
            }
        }

        @Override
        public void postCategoryItemsChanged() {
            // Sort the items
            for (PkgCategory cat : getCategories()) {
                Collections.sort(cat.getItems());

                // When sorting by API, we can't always get the platform name
                // from the package manager. In this case at the very end we
                // look for a potential platform package we can use to extract
                // the platform version name (e.g. '1.5') from the first suitable
                // platform package we can find.

                assert cat instanceof PkgCategoryApi;
                PkgCategoryApi pac = (PkgCategoryApi) cat;
                if (pac.getPlatformName() == null) {
                    // Check whether we can get the actual platform version name (e.g. "1.5")
                    // from the first Platform package we find in this category.

                    for (PkgItem item : cat.getItems()) {
                        Package p = item.getMainPackage();
                        if (p instanceof PlatformPackage) {
                            String platformName = ((PlatformPackage) p).getVersionName();
                            if (platformName != null) {
                                pac.setPlatformName(platformName);
                                break;
                            }
                        }
                    }
                }
            }

        }
    }

    /**
     * {@link UpdateOp} describing the Sort-by-Source operation.
     */
    private class UpdateOpSource extends UpdateOp {

        @Override
        public boolean updateSourcePackages(SdkSource source, Package[] newPackages) {
            // When displaying the repo by source, we want to create all the
            // categories so that they can appear on the UI even if empty.
            if (source != null) {
                List<PkgCategory> cats = getCategories();
                Object catKey = source;
                PkgCategory cat = findCurrentCategory(cats, catKey);

                if (cat == null) {
                    // This is a new category. Create it and add it to the list.
                    cat = createCategory(catKey);
                    synchronized (cats) {
                        cats.add(cat);
                    }
                    sortCategoryList();
                }

                keep(cat);
            }

            return super.updateSourcePackages(source, newPackages);
        }

        @Override
        public Object getCategoryKey(Package pkg) {
            // Sort by source
            SdkSource source = pkg.getParentSource();
            if (source == null) {
                return PkgCategorySource.UNKNOWN_SOURCE;
            }
            return source;
        }

        @Override
        public void addDefaultCategories() {
            List<PkgCategory> cats = getCategories();
            for (PkgCategory cat : cats) {
                if (cat.getKey().equals(PkgCategorySource.UNKNOWN_SOURCE)) {
                    // Already present.
                    return;
                }
            }

            // Always add the local categories, even if empty (unlikely anyway)
            PkgCategorySource cat = new PkgCategorySource(
                    PkgCategorySource.UNKNOWN_SOURCE,
                    mUpdaterData);
            // Mark it so that it can be cleared in updateEnd() if not used.
            dontKeep(cat);
            synchronized (cats) {
                cats.add(cat);
            }
        }

        /**
         * Create a new source category.
         * <p/>
         * One issue is that local archives are processed first and we don't have the
         * full source information on them (e.g. we know the referral URL but not
         * the referral name of the site).
         * In this case this will just create {@link PkgCategorySource} where the label isn't
         * known yet.
         */
        @Override
        public PkgCategory createCategory(Object catKey) {
            assert catKey instanceof SdkSource;
            PkgCategory cat = new PkgCategorySource((SdkSource) catKey, mUpdaterData);
            return cat;
        }

        /**
         * Checks whether the category needs to be adjust.
         * As mentioned in {@link #createCategory(Object)}, local archives are processed
         * first and result in a {@link PkgCategorySource} where the label isn't known.
         * Once we process the external source with the actual name, we'll update it.
         */
        @Override
        public void adjustCategory(PkgCategory cat, Object catKey) {
            assert cat instanceof PkgCategorySource;
            assert catKey instanceof SdkSource;
            if (cat instanceof PkgCategorySource) {
                ((PkgCategorySource) cat).adjustLabel((SdkSource) catKey);
            }
        }

        @Override
        public void sortCategoryList() {
            // Sort the sources in ascending source name order,
            // with the local packages always first.

            synchronized (getCategories()) {
                Collections.sort(getCategories(), new Comparator<PkgCategory>() {
                    @Override
                    public int compare(PkgCategory cat1, PkgCategory cat2) {
                        assert cat1 instanceof PkgCategorySource;
                        assert cat2 instanceof PkgCategorySource;

                        SdkSource src1 = ((PkgCategorySource) cat1).getSource();
                        SdkSource src2 = ((PkgCategorySource) cat2).getSource();

                        if (src1 == src2) {
                            return 0;
                        } else if (src1 == PkgCategorySource.UNKNOWN_SOURCE) {
                            return -1;
                        } else if (src2 == PkgCategorySource.UNKNOWN_SOURCE) {
                            return 1;
                        }
                        assert src1 != null; // true because LOCAL_SOURCE==null
                        assert src2 != null;
                        return src1.toString().compareTo(src2.toString());
                    }
                });
            }
        }

        @Override
        public void postCategoryItemsChanged() {
            // Sort the items
            for (PkgCategory cat : getCategories()) {
                Collections.sort(cat.getItems());
            }
        }
    }
}
