/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.gecko.sync.repositories.android;

import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import org.mozilla.gecko.background.common.log.Logger;
import org.mozilla.gecko.db.BrowserContract;
import org.mozilla.gecko.sync.repositories.NullCursorException;
import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionStoreDelegate;

/**
 * Queue up deletions. Process them at the end.
 *
 * Algorithm:
 *
 * * Collect GUIDs as we go. For convenience we partition these into
 *   folders and non-folders.
 *
 * * Non-folders can be deleted in batches as we go.
 *
 * * At the end of the sync:
 *   * Delete all that aren't folders.
 *   * Move the remaining children of any that are folders to an "Orphans" folder.
 *     - We do this even for children that are _marked_ as deleted -- we still want
 *       to upload them, and their parent is irrelevant.
 *   * Delete all the folders.
 *
 * * Any outstanding records -- the ones we moved to "Orphans" -- are true orphans.
 *   These should be reuploaded (because their parent has changed), as should their
 *   new parent (because its children array has changed).
 *   We achieve the former by moving them without tracking (but we don't make any
 *   special effort here -- warning! Lurking bug!).
 *   We achieve the latter by bumping its mtime. The caller should take care of untracking it.
 *
 * Note that we make no particular effort to handle repositioning or reparenting:
 * batching deletes at the end should be handled seamlessly by existing code,
 * because the deleted records could have arrived in a batch at the end regardless.
 *
 * Note that this class is not thread safe. This should be fine: call it only
 * from within a store runnable.
 *
 */
public class BookmarksDeletionManager {
  private static final String LOG_TAG = "BookmarkDelete";

  private final BookmarksDataAccessor dataAccessor;

  private final int flushThreshold;

  private final HashSet<String> folders    = new HashSet<String>();
  private final HashSet<String> nonFolders = new HashSet<String>();
  private int nonFolderCount = 0;

  // Records that we need to touch once we've deleted the non-folders.
  private HashSet<String> nonFolderParents = new HashSet<String>();
  private HashSet<String> folderParents    = new HashSet<String>();

  /**
   * Create an instance to be used for tracking deletions in a bookmarks
   * repository session.
   *
   * @param dataAccessor
   *        Used to effect database changes.
   *
   * @param flushThreshold
   *        When this many non-folder records have been stored for deletion,
   *        an incremental flush occurs.
   */
  public BookmarksDeletionManager(BookmarksDataAccessor dataAccessor, int flushThreshold) {
    this.dataAccessor = dataAccessor;
    this.flushThreshold = flushThreshold;
  }

  public void deleteRecord(RepositorySessionStoreDelegate delegate, String guid, boolean isFolder, String parentGUID) {
    if (guid == null) {
      Logger.warn(LOG_TAG, "Cannot queue deletion of record with no GUID.");
      return;
    }
    Logger.debug(LOG_TAG, "Queuing deletion of " + guid);

    if (isFolder) {
      folders.add(guid);
      if (!folders.contains(parentGUID)) {
        // We're not going to delete its parent; will need to bump it.
        folderParents.add(parentGUID);
      }

      nonFolderParents.remove(guid);
      folderParents.remove(guid);
      return;
    }

    if (!folders.contains(parentGUID)) {
      // We're not going to delete its parent; will need to bump it.
      nonFolderParents.add(parentGUID);
    }

    if (nonFolders.add(guid)) {
      if (++nonFolderCount >= flushThreshold) {
        deleteNonFolders(delegate);
      }
    }
  }

  /**
   * Flush deletions that can be easily taken care of right now.
   */
  public void incrementalFlush(RepositorySessionStoreDelegate delegate) {
    // Yes, this means we only bump when we finish, not during an incremental flush.
    deleteNonFolders(delegate);
  }

  /**
   * Apply all pending deletions and reset state for the next batch of stores.
   *
   * @param orphanDestination the ID of the folder to which orphaned children
   *                          should be moved.
   *
   * @throws NullCursorException
   * @return a set of IDs to untrack. Will not be null.
   */
  public Set<String> flushAll(RepositorySessionStoreDelegate delegate, long orphanDestination, long now) throws NullCursorException {
    Logger.debug(LOG_TAG, "Doing complete flush of deleted items. Moving orphans to " + orphanDestination);
    deleteNonFolders(delegate);

    // Find out which parents *won't* be deleted, and thus need to have their
    // modified times bumped.
    nonFolderParents.removeAll(folders);

    Logger.debug(LOG_TAG, "Bumping modified times for " + nonFolderParents.size() +
                          " parents of deleted non-folders.");
    dataAccessor.bumpModifiedByGUID(nonFolderParents, now);

    if (folders.size() > 0) {
      final String[] folderGUIDs = folders.toArray(new String[folders.size()]);
      final String[] folderIDs = getIDs(folderGUIDs);   // Throws if any don't exist.
      int moved = dataAccessor.moveChildren(folderIDs, orphanDestination);
      if (moved > 0) {
        dataAccessor.bumpModified(orphanDestination, now);
      }

      // We've deleted or moved anything that might be under these folders.
      // Just delete them.
      final String folderWhere = RepoUtils.computeSQLInClause(folders.size(), BrowserContract.Bookmarks.GUID);
      dataAccessor.delete(folderWhere, folderGUIDs);
      invokeCallbacks(delegate, folderGUIDs);

      folderParents.removeAll(folders);
      Logger.debug(LOG_TAG, "Bumping modified times for " + folderParents.size() +
                            " parents of deleted folders.");
      dataAccessor.bumpModifiedByGUID(folderParents, now);

      // Clean up.
      folders.clear();
    }

    HashSet<String> ret = nonFolderParents;
    ret.addAll(folderParents);

    nonFolderParents = new HashSet<String>();
    folderParents    = new HashSet<String>();
    return ret;
  }

  private String[] getIDs(String[] guids) throws NullCursorException {
    // Convert GUIDs to numeric IDs.
    String[] ids = new String[guids.length];
    Map<String, Long> guidsToIDs = dataAccessor.idsForGUIDs(guids);
    for (int i = 0; i < guids.length; ++i) {
      String guid = guids[i];
      Long id =  guidsToIDs.get(guid);
      if (id == null) {
        throw new IllegalArgumentException("Can't get ID for unknown record " + guid);
      }
      ids[i] = id.toString();
    }
    return ids;
  }

  /**
   * Flush non-folder deletions. This can be called at any time.
   */
  private void deleteNonFolders(RepositorySessionStoreDelegate delegate) {
    if (nonFolderCount == 0) {
      Logger.debug(LOG_TAG, "No non-folders to delete.");
      return;
    }

    Logger.debug(LOG_TAG, "Applying deletion of " + nonFolderCount + " non-folders.");
    final String[] nonFolderGUIDs = nonFolders.toArray(new String[nonFolderCount]);
    final String nonFolderWhere = RepoUtils.computeSQLInClause(nonFolderCount, BrowserContract.Bookmarks.GUID);
    dataAccessor.delete(nonFolderWhere, nonFolderGUIDs);

    invokeCallbacks(delegate, nonFolderGUIDs);

    // Discard these.
    // Note that we maintain folderParents and nonFolderParents; we need them later.
    nonFolders.clear();
    nonFolderCount = 0;
  }

  private void invokeCallbacks(RepositorySessionStoreDelegate delegate,
                               String[] nonFolderGUIDs) {
    if (delegate == null) {
      return;
    }
    Logger.trace(LOG_TAG, "Invoking store callback for " + nonFolderGUIDs.length + " GUIDs.");
    for (String guid : nonFolderGUIDs) {
      delegate.onRecordStoreSucceeded(guid);
    }
  }

  /**
   * Clear state in case of redundancy (e.g., wipe).
   */
  public void clear() {
    nonFolders.clear();
    nonFolderCount = 0;
    folders.clear();
    nonFolderParents.clear();
    folderParents.clear();
  }
}
