/* 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.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;

import org.mozilla.gecko.background.common.log.Logger;
import org.mozilla.gecko.sync.Utils;
import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionStoreDelegate;
import org.mozilla.gecko.sync.repositories.domain.BookmarkRecord;

/**
 * Queue up insertions:
 * <ul>
 * <li>Folder inserts where the parent is known. Do these immediately, because
 * they allow other records to be inserted. Requires bookkeeping updates. On
 * insert, flush the next set.</li>
 * <li>Regular inserts where the parent is known. These can happen whenever.
 * Batch for speed.</li>
 * <li>Records where the parent is not known. These can be flushed out when the
 * parent is known, or entered as orphans. This can be a queue earlier in the
 * process, so they don't get assigned to Unsorted. Feed into the main batch
 * when the parent arrives.</li>
 * </ul>
 * <p>
 * Deletions are always done at the end so that orphaning is minimized, and
 * that's why we are batching folders and non-folders separately.
 * <p>
 * Updates are always applied as they arrive.
 * <p>
 * Note that this class is not thread safe. This should be fine: call it only
 * from within a store runnable.
 */
public class BookmarksInsertionManager {
  public static final String LOG_TAG = "BookmarkInsert";
  public static boolean DEBUG = false;

  protected final int flushThreshold;
  protected final BookmarkInserter inserter;

  /**
   * Folders that have been successfully inserted.
   */
  private final Set<String> insertedFolders = new HashSet<String>();

  /**
   * Non-folders waiting for bulk insertion.
   * <p>
   * We write in insertion order to keep things easy to debug.
   */
  private final Set<BookmarkRecord> nonFoldersToWrite = new LinkedHashSet<BookmarkRecord>();

  /**
   * Map from parent folder GUID to child records (folders and non-folders)
   * waiting to be enqueued after parent folder is inserted.
   */
  private final Map<String, Set<BookmarkRecord>> recordsWaitingForParent = new HashMap<String, Set<BookmarkRecord>>();

  /**
   * Create an instance to be used for tracking insertions in a bookmarks
   * repository session.
   *
   * @param flushThreshold
   *        When this many non-folder records have been stored for insertion,
   *        an incremental flush occurs.
   * @param insertedFolders
   *        The GUIDs of all the folders already inserted into the database.
   * @param inserter
   *        The <code>BookmarkInsert</code> to use.
   */
  public BookmarksInsertionManager(int flushThreshold, Collection<String> insertedFolders, BookmarkInserter inserter) {
    this.flushThreshold = flushThreshold;
    this.insertedFolders.addAll(insertedFolders);
    this.inserter = inserter;
  }

  protected void addRecordWithUnwrittenParent(BookmarkRecord record) {
    Set<BookmarkRecord> destination = recordsWaitingForParent.get(record.parentID);
    if (destination == null) {
      destination = new LinkedHashSet<BookmarkRecord>();
      recordsWaitingForParent.put(record.parentID, destination);
    }
    destination.add(record);
  }

  /**
   * If <code>record</code> is a folder, insert it immediately; if it is a
   * non-folder, enqueue it. Then do the same for any records waiting for this record.
   *
   * @param record
   *          the <code>BookmarkRecord</code> to enqueue.
   */
  protected void recursivelyEnqueueRecordAndChildren(RepositorySessionStoreDelegate delegate, BookmarkRecord record) {
    if (record.isFolder()) {
      if (!inserter.insertFolder(delegate, record)) {
        Logger.warn(LOG_TAG, "Folder with known parent with guid " + record.parentID + " failed to insert!");
        return;
      }
      Logger.debug(LOG_TAG, "Folder with known parent with guid " + record.parentID + " inserted; adding to inserted folders.");
      insertedFolders.add(record.guid);
    } else {
      Logger.debug(LOG_TAG, "Non-folder has known parent with guid " + record.parentID + "; adding to insertion queue.");
      nonFoldersToWrite.add(record);
    }

    // Now process record's children.
    Set<BookmarkRecord> waiting = recordsWaitingForParent.remove(record.guid);
    if (waiting == null) {
      return;
    }
    for (BookmarkRecord waiter : waiting) {
      recursivelyEnqueueRecordAndChildren(delegate, waiter);
    }
  }

  /**
   * Enqueue a folder.
   *
   * @param record
   *          the folder to enqueue.
   */
  protected void enqueueFolder(RepositorySessionStoreDelegate delegate, BookmarkRecord record) {
    Logger.debug(LOG_TAG, "Inserting folder with guid " + record.guid);

    if (!insertedFolders.contains(record.parentID)) {
      Logger.debug(LOG_TAG, "Folder has unknown parent with guid " + record.parentID + "; keeping until we see the parent.");
      addRecordWithUnwrittenParent(record);
      return;
    }

    // Parent is known; add as much of the tree as this roots.
    recursivelyEnqueueRecordAndChildren(delegate, record);
    flushNonFoldersIfNecessary(delegate);
  }

  /**
   * Enqueue a non-folder.
   *
   * @param record
   *          the non-folder to enqueue.
   */
  protected void enqueueNonFolder(RepositorySessionStoreDelegate delegate, BookmarkRecord record) {
    Logger.debug(LOG_TAG, "Inserting non-folder with guid " + record.guid);

    if (!insertedFolders.contains(record.parentID)) {
      Logger.debug(LOG_TAG, "Non-folder has unknown parent with guid " + record.parentID + "; keeping until we see the parent.");
      addRecordWithUnwrittenParent(record);
      return;
    }

    // Parent is known; add to insertion queue and maybe write.
    Logger.debug(LOG_TAG, "Non-folder has known parent with guid " + record.parentID + "; adding to insertion queue.");
    nonFoldersToWrite.add(record);
    flushNonFoldersIfNecessary(delegate);
  }

  /**
   * Enqueue a bookmark record for eventual insertion.
   *
   * @param record
   *          the <code>BookmarkRecord</code> to enqueue.
   */
  public void enqueueRecord(RepositorySessionStoreDelegate delegate, BookmarkRecord record) {
    if (record.isFolder()) {
      enqueueFolder(delegate, record);
    } else {
      enqueueNonFolder(delegate, record);
    }
    if (DEBUG) {
      dumpState();
    }
  }

  /**
   * Flush non-folders; empties the insertion queue entirely.
   */
  protected void flushNonFolders(RepositorySessionStoreDelegate delegate) {
    inserter.bulkInsertNonFolders(delegate, nonFoldersToWrite); // All errors are handled in bulkInsertNonFolders.
    nonFoldersToWrite.clear();
  }

  /**
   * Flush non-folder insertions if there are many of them; empties the
   * insertion queue entirely.
   */
  protected void flushNonFoldersIfNecessary(RepositorySessionStoreDelegate delegate) {
    int num = nonFoldersToWrite.size();
    if (num < flushThreshold) {
      Logger.debug(LOG_TAG, "Incremental flush called with " + num + " < " + flushThreshold + " non-folders; not flushing.");
      return;
    }
    Logger.debug(LOG_TAG, "Incremental flush called with " + num + " non-folders; flushing.");
    flushNonFolders(delegate);
  }

  /**
   * Insert all remaining folders followed by all remaining non-folders,
   * regardless of whether parent records have been successfully inserted.
   */
  public void finishUp(RepositorySessionStoreDelegate delegate) {
    // Iterate through all waiting records, writing the folders and collecting
    // the non-folders for bulk insertion.
    int numFolders = 0;
    int numNonFolders = 0;
    for (Set<BookmarkRecord> records : recordsWaitingForParent.values()) {
      for (BookmarkRecord record : records) {
        if (!record.isFolder()) {
          numNonFolders += 1;
          nonFoldersToWrite.add(record);
          continue;
        }

        numFolders += 1;
        if (!inserter.insertFolder(delegate, record)) {
          Logger.warn(LOG_TAG, "Folder with known parent with guid " + record.parentID + " failed to insert!");
          continue;
        }

        Logger.debug(LOG_TAG, "Folder with known parent with guid " + record.parentID + " inserted; adding to inserted folders.");
        insertedFolders.add(record.guid);
      }
    }
    recordsWaitingForParent.clear();
    flushNonFolders(delegate);

    Logger.debug(LOG_TAG, "finishUp inserted " +
        numFolders + " folders without known parents and " +
        numNonFolders + " non-folders without known parents.");
    if (DEBUG) {
      dumpState();
    }
  }

  public void clear() {
    this.insertedFolders.clear();
    this.nonFoldersToWrite.clear();
    this.recordsWaitingForParent.clear();
  }

  // For debugging.
  public boolean isClear() {
    return nonFoldersToWrite.isEmpty() && recordsWaitingForParent.isEmpty();
  }

  // For debugging.
  public void dumpState() {
    ArrayList<String> readies = new ArrayList<String>();
    for (BookmarkRecord record : nonFoldersToWrite) {
      readies.add(record.guid);
    }
    String ready = Utils.toCommaSeparatedString(new ArrayList<String>(readies));

    ArrayList<String> waits = new ArrayList<String>();
    for (Set<BookmarkRecord> recs : recordsWaitingForParent.values()) {
      for (BookmarkRecord rec : recs) {
        waits.add(rec.guid);
      }
    }
    String waiting = Utils.toCommaSeparatedString(waits);
    String known = Utils.toCommaSeparatedString(insertedFolders);

    Logger.debug(LOG_TAG, "Q=(" + ready + "), W = (" + waiting + "), P=(" + known + ")");
  }

  public interface BookmarkInserter {
    /**
     * Insert a single folder.
     * <p>
     * All exceptions should be caught and all delegate callbacks invoked here.
     *
     * @param record
     *          the record to insert.
     * @return
     *          <code>true</code> if the folder was inserted; <code>false</code> otherwise.
     */
    public boolean insertFolder(RepositorySessionStoreDelegate delegate, BookmarkRecord record);

    /**
     * Insert many non-folders. Each non-folder's parent was already present in
     * the database before this <code>BookmarkInsertionsManager</code> was
     * created, or had <code>insertFolder</code> called with it as argument (and
     * possibly was not inserted).
     * <p>
     * All exceptions should be caught and all delegate callbacks invoked here.
     *
     * @param records
     *          the records to insert.
     */
    public void bulkInsertNonFolders(RepositorySessionStoreDelegate delegate, Collection<BookmarkRecord> records);
  }
}
