/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim:set ts=2 sw=2 sts=2 et: */
/* 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/. */

const bmsvc    = PlacesUtils.bookmarks;
const tagssvc  = PlacesUtils.tagging;
const annosvc  = PlacesUtils.annotations;
const PT       = PlacesTransactions;
const menuGuid = PlacesUtils.bookmarks.menuGuid;

Cu.importGlobalProperties(["URL"]);
ChromeUtils.defineModuleGetter(this, "Preferences",
                               "resource://gre/modules/Preferences.jsm");


// Create and add bookmarks observer.
var observer = {
  __proto__: NavBookmarkObserver.prototype,

  tagRelatedGuids: new Set(),

  reset() {
    this.itemsAdded = new Map();
    this.itemsRemoved = new Map();
    this.itemsChanged = new Map();
    this.itemsMoved = new Map();
    this.beginUpdateBatch = false;
    this.endUpdateBatch = false;
  },

  onBeginUpdateBatch() {
    this.beginUpdateBatch = true;
  },

  onEndUpdateBatch() {
    this.endUpdateBatch = true;
  },

  onItemAdded(aItemId, aParentId, aIndex, aItemType, aURI, aTitle, aDateAdded,
           aGuid, aParentGuid) {
    // Ignore tag items.
    if (aParentId == PlacesUtils.tagsFolderId ||
        (aParentId != PlacesUtils.placesRootId &&
         bmsvc.getFolderIdForItem(aParentId) == PlacesUtils.tagsFolderId)) {
      this.tagRelatedGuids.add(aGuid);
      return;
    }

    this.itemsAdded.set(aGuid, { itemId:         aItemId,
                                 parentGuid:     aParentGuid,
                                 index:          aIndex,
                                 itemType:       aItemType,
                                 title:          aTitle,
                                 url:            aURI });
  },

  onItemRemoved(aItemId, aParentId, aIndex, aItemType, aURI, aGuid, aParentGuid) {
    if (this.tagRelatedGuids.has(aGuid))
      return;

    this.itemsRemoved.set(aGuid, { parentGuid: aParentGuid,
                                   index:      aIndex,
                                   itemType:   aItemType });
  },

  onItemChanged(aItemId, aProperty, aIsAnnoProperty, aNewValue, aLastModified,
           aItemType, aParentId, aGuid, aParentGuid) {
    if (this.tagRelatedGuids.has(aGuid))
      return;

    let changesForGuid = this.itemsChanged.get(aGuid);
    if (changesForGuid === undefined) {
      changesForGuid = new Map();
      this.itemsChanged.set(aGuid, changesForGuid);
    }

    let newValue = aNewValue;
    if (aIsAnnoProperty) {
      if (annosvc.itemHasAnnotation(aItemId, aProperty))
        newValue = annosvc.getItemAnnotation(aItemId, aProperty);
      else
        newValue = null;
    }
    let change = { isAnnoProperty: aIsAnnoProperty,
                   newValue,
                   lastModified: aLastModified,
                   itemType: aItemType };
    changesForGuid.set(aProperty, change);
  },

  onItemVisited: () => {},

  onItemMoved(aItemId, aOldParent, aOldIndex, aNewParent, aNewIndex, aItemType,
           aGuid, aOldParentGuid, aNewParentGuid) {
    this.itemsMoved.set(aGuid, { oldParentGuid: aOldParentGuid,
                                 oldIndex:      aOldIndex,
                                 newParentGuid: aNewParentGuid,
                                 newIndex:      aNewIndex,
                                 itemType:      aItemType });
  }
};
observer.reset();

// index at which items should begin
var bmStartIndex = 0;

function run_test() {
  bmsvc.addObserver(observer);
  registerCleanupFunction(function() {
    bmsvc.removeObserver(observer);
  });

  run_next_test();
}

function sanityCheckTransactionHistory() {
  Assert.ok(PT.undoPosition <= PT.length);

  let check_entry_throws = f => {
    try {
      f();
      do_throw("PT.entry should throw for invalid input");
    } catch (ex) {}
  };
  check_entry_throws( () => PT.entry(-1) );
  check_entry_throws( () => PT.entry({}) );
  check_entry_throws( () => PT.entry(PT.length) );

  if (PT.undoPosition < PT.length)
    Assert.equal(PT.topUndoEntry, PT.entry(PT.undoPosition));
  else
    Assert.equal(null, PT.topUndoEntry);
  if (PT.undoPosition > 0)
    Assert.equal(PT.topRedoEntry, PT.entry(PT.undoPosition - 1));
  else
    Assert.equal(null, PT.topRedoEntry);
}

function getTransactionsHistoryState() {
  let history = [];
  for (let i = 0; i < PT.length; i++) {
    history.push(PT.entry(i));
  }
  return [history, PT.undoPosition];
}

function ensureUndoState(aExpectedEntries = [], aExpectedUndoPosition = 0) {
  // ensureUndoState is called in various places during this test, so it's
  // a good places to sanity-check the transaction-history APIs in all
  // cases.
  sanityCheckTransactionHistory();

  let [actualEntries, actualUndoPosition] = getTransactionsHistoryState();
  Assert.equal(actualEntries.length, aExpectedEntries.length);
  Assert.equal(actualUndoPosition, aExpectedUndoPosition);

  function checkEqualEntries(aExpectedEntry, aActualEntry) {
    Assert.equal(aExpectedEntry.length, aActualEntry.length);
    aExpectedEntry.forEach( (t, i) => Assert.equal(t, aActualEntry[i]) );
  }
  aExpectedEntries.forEach( (e, i) => checkEqualEntries(e, actualEntries[i]) );
}

function ensureItemsAdded(...items) {
  let expectedResultsCount = items.length;

  for (let item of items) {
    if ("children" in item) {
      expectedResultsCount += item.children.length;
    }
    Assert.ok(observer.itemsAdded.has(item.guid),
      `Should have the expected guid ${item.guid}`);
    let info = observer.itemsAdded.get(item.guid);
    Assert.equal(info.parentGuid, item.parentGuid,
      "Should have notified the correct parentGuid");
    for (let propName of ["title", "index", "itemType"]) {
      if (propName in item)
        Assert.equal(info[propName], item[propName]);
    }
    if ("url" in item)
      Assert.ok(info.url.equals(Services.io.newURI(item.url)),
        "Should have the correct url");
  }

  Assert.equal(observer.itemsAdded.size, expectedResultsCount,
    "Should have added the correct number of items");
}

function ensureItemsRemoved(...items) {
  let expectedResultsCount = items.length;

  for (let item of items) {
    // We accept both guids and full info object here.
    if (typeof(item) == "string") {
      Assert.ok(observer.itemsRemoved.has(item),
        `Should have removed the expected guid ${item}`);
    } else {
      if ("children" in item) {
        expectedResultsCount += item.children.length;
      }

      Assert.ok(observer.itemsRemoved.has(item.guid),
        `Should have removed expected guid ${item.guid}`);
      let info = observer.itemsRemoved.get(item.guid);
      Assert.equal(info.parentGuid, item.parentGuid,
        "Should have notified the correct parentGuid");
      if ("index" in item)
        Assert.equal(info.index, item.index);
    }
  }

  Assert.equal(observer.itemsRemoved.size, expectedResultsCount,
    "Should have removed the correct number of items");
}

function ensureItemsChanged(...items) {
  for (let item of items) {
    Assert.ok(observer.itemsChanged.has(item.guid));
    let changes = observer.itemsChanged.get(item.guid);
    Assert.ok(changes.has(item.property));
    let info = changes.get(item.property);
    if (!("isAnnoProperty" in item)) {
      Assert.ok(!info.isAnnoProperty);
    } else {
      Assert.equal(info.isAnnoProperty, Boolean(item.isAnnoProperty));
    }
    Assert.equal(info.newValue, item.newValue);
    if ("url" in item)
      Assert.ok(item.url.equals(info.url));
  }
}

function ensureAnnotationsSet(aGuid, aAnnos) {
  Assert.ok(observer.itemsChanged.has(aGuid));
  let changes = observer.itemsChanged.get(aGuid);
  for (let anno of aAnnos) {
    Assert.ok(changes.has(anno.name));
    let changeInfo = changes.get(anno.name);
    Assert.ok(changeInfo.isAnnoProperty);
    Assert.equal(changeInfo.newValue, anno.value);
  }
}

function ensureItemsMoved(...items) {
  Assert.ok(observer.itemsMoved.size, items.length);
  for (let item of items) {
    Assert.ok(observer.itemsMoved.has(item.guid));
    let info = observer.itemsMoved.get(item.guid);
    Assert.equal(info.oldParentGuid, item.oldParentGuid);
    Assert.equal(info.oldIndex, item.oldIndex);
    Assert.equal(info.newParentGuid, item.newParentGuid);
    Assert.equal(info.newIndex, item.newIndex);
  }
}

function ensureTimestampsUpdated(aGuid, aCheckDateAdded = false) {
  Assert.ok(observer.itemsChanged.has(aGuid));
  let changes = observer.itemsChanged.get(aGuid);
  if (aCheckDateAdded)
    Assert.ok(changes.has("dateAdded"));
  Assert.ok(changes.has("lastModified"));
}

function ensureTagsForURI(aURI, aTags) {
  let tagsSet = tagssvc.getTagsForURI(Services.io.newURI(aURI));
  Assert.equal(tagsSet.length, aTags.length);
  Assert.ok(aTags.every( t => tagsSet.includes(t)));
}

function createTestFolderInfo(title = "Test Folder", parentGuid = menuGuid,
                              children = undefined) {
  let info = { parentGuid, title };
  if (children) {
    info.children = children;
  }
  return info;
}

function isLivemarkTree(aTree) {
  return !!aTree.annos &&
         aTree.annos.some( a => a.name == PlacesUtils.LMANNO_FEEDURI );
}

async function ensureLivemarkCreatedByAddLivemark(aLivemarkGuid) {
  // This throws otherwise.
  await PlacesUtils.livemarks.getLivemark({ guid: aLivemarkGuid });
}

function removeAllDatesInTree(tree) {
  if ("lastModified" in tree) {
    delete tree.lastModified;
  }
  if ("dateAdded" in tree) {
    delete tree.dateAdded;
  }

  if (!tree.children) {
    return;
  }

  for (let child of tree.children) {
    removeAllDatesInTree(child);
  }
}

// Checks if two bookmark trees (as returned by promiseBookmarksTree) are the
// same.
// false value for aCheckParentAndPosition is ignored if aIsRestoredItem is set.
async function ensureEqualBookmarksTrees(aOriginal,
                                         aNew,
                                         aIsRestoredItem = true,
                                         aCheckParentAndPosition = false,
                                         aIgnoreAllDates = false) {
  // Note "id" is not-enumerable, and is therefore skipped by Object.keys (both
  // ours and the one at deepEqual). This is fine for us because ids are not
  // restored by Redo.
  if (aIsRestoredItem) {
    if (aIgnoreAllDates) {
      removeAllDatesInTree(aOriginal);
      removeAllDatesInTree(aNew);
    } else if (!aOriginal.lastModified) {
      // Ignore lastModified for newly created items, for performance reasons.
      aNew.lastModified = aOriginal.lastModified;
    }
    Assert.deepEqual(aOriginal, aNew);
    if (isLivemarkTree(aNew))
      await ensureLivemarkCreatedByAddLivemark(aNew.guid);
    return;
  }

  for (let property of Object.keys(aOriginal)) {
    if (property == "children") {
      Assert.equal(aOriginal.children.length, aNew.children.length);
      for (let i = 0; i < aOriginal.children.length; i++) {
        await ensureEqualBookmarksTrees(aOriginal.children[i],
                                        aNew.children[i],
                                        false,
                                        true,
                                        aIgnoreAllDates);
      }
    } else if (property == "guid") {
      // guid shouldn't be copied if the item was not restored.
      Assert.notEqual(aOriginal.guid, aNew.guid);
    } else if (property == "dateAdded") {
      // dateAdded shouldn't be copied if the item was not restored.
      Assert.ok(is_time_ordered(aOriginal.dateAdded, aNew.dateAdded));
    } else if (property == "lastModified") {
      // same same, except for the never-changed case
      if (!aOriginal.lastModified)
        Assert.ok(!aNew.lastModified);
      else
        Assert.ok(is_time_ordered(aOriginal.lastModified, aNew.lastModified));
    } else if (aCheckParentAndPosition ||
             (property != "parentGuid" && property != "index")) {
      Assert.deepEqual(aOriginal[property], aNew[property]);
    }
  }

  if (isLivemarkTree(aNew))
    await ensureLivemarkCreatedByAddLivemark(aNew.guid);
}

async function ensureBookmarksTreeRestoredCorrectly(...aOriginalBookmarksTrees) {
  for (let originalTree of aOriginalBookmarksTrees) {
    let restoredTree =
      await PlacesUtils.promiseBookmarksTree(originalTree.guid);
    await ensureEqualBookmarksTrees(originalTree, restoredTree);
  }
}

async function ensureBookmarksTreeRestoredCorrectlyExceptDates(...aOriginalBookmarksTrees) {
  for (let originalTree of aOriginalBookmarksTrees) {
    let restoredTree =
      await PlacesUtils.promiseBookmarksTree(originalTree.guid);
    await ensureEqualBookmarksTrees(originalTree, restoredTree, true, false, true);
  }
}

async function ensureNonExistent(...aGuids) {
  for (let guid of aGuids) {
    Assert.strictEqual((await PlacesUtils.promiseBookmarksTree(guid)), null);
  }
}

add_task(async function test_recycled_transactions() {
  async function ensureTransactThrowsFor(aTransaction) {
    let [txns, undoPosition] = getTransactionsHistoryState();
    try {
      await aTransaction.transact();
      do_throw("Shouldn't be able to use the same transaction twice");
    } catch (ex) { }
    ensureUndoState(txns, undoPosition);
  }

  let txn_a = PT.NewFolder(createTestFolderInfo());
  await txn_a.transact();
  ensureUndoState([[txn_a]], 0);
  await ensureTransactThrowsFor(txn_a);

  await PT.undo();
  ensureUndoState([[txn_a]], 1);
  ensureTransactThrowsFor(txn_a);

  await PT.clearTransactionsHistory();
  ensureUndoState();
  ensureTransactThrowsFor(txn_a);

  let txn_b = PT.NewFolder(createTestFolderInfo());
  await PT.batch(async function() {
    try {
      await txn_a.transact();
      do_throw("Shouldn't be able to use the same transaction twice");
    } catch (ex) { }
    ensureUndoState();
    await txn_b.transact();
  });
  ensureUndoState([[txn_b]], 0);

  await PT.undo();
  ensureUndoState([[txn_b]], 1);
  ensureTransactThrowsFor(txn_a);
  ensureTransactThrowsFor(txn_b);

  await PT.clearTransactionsHistory();
  ensureUndoState();
  observer.reset();
});

add_task(async function test_new_folder_with_annotation() {
  const ANNO = { name: "TestAnno", value: "TestValue" };
  let folder_info = createTestFolderInfo();
  folder_info.index = bmStartIndex;
  folder_info.annotations = [ANNO];
  ensureUndoState();
  let txn = PT.NewFolder(folder_info);
  folder_info.guid = await txn.transact();
  let originalInfo = await PlacesUtils.promiseBookmarksTree(folder_info.guid);
  let ensureDo = async function(aRedo = false) {
    ensureUndoState([[txn]], 0);
    await ensureItemsAdded(folder_info);
    ensureAnnotationsSet(folder_info.guid, [ANNO]);
    if (aRedo) {
      // Ignore lastModified in the comparison, for performance reasons.
      originalInfo.lastModified = null;
      await ensureBookmarksTreeRestoredCorrectlyExceptDates(originalInfo);
    }
    observer.reset();
  };

  let ensureUndo = () => {
    ensureUndoState([[txn]], 1);
    ensureItemsRemoved({ guid:       folder_info.guid,
                         parentGuid: folder_info.parentGuid,
                         index:      bmStartIndex });
    observer.reset();
  };

  await ensureDo();
  await PT.undo();
  await ensureUndo();
  await PT.redo();
  await ensureDo(true);
  await PT.undo();
  ensureUndo();
  await PT.clearTransactionsHistory();
  ensureUndoState();
});

add_task(async function test_new_folder_with_children() {
  let folder_info = createTestFolderInfo("Test folder", PlacesUtils.bookmarks.menuGuid, [{
    url: "http://test_create_item.com",
    title: "Test creating an item"
  }]);
  ensureUndoState();
  let txn = PT.NewFolder(folder_info);
  folder_info.guid = await txn.transact();
  let originalInfo = await PlacesUtils.promiseBookmarksTree(folder_info.guid);
  let ensureDo = async function(aRedo = false) {
    ensureUndoState([[txn]], 0);
    ensureItemsAdded(folder_info);
    if (aRedo) {
      // Ignore lastModified in the comparison, for performance reasons.
      originalInfo.lastModified = null;
      await ensureBookmarksTreeRestoredCorrectlyExceptDates(originalInfo);
    }
    observer.reset();
  };

  let ensureUndo = () => {
    ensureUndoState([[txn]], 1);
    ensureItemsRemoved({
      guid: folder_info.guid,
      parentGuid: folder_info.parentGuid,
      index: bmStartIndex,
      children: [{
        title: "Test creating an item",
        url: "http://test_create_item.com",
      }]
    });
    observer.reset();
  };

  await ensureDo();
  await PT.undo();
  await ensureUndo();
  await PT.redo();
  await ensureDo(true);
  await PT.undo();
  ensureUndo();
  await PT.clearTransactionsHistory();
  ensureUndoState();
});

add_task(async function test_new_bookmark() {
  let bm_info = { parentGuid: PlacesUtils.bookmarks.unfiledGuid,
                  url:        "http://test_create_item.com",
                  index:      bmStartIndex,
                  title:      "Test creating an item" };

  ensureUndoState();
  let txn = PT.NewBookmark(bm_info);
  bm_info.guid = await txn.transact();

  let originalInfo = await PlacesUtils.promiseBookmarksTree(bm_info.guid);
  let ensureDo = async function(aRedo = false) {
    ensureUndoState([[txn]], 0);
    await ensureItemsAdded(bm_info);
    if (aRedo)
      await ensureBookmarksTreeRestoredCorrectly(originalInfo);
    observer.reset();
  };
  let ensureUndo = () => {
    ensureUndoState([[txn]], 1);
    ensureItemsRemoved({ guid:       bm_info.guid,
                         parentGuid: bm_info.parentGuid,
                         index:      bmStartIndex });
    observer.reset();
  };

  await ensureDo();
  await PT.undo();
  ensureUndo();
  await PT.redo(true);
  await ensureDo();
  await PT.undo();
  ensureUndo();

  await PT.clearTransactionsHistory();
  ensureUndoState();
});

add_task(async function test_merge_create_folder_and_item() {
  let folder_info = createTestFolderInfo();
  let bm_info = { url: "http://test_create_item_to_folder.com",
                  title: "Test Bookmark",
                  index: bmStartIndex };

  let [folderTxnResult, bkmTxnResult] = await PT.batch(async function() {
    let folderTxn = PT.NewFolder(folder_info);
    folder_info.guid = bm_info.parentGuid = await folderTxn.transact();
    let bkmTxn = PT.NewBookmark(bm_info);
    bm_info.guid = await bkmTxn.transact();
    return [folderTxn, bkmTxn];
  });

  let ensureDo = async function() {
    ensureUndoState([[bkmTxnResult, folderTxnResult]], 0);
    await ensureItemsAdded(folder_info, bm_info);
    observer.reset();
  };

  let ensureUndo = () => {
    ensureUndoState([[bkmTxnResult, folderTxnResult]], 1);
    ensureItemsRemoved(folder_info, bm_info);
    observer.reset();
  };

  await ensureDo();
  await PT.undo();
  ensureUndo();
  await PT.redo();
  await ensureDo();
  await PT.undo();
  ensureUndo();

  await PT.clearTransactionsHistory();
  ensureUndoState();
});

add_task(async function test_move_items_to_folder() {
  let folder_a_info = createTestFolderInfo("Folder A");
  let bkm_a_info = { url: "http://test_move_items.com",
                     title: "Bookmark A" };
  let bkm_b_info = { url: "http://test_move_items.com",
                     title: "Bookmark B" };

  // Test moving items within the same folder.
  let [folder_a_txn_result, bkm_a_txn_result, bkm_b_txn_result] = await PT.batch(async function() {
    let folder_a_txn = PT.NewFolder(folder_a_info);

    folder_a_info.guid = bkm_a_info.parentGuid = bkm_b_info.parentGuid =
      await folder_a_txn.transact();
    let bkm_a_txn = PT.NewBookmark(bkm_a_info);
    bkm_a_info.guid = await bkm_a_txn.transact();
    let bkm_b_txn = PT.NewBookmark(bkm_b_info);
    bkm_b_info.guid = await bkm_b_txn.transact();
    return [folder_a_txn, bkm_a_txn, bkm_b_txn];
  });

  ensureUndoState([[bkm_b_txn_result, bkm_a_txn_result, folder_a_txn_result]], 0);

  let moveTxn = PT.Move({ guid:          bkm_a_info.guid,
                          newParentGuid: folder_a_info.guid });
  await moveTxn.transact();

  let ensureDo = () => {
    ensureUndoState([[moveTxn], [bkm_b_txn_result, bkm_a_txn_result, folder_a_txn_result]], 0);
    ensureItemsMoved({ guid:          bkm_a_info.guid,
                       oldParentGuid: folder_a_info.guid,
                       newParentGuid: folder_a_info.guid,
                       oldIndex:      0,
                       newIndex:      1 });
    observer.reset();
  };
  let ensureUndo = () => {
    ensureUndoState([[moveTxn], [bkm_b_txn_result, bkm_a_txn_result, folder_a_txn_result]], 1);
    ensureItemsMoved({ guid:          bkm_a_info.guid,
                       oldParentGuid: folder_a_info.guid,
                       newParentGuid: folder_a_info.guid,
                       oldIndex:      1,
                       newIndex:      0 });
    observer.reset();
  };

  ensureDo();
  await PT.undo();
  ensureUndo();
  await PT.redo();
  ensureDo();
  await PT.undo();
  ensureUndo();

  await PT.clearTransactionsHistory(false, true);
  ensureUndoState([[bkm_b_txn_result, bkm_a_txn_result, folder_a_txn_result]], 0);

  // Test moving items between folders.
  let folder_b_info = createTestFolderInfo("Folder B");
  let folder_b_txn = PT.NewFolder(folder_b_info);
  folder_b_info.guid = await folder_b_txn.transact();
  ensureUndoState([ [folder_b_txn],
                    [bkm_b_txn_result, bkm_a_txn_result, folder_a_txn_result] ], 0);

  moveTxn = PT.Move({ guid:          bkm_a_info.guid,
                      newParentGuid: folder_b_info.guid,
                      newIndex:      bmsvc.DEFAULT_INDEX });
  await moveTxn.transact();

  ensureDo = () => {
    ensureUndoState([ [moveTxn],
                      [folder_b_txn],
                      [bkm_b_txn_result, bkm_a_txn_result, folder_a_txn_result] ], 0);
    ensureItemsMoved({ guid:          bkm_a_info.guid,
                       oldParentGuid: folder_a_info.guid,
                       newParentGuid: folder_b_info.guid,
                       oldIndex:      0,
                       newIndex:      0 });
    observer.reset();
  };
  ensureUndo = () => {
    ensureUndoState([ [moveTxn],
                      [folder_b_txn],
                      [bkm_b_txn_result, bkm_a_txn_result, folder_a_txn_result] ], 1);
    ensureItemsMoved({ guid:          bkm_a_info.guid,
                       oldParentGuid: folder_b_info.guid,
                       newParentGuid: folder_a_info.guid,
                       oldIndex:      0,
                       newIndex:      0 });
    observer.reset();
  };

  ensureDo();
  await PT.undo();
  ensureUndo();
  await PT.redo();
  ensureDo();
  await PT.undo();
  ensureUndo();

  // Clean up
  await PT.undo(); // folder_b_txn
  await PT.undo(); // folder_a_txn + the bookmarks;
  Assert.equal(observer.itemsRemoved.size, 4);
  ensureUndoState([ [moveTxn],
                    [folder_b_txn],
                    [bkm_b_txn_result, bkm_a_txn_result, folder_a_txn_result] ], 3);
  await PT.clearTransactionsHistory();
  ensureUndoState();
});

add_task(async function test_remove_folder() {
  let folder_level_1_info = createTestFolderInfo("Folder Level 1");
  let folder_level_2_info = { title: "Folder Level 2" };
  let [folder_level_1_txn_result,
       folder_level_2_txn_result] = await PT.batch(async function() {
    let folder_level_1_txn  = PT.NewFolder(folder_level_1_info);
    folder_level_1_info.guid = await folder_level_1_txn.transact();
    folder_level_2_info.parentGuid = folder_level_1_info.guid;
    let folder_level_2_txn = PT.NewFolder(folder_level_2_info);
    folder_level_2_info.guid = await folder_level_2_txn.transact();
    return [folder_level_1_txn, folder_level_2_txn];
  });

  let original_folder_level_1_tree =
    await PlacesUtils.promiseBookmarksTree(folder_level_1_info.guid);
  let original_folder_level_2_tree =
    Object.assign({ parentGuid: original_folder_level_1_tree.guid },
                  original_folder_level_1_tree.children[0]);

  ensureUndoState([[folder_level_2_txn_result, folder_level_1_txn_result]]);
  await ensureItemsAdded(folder_level_1_info, folder_level_2_info);
  observer.reset();

  let remove_folder_2_txn = PT.Remove(folder_level_2_info);
  await remove_folder_2_txn.transact();

  ensureUndoState([ [remove_folder_2_txn],
                    [folder_level_2_txn_result, folder_level_1_txn_result] ]);
  await ensureItemsRemoved(folder_level_2_info);

  // Undo Remove "Folder Level 2"
  await PT.undo();
  ensureUndoState([ [remove_folder_2_txn],
                    [folder_level_2_txn_result, folder_level_1_txn_result] ], 1);
  await ensureItemsAdded(folder_level_2_info);
  await ensureBookmarksTreeRestoredCorrectly(original_folder_level_2_tree);
  observer.reset();

  // Redo Remove "Folder Level 2"
  await PT.redo();
  ensureUndoState([ [remove_folder_2_txn],
                    [folder_level_2_txn_result, folder_level_1_txn_result] ]);
  await ensureItemsRemoved(folder_level_2_info);
  observer.reset();

  // Undo it again
  await PT.undo();
  ensureUndoState([ [remove_folder_2_txn],
                    [folder_level_2_txn_result, folder_level_1_txn_result] ], 1);
  await ensureItemsAdded(folder_level_2_info);
  await ensureBookmarksTreeRestoredCorrectly(original_folder_level_2_tree);
  observer.reset();

  // Undo the creation of both folders
  await PT.undo();
  ensureUndoState([ [remove_folder_2_txn],
                    [folder_level_2_txn_result, folder_level_1_txn_result] ], 2);
  await ensureItemsRemoved(folder_level_2_info, folder_level_1_info);
  observer.reset();

  // Redo the creation of both folders
  await PT.redo();
  ensureUndoState([ [remove_folder_2_txn],
                    [folder_level_2_txn_result, folder_level_1_txn_result] ], 1);
  await ensureItemsAdded(folder_level_1_info, folder_level_2_info);
  await ensureBookmarksTreeRestoredCorrectlyExceptDates(original_folder_level_1_tree);
  observer.reset();

  // Redo Remove "Folder Level 2"
  await PT.redo();
  ensureUndoState([ [remove_folder_2_txn],
                    [folder_level_2_txn_result, folder_level_1_txn_result] ]);
  await ensureItemsRemoved(folder_level_2_info);
  observer.reset();

  // Undo everything one last time
  await PT.undo();
  ensureUndoState([ [remove_folder_2_txn],
                    [folder_level_2_txn_result, folder_level_1_txn_result] ], 1);
  await ensureItemsAdded(folder_level_2_info);
  observer.reset();

  await PT.undo();
  ensureUndoState([ [remove_folder_2_txn],
                    [folder_level_2_txn_result, folder_level_1_txn_result] ], 2);
  await ensureItemsRemoved(folder_level_2_info, folder_level_2_info);
  observer.reset();

  await PT.clearTransactionsHistory();
  ensureUndoState();
});

add_task(async function test_add_and_remove_bookmarks_with_additional_info() {
  const testURI = "http://add.remove.tag";
  const TAG_1 = "TestTag1";
  const TAG_2 = "TestTag2";
  const ANNO = { name: "TestAnno", value: "TestAnnoValue" };

  let folder_info = createTestFolderInfo();
  folder_info.guid = await PT.NewFolder(folder_info).transact();
  let ensureTags = ensureTagsForURI.bind(null, testURI);

  // Check that the NewBookmark transaction preserves tags.
  observer.reset();
  let b1_info = { parentGuid: folder_info.guid, url: testURI, tags: [TAG_1] };
  b1_info.guid = await PT.NewBookmark(b1_info).transact();
  let b1_originalInfo = await PlacesUtils.promiseBookmarksTree(b1_info.guid);
  ensureTags([TAG_1]);
  await PT.undo();
  ensureTags([]);

  observer.reset();
  await PT.redo();
  await ensureBookmarksTreeRestoredCorrectly(b1_originalInfo);
  ensureTags([TAG_1]);

  // Check if the Remove transaction removes and restores tags of children
  // correctly.
  await PT.Remove(folder_info.guid).transact();
  ensureTags([]);

  observer.reset();
  await PT.undo();
  await ensureBookmarksTreeRestoredCorrectly(b1_originalInfo);
  ensureTags([TAG_1]);

  await PT.redo();
  ensureTags([]);

  observer.reset();
  await PT.undo();
  await ensureBookmarksTreeRestoredCorrectly(b1_originalInfo);
  ensureTags([TAG_1]);

  // * Check that no-op tagging (the uri is already tagged with TAG_1) is
  //   also a no-op on undo.
  observer.reset();
  let b2_info = { parentGuid:  folder_info.guid,
                  url:         testURI,
                  tags:        [TAG_1, TAG_2],
                  annotations: [ANNO] };
  b2_info.guid = await PT.NewBookmark(b2_info).transact();
  let b2_post_creation_changes = [
   { guid: b2_info.guid,
     isAnnoProperty: true,
     property: ANNO.name,
     newValue: ANNO.value } ];
  ensureItemsChanged(...b2_post_creation_changes);
  ensureTags([TAG_1, TAG_2]);

  observer.reset();
  await PT.undo();
  await ensureItemsRemoved(b2_info);
  ensureTags([TAG_1]);

  // Check if Remove correctly restores tags and annotations.
  observer.reset();
  await PT.redo();
  ensureItemsChanged({ guid: b2_info.guid,
                       isAnnoProperty: true,
                       property: ANNO.name,
                       newValue: ANNO.value });
  ensureTags([TAG_1, TAG_2]);

  // Test Remove for multiple items.
  observer.reset();
  await PT.Remove(b1_info.guid).transact();
  await PT.Remove(b2_info.guid).transact();
  await PT.Remove(folder_info.guid).transact();
  await ensureItemsRemoved(b1_info, b2_info, folder_info);
  ensureTags([]);

  observer.reset();
  await PT.undo();
  await ensureItemsAdded(folder_info);
  ensureTags([]);

  observer.reset();
  await PT.undo();
  ensureItemsChanged(...b2_post_creation_changes);
  ensureTags([TAG_1, TAG_2]);

  observer.reset();
  await PT.undo();
  await ensureItemsAdded(b1_info);
  ensureTags([TAG_1, TAG_2]);

  // The redo calls below cleanup everything we did.
  observer.reset();
  await PT.redo();
  await ensureItemsRemoved(b1_info);
  ensureTags([TAG_1, TAG_2]);

  observer.reset();
  await PT.redo();
  await ensureItemsRemoved(b2_info);
  ensureTags([]);

  observer.reset();
  await PT.redo();
  await ensureItemsRemoved(folder_info);
  ensureTags([]);

  await PT.clearTransactionsHistory();
  ensureUndoState();
});

add_task(async function test_creating_and_removing_a_separator() {
  let folder_info = createTestFolderInfo();
  let separator_info = {};
  let undoEntries = [];

  observer.reset();
  let create_txns = await PT.batch(async function() {
    let folder_txn = PT.NewFolder(folder_info);
    folder_info.guid = separator_info.parentGuid = await folder_txn.transact();
    let separator_txn = PT.NewSeparator(separator_info);
    separator_info.guid = await separator_txn.transact();
    return [separator_txn, folder_txn];
  });
  undoEntries.unshift(create_txns);
  ensureUndoState(undoEntries, 0);
  ensureItemsAdded(folder_info, separator_info);

  observer.reset();
  await PT.undo();
  ensureUndoState(undoEntries, 1);
  ensureItemsRemoved(folder_info, separator_info);

  observer.reset();
  await PT.redo();
  ensureUndoState(undoEntries, 0);
  ensureItemsAdded(folder_info, separator_info);

  observer.reset();
  let remove_sep_txn = PT.Remove(separator_info);
  await remove_sep_txn.transact();
  undoEntries.unshift([remove_sep_txn]);
  ensureUndoState(undoEntries, 0);
  ensureItemsRemoved(separator_info);

  observer.reset();
  await PT.undo();
  ensureUndoState(undoEntries, 1);
  ensureItemsAdded(separator_info);

  observer.reset();
  await PT.undo();
  ensureUndoState(undoEntries, 2);
  ensureItemsRemoved(folder_info, separator_info);

  observer.reset();
  await PT.redo();
  ensureUndoState(undoEntries, 1);
  ensureItemsAdded(folder_info, separator_info);

  // Clear redo entries and check that |redo| does nothing
  observer.reset();
  await PT.clearTransactionsHistory(false, true);
  undoEntries.shift();
  ensureUndoState(undoEntries, 0);
  await PT.redo();
  ensureItemsAdded();
  ensureItemsRemoved();

  // Cleanup
  observer.reset();
  await PT.undo();
  ensureUndoState(undoEntries, 1);
  ensureItemsRemoved(folder_info, separator_info);
  await PT.clearTransactionsHistory();
  ensureUndoState();
});

add_task(async function test_add_and_remove_livemark() {
  let createLivemarkTxn = PT.NewLivemark(
    { feedUrl: "http://test.remove.livemark",
      parentGuid: PlacesUtils.bookmarks.unfiledGuid,
      title: "Test Remove Livemark" });
  let guid = await createLivemarkTxn.transact();
  let originalInfo = await PlacesUtils.promiseBookmarksTree(guid);
  Assert.ok(originalInfo);
  await ensureLivemarkCreatedByAddLivemark(guid);

  let removeTxn = PT.Remove(guid);
  await removeTxn.transact();
  await ensureNonExistent(guid);

  async function undo() {
    ensureUndoState([[removeTxn], [createLivemarkTxn]], 0);
    await PT.undo();
    ensureUndoState([[removeTxn], [createLivemarkTxn]], 1);
    await ensureBookmarksTreeRestoredCorrectly(originalInfo);
    await PT.undo();
    ensureUndoState([[removeTxn], [createLivemarkTxn]], 2);
    await ensureNonExistent(guid);
  }
  async function redo() {
    ensureUndoState([[removeTxn], [createLivemarkTxn]], 2);
    await PT.redo();
    ensureUndoState([[removeTxn], [createLivemarkTxn]], 1);
    await ensureBookmarksTreeRestoredCorrectly(originalInfo);
    await PT.redo();
    ensureUndoState([[removeTxn], [createLivemarkTxn]], 0);
    await ensureNonExistent(guid);
  }

  await undo();
  await redo();
  await undo();
  await redo();

  // Cleanup
  await undo();
  observer.reset();
  await PT.clearTransactionsHistory();
});

add_task(async function test_edit_title() {
  let bm_info = {
    parentGuid: PlacesUtils.bookmarks.unfiledGuid,
    url: "http://test_create_item.com",
    title: "Original Title"
  };

  function ensureTitleChange(aCurrentTitle) {
    ensureItemsChanged({ guid: bm_info.guid,
                         property: "title",
                         newValue: aCurrentTitle});
  }

  bm_info.guid = await PT.NewBookmark(bm_info).transact();

  observer.reset();
  await PT.EditTitle({ guid: bm_info.guid, title: "New Title" }).transact();
  ensureTitleChange("New Title");

  observer.reset();
  await PT.undo();
  ensureTitleChange("Original Title");

  observer.reset();
  await PT.redo();
  ensureTitleChange("New Title");

  // Cleanup
  observer.reset();
  await PT.undo();
  ensureTitleChange("Original Title");
  await PT.undo();
  ensureItemsRemoved(bm_info);

  await PT.clearTransactionsHistory();
  ensureUndoState();
});

add_task(async function test_edit_url() {
  let oldURI = "http://old.test_editing_item_uri.com/";
  let newURI = "http://new.test_editing_item_uri.com/";
  let bm_info = { parentGuid: PlacesUtils.bookmarks.unfiledGuid, url: oldURI, tags: ["TestTag"] };
  function ensureURIAndTags(aPreChangeURI, aPostChangeURI, aOLdURITagsPreserved) {
    ensureItemsChanged({ guid: bm_info.guid,
                         property: "uri",
                         newValue: aPostChangeURI });
    ensureTagsForURI(aPostChangeURI, bm_info.tags);
    ensureTagsForURI(aPreChangeURI, aOLdURITagsPreserved ? bm_info.tags : []);
  }

  bm_info.guid = await PT.NewBookmark(bm_info).transact();
  ensureTagsForURI(oldURI, bm_info.tags);

  // When there's a single bookmark for the same url, tags should be moved.
  observer.reset();
  await PT.EditUrl({ guid: bm_info.guid, url: newURI }).transact();
  ensureURIAndTags(oldURI, newURI, false);

  observer.reset();
  await PT.undo();
  ensureURIAndTags(newURI, oldURI, false);

  observer.reset();
  await PT.redo();
  ensureURIAndTags(oldURI, newURI, false);

  observer.reset();
  await PT.undo();
  ensureURIAndTags(newURI, oldURI, false);

  // When there're multiple bookmarks for the same url, tags should be copied.
  let bm2_info = Object.create(bm_info);
  bm2_info.guid = await PT.NewBookmark(bm2_info).transact();
  let bm3_info = Object.create(bm_info);
  bm3_info.url = newURI;
  bm3_info.guid = await PT.NewBookmark(bm3_info).transact();

  observer.reset();
  await PT.EditUrl({ guid: bm_info.guid, url: newURI }).transact();
  ensureURIAndTags(oldURI, newURI, true);

  observer.reset();
  await PT.undo();
  ensureURIAndTags(newURI, oldURI, true);

  observer.reset();
  await PT.redo();
  ensureURIAndTags(oldURI, newURI, true);

  // Cleanup
  observer.reset();
  await PT.undo();
  ensureURIAndTags(newURI, oldURI, true);
  await PT.undo();
  await PT.undo();
  await PT.undo();
  ensureItemsRemoved(bm3_info, bm2_info, bm_info);

  await PT.clearTransactionsHistory();
  ensureUndoState();
});

add_task(async function test_edit_keyword() {
  let bm_info = { parentGuid: PlacesUtils.bookmarks.unfiledGuid,
                  url: "http://test.edit.keyword/" };
  const KEYWORD = "test_keyword";
  bm_info.guid = await PT.NewBookmark(bm_info).transact();
  function ensureKeywordChange(aCurrentKeyword = "") {
    ensureItemsChanged({ guid: bm_info.guid,
                         property: "keyword",
                         newValue: aCurrentKeyword });
  }

  bm_info.guid = await PT.NewBookmark(bm_info).transact();

  observer.reset();
  await PT.EditKeyword({ guid: bm_info.guid, keyword: KEYWORD, postData: "postData" }).transact();
  ensureKeywordChange(KEYWORD);
  let entry = await PlacesUtils.keywords.fetch(KEYWORD);
  Assert.equal(entry.url.href, bm_info.url);
  Assert.equal(entry.postData, "postData");

  observer.reset();
  await PT.undo();
  ensureKeywordChange();
  entry = await PlacesUtils.keywords.fetch(KEYWORD);
  Assert.equal(entry, null);

  observer.reset();
  await PT.redo();
  ensureKeywordChange(KEYWORD);
  entry = await PlacesUtils.keywords.fetch(KEYWORD);
  Assert.equal(entry.url.href, bm_info.url);
  Assert.equal(entry.postData, "postData");

  // Cleanup
  observer.reset();
  await PT.undo();
  ensureKeywordChange();
  await PT.undo();
  ensureItemsRemoved(bm_info);

  await PT.clearTransactionsHistory();
  ensureUndoState();
});

add_task(async function test_edit_keyword_null_postData() {
  let bm_info = { parentGuid: PlacesUtils.bookmarks.unfiledGuid,
                  url: "http://test.edit.keyword/" };
  const KEYWORD = "test_keyword";
  bm_info.guid = await PT.NewBookmark(bm_info).transact();
  function ensureKeywordChange(aCurrentKeyword = "") {
    ensureItemsChanged({ guid: bm_info.guid,
                         property: "keyword",
                         newValue: aCurrentKeyword });
  }

  bm_info.guid = await PT.NewBookmark(bm_info).transact();

  observer.reset();
  await PT.EditKeyword({ guid: bm_info.guid, keyword: KEYWORD, postData: null }).transact();
  ensureKeywordChange(KEYWORD);
  let entry = await PlacesUtils.keywords.fetch(KEYWORD);
  Assert.equal(entry.url.href, bm_info.url);
  Assert.equal(entry.postData, null);

  observer.reset();
  await PT.undo();
  ensureKeywordChange();
  entry = await PlacesUtils.keywords.fetch(KEYWORD);
  Assert.equal(entry, null);

  observer.reset();
  await PT.redo();
  ensureKeywordChange(KEYWORD);
  entry = await PlacesUtils.keywords.fetch(KEYWORD);
  Assert.equal(entry.url.href, bm_info.url);
  Assert.equal(entry.postData, null);

  // Cleanup
  observer.reset();
  await PT.undo();
  ensureKeywordChange();
  await PT.undo();
  ensureItemsRemoved(bm_info);

  await PT.clearTransactionsHistory();
  ensureUndoState();
});

add_task(async function test_edit_specific_keyword() {
  let bm_info = { parentGuid: PlacesUtils.bookmarks.unfiledGuid,
                  url: "http://test.edit.keyword/" };
  bm_info.guid = await PT.NewBookmark(bm_info).transact();
  function ensureKeywordChange(aCurrentKeyword = "", aPreviousKeyword = "") {
    ensureItemsChanged({ guid: bm_info.guid,
                         property: "keyword",
                         newValue: aCurrentKeyword
                       });
  }

  await PlacesUtils.keywords.insert({
    keyword: "kw1",
    url: bm_info.url,
    postData: "postData1"
  });
  await PlacesUtils.keywords.insert({
    keyword: "kw2",
    url: bm_info.url,
    postData: "postData2"
  });
  bm_info.guid = await PT.NewBookmark(bm_info).transact();

  observer.reset();
  await PT.EditKeyword({ guid: bm_info.guid, keyword: "keyword", oldKeyword: "kw2" }).transact();
  ensureKeywordChange("keyword", "kw2");
  let entry = await PlacesUtils.keywords.fetch("kw1");
  Assert.equal(entry.url.href, bm_info.url);
  Assert.equal(entry.postData, "postData1");
  entry = await PlacesUtils.keywords.fetch("keyword");
  Assert.equal(entry.url.href, bm_info.url);
  Assert.equal(entry.postData, "postData2");
  entry = await PlacesUtils.keywords.fetch("kw2");
  Assert.equal(entry, null);

  observer.reset();
  await PT.undo();
  ensureKeywordChange("kw2", "keyword");
  entry = await PlacesUtils.keywords.fetch("kw1");
  Assert.equal(entry.url.href, bm_info.url);
  Assert.equal(entry.postData, "postData1");
  entry = await PlacesUtils.keywords.fetch("kw2");
  Assert.equal(entry.url.href, bm_info.url);
  Assert.equal(entry.postData, "postData2");
  entry = await PlacesUtils.keywords.fetch("keyword");
  Assert.equal(entry, null);

  observer.reset();
  await PT.redo();
  ensureKeywordChange("keyword", "kw2");
  entry = await PlacesUtils.keywords.fetch("kw1");
  Assert.equal(entry.url.href, bm_info.url);
  Assert.equal(entry.postData, "postData1");
  entry = await PlacesUtils.keywords.fetch("keyword");
  Assert.equal(entry.url.href, bm_info.url);
  Assert.equal(entry.postData, "postData2");
  entry = await PlacesUtils.keywords.fetch("kw2");
  Assert.equal(entry, null);

  // Cleanup
  observer.reset();
  await PT.undo();
  ensureKeywordChange("kw2");
  await PT.undo();
  ensureItemsRemoved(bm_info);

  await PT.clearTransactionsHistory();
  ensureUndoState();
});

add_task(async function test_tag_uri() {
  // This also tests passing uri specs.
  let bm_info_a = { url: "http://bookmarked.uri",
                    parentGuid: PlacesUtils.bookmarks.unfiledGuid };
  let bm_info_b = { url: "http://bookmarked2.uri",
                    parentGuid: PlacesUtils.bookmarks.unfiledGuid };
  let unbookmarked_uri = "http://un.bookmarked.uri";

  await PT.batch(async function() {
    bm_info_a.guid = await PT.NewBookmark(bm_info_a).transact();
    bm_info_b.guid = await PT.NewBookmark(bm_info_b).transact();
  });

  async function doTest(aInfo) {
    let urls = "url" in aInfo ? [aInfo.url] : aInfo.urls;
    let tags = "tag" in aInfo ? [aInfo.tag] : aInfo.tags;

    let tagWillAlsoBookmark = new Set();
    for (let url of urls) {
      if (!(await bmsvc.fetch({ url }))) {
        tagWillAlsoBookmark.add(url);
      }
    }

    async function ensureTagsSet() {
      for (let url of urls) {
        ensureTagsForURI(url, tags);
        Assert.ok(await bmsvc.fetch({ url }));
      }
    }
    async function ensureTagsUnset() {
      for (let url of urls) {
        ensureTagsForURI(url, []);
        if (tagWillAlsoBookmark.has(url))
          Assert.ok(!(await bmsvc.fetch({ url })));
        else
          Assert.ok(await bmsvc.fetch({ url }));
      }
    }

    await PT.Tag(aInfo).transact();
    await ensureTagsSet();
    await PT.undo();
    await ensureTagsUnset();
    await PT.redo();
    await ensureTagsSet();
    await PT.undo();
    await ensureTagsUnset();
  }

  await doTest({ url: bm_info_a.url, tags: ["MyTag"] });
  await doTest({ urls: [bm_info_a.url], tag: "MyTag" });
  await doTest({ urls: [bm_info_a.url, bm_info_b.url], tags: ["A, B"] });
  await doTest({ urls: [bm_info_a.url, unbookmarked_uri], tag: "C" });
  // Duplicate URLs listed.
  await doTest({ urls: [bm_info_a.url, bm_info_b.url, bm_info_a.url], tag: "D" });

  // Cleanup
  observer.reset();
  await PT.undo();
  ensureItemsRemoved(bm_info_a, bm_info_b);

  await PT.clearTransactionsHistory();
  ensureUndoState();
});

add_task(async function test_untag_uri() {
  let bm_info_a = { url: "http://bookmarked.uri",
                    parentGuid: PlacesUtils.bookmarks.unfiledGuid,
                    tags: ["A", "B"] };
  let bm_info_b = { url: "http://bookmarked2.uri",
                    parentGuid: PlacesUtils.bookmarks.unfiledGuid,
                    tag: "B" };

  await PT.batch(async function() {
    bm_info_a.guid = await PT.NewBookmark(bm_info_a).transact();
    ensureTagsForURI(bm_info_a.url, bm_info_a.tags);
    bm_info_b.guid = await PT.NewBookmark(bm_info_b).transact();
    ensureTagsForURI(bm_info_b.url, [bm_info_b.tag]);
  });

  async function doTest(aInfo) {
    let urls, tagsRemoved;
    if (typeof(aInfo) == "string") {
      urls = [aInfo];
      tagsRemoved = [];
    } else if (Array.isArray(aInfo)) {
      urls = aInfo;
      tagsRemoved = [];
    } else {
      urls = "url" in aInfo ? [aInfo.url] : aInfo.urls;
      tagsRemoved = "tag" in aInfo ? [aInfo.tag] : aInfo.tags;
    }

    let preRemovalTags = new Map();
    for (let url of urls) {
      preRemovalTags.set(url, tagssvc.getTagsForURI(Services.io.newURI(url)));
    }

    function ensureTagsSet() {
      for (let url of urls) {
        ensureTagsForURI(url, preRemovalTags.get(url));
      }
    }
    function ensureTagsUnset() {
      for (let url of urls) {
        let expectedTags = tagsRemoved.length == 0 ?
           [] : preRemovalTags.get(url).filter(tag => !tagsRemoved.includes(tag));
        ensureTagsForURI(url, expectedTags);
      }
    }

    await PT.Untag(aInfo).transact();
    await ensureTagsUnset();
    await PT.undo();
    await ensureTagsSet();
    await PT.redo();
    await ensureTagsUnset();
    await PT.undo();
    await ensureTagsSet();
  }

  await doTest(bm_info_a);
  await doTest(bm_info_b);
  await doTest(bm_info_a.url);
  await doTest(bm_info_b.url);
  await doTest([bm_info_a.url, bm_info_b.url]);
  await doTest({ urls: [bm_info_a.url, bm_info_b.url], tags: ["A", "B"] });
  await doTest({ urls: [bm_info_a.url, bm_info_b.url], tag: "B" });
  await doTest({ urls: [bm_info_a.url, bm_info_b.url], tag: "C" });
  await doTest({ urls: [bm_info_a.url, bm_info_b.url], tags: ["C"] });

  // Cleanup
  observer.reset();
  await PT.undo();
  ensureItemsRemoved(bm_info_a, bm_info_b);

  await PT.clearTransactionsHistory();
  ensureUndoState();
});

add_task(async function test_annotate() {
  let bm_info = { url: "http://test.item.annotation",
                  parentGuid: PlacesUtils.bookmarks.unfiledGuid };
  let anno_info = { name: "TestAnno", value: "TestValue" };
  function ensureAnnoState(aSet) {
    ensureAnnotationsSet(bm_info.guid,
                         [{ name: anno_info.name,
                            value: aSet ? anno_info.value : null }]);
  }

  bm_info.guid = await PT.NewBookmark(bm_info).transact();

  observer.reset();
  await PT.Annotate({ guid: bm_info.guid, annotation: anno_info }).transact();
  ensureAnnoState(true);

  observer.reset();
  await PT.undo();
  ensureAnnoState(false);

  observer.reset();
  await PT.redo();
  ensureAnnoState(true);

  // Test removing the annotation by not passing the |value| property.
  observer.reset();
  await PT.Annotate({ guid: bm_info.guid,
                      annotation: { name: anno_info.name }}).transact();
  ensureAnnoState(false);

  observer.reset();
  await PT.undo();
  ensureAnnoState(true);

  observer.reset();
  await PT.redo();
  ensureAnnoState(false);

  // Cleanup
  await PT.undo();
  observer.reset();
});

add_task(async function test_annotate_multiple() {
  let guid = await PT.NewFolder(createTestFolderInfo()).transact();
  let itemId = await PlacesUtils.promiseItemId(guid);

  function AnnoObj(aName, aValue) {
    this.name = aName;
    this.value = aValue;
    this.flags = 0;
    this.expires = Ci.nsIAnnotationService.EXPIRE_NEVER;
  }

  function annos(a = null, b = null) {
    return [new AnnoObj("A", a), new AnnoObj("B", b)];
  }

  async function verifyAnnoValues(a = null, b = null) {
    let currentAnnos = await PlacesUtils.promiseAnnotationsForItem(itemId);
    let expectedAnnos = [];
    if (a !== null)
      expectedAnnos.push(new AnnoObj("A", a));
    if (b !== null)
      expectedAnnos.push(new AnnoObj("B", b));

    Assert.deepEqual(currentAnnos, expectedAnnos);
  }

  await PT.Annotate({ guid, annotations: annos(1, 2) }).transact();
  await verifyAnnoValues(1, 2);
  await PT.undo();
  await verifyAnnoValues();
  await PT.redo();
  await verifyAnnoValues(1, 2);

  await PT.Annotate({ guid,
                      annotation: { name: "A" } }).transact();
  await verifyAnnoValues(null, 2);

  await PT.Annotate({ guid,
                      annotation: { name: "B", value: 0 } }).transact();
  await verifyAnnoValues(null, 0);
  await PT.undo();
  await verifyAnnoValues(null, 2);
  await PT.redo();
  await verifyAnnoValues(null, 0);
  await PT.undo();
  await verifyAnnoValues(null, 2);
  await PT.undo();
  await verifyAnnoValues(1, 2);
  await PT.undo();
  await verifyAnnoValues();

  // Cleanup
  await PT.undo();
  observer.reset();
});

add_task(async function test_sort_folder_by_name() {
  let folder_info = createTestFolderInfo();

  let url = "http://sort.by.name/";
  let preSep =  ["3", "2", "1"].map(i => ({ title: i, url }));
  let sep = {};
  let postSep = ["c", "b", "a"].map(l => ({ title: l, url }));
  let originalOrder = [...preSep, sep, ...postSep];
  let sortedOrder = [...preSep.slice(0).reverse(),
                     sep,
                     ...postSep.slice(0).reverse()];
  await PT.batch(async function() {
    folder_info.guid = await PT.NewFolder(folder_info).transact();
    for (let info of originalOrder) {
      info.parentGuid = folder_info.guid;
      info.guid = await (info == sep ?
                    PT.NewSeparator(info).transact() :
                    PT.NewBookmark(info).transact());
    }
  });

  let folderId = await PlacesUtils.promiseItemId(folder_info.guid);
  let folderContainer = PlacesUtils.getFolderContents(folderId).root;
  function ensureOrder(aOrder) {
    for (let i = 0; i < folderContainer.childCount; i++) {
      Assert.equal(folderContainer.getChild(i).bookmarkGuid, aOrder[i].guid);
    }
  }

  ensureOrder(originalOrder);
  await PT.SortByName(folder_info.guid).transact();
  ensureOrder(sortedOrder);
  await PT.undo();
  ensureOrder(originalOrder);
  await PT.redo();
  ensureOrder(sortedOrder);

  // Cleanup
  observer.reset();
  await PT.undo();
  ensureOrder(originalOrder);
  await PT.undo();
  ensureItemsRemoved(...originalOrder, folder_info);
});

add_task(async function test_livemark_txns() {
  let livemark_info =
    { feedUrl: "http://test.feed.uri/",
      parentGuid: PlacesUtils.bookmarks.unfiledGuid,
      title: "Test Livemark" };
  function ensureLivemarkAdded() {
    ensureItemsAdded({ guid:       livemark_info.guid,
                       title:      livemark_info.title,
                       parentGuid: livemark_info.parentGuid,
                       itemType:   bmsvc.TYPE_FOLDER });
    let annos = [{ name:  PlacesUtils.LMANNO_FEEDURI,
                   value: livemark_info.feedUrl }];
    if ("siteUrl" in livemark_info) {
      annos.push({ name: PlacesUtils.LMANNO_SITEURI,
                   value: livemark_info.siteUrl });
    }
    ensureAnnotationsSet(livemark_info.guid, annos);
  }
  function ensureLivemarkRemoved() {
    ensureItemsRemoved({ guid:       livemark_info.guid,
                         parentGuid: livemark_info.parentGuid });
  }

  async function _testDoUndoRedoUndo() {
    observer.reset();
    livemark_info.guid = await PT.NewLivemark(livemark_info).transact();
    ensureLivemarkAdded();

    observer.reset();
    await PT.undo();
    ensureLivemarkRemoved();

    observer.reset();
    await PT.redo();
    ensureLivemarkAdded();

    await PT.undo();
    ensureLivemarkRemoved();
  }

  await _testDoUndoRedoUndo();
  livemark_info.siteUrl = "http://feed.site.uri/";
  await _testDoUndoRedoUndo();

  // Cleanup
  observer.reset();
  await PT.clearTransactionsHistory();
});

add_task(async function test_copy() {
  async function duplicate_and_test(aOriginalGuid) {
    let txn = PT.Copy({
      guid: aOriginalGuid, newParentGuid: PlacesUtils.bookmarks.unfiledGuid
    });
    let duplicateGuid = await txn.transact();
    let originalInfo = await PlacesUtils.promiseBookmarksTree(aOriginalGuid);
    let duplicateInfo = await PlacesUtils.promiseBookmarksTree(duplicateGuid);
    await ensureEqualBookmarksTrees(originalInfo, duplicateInfo, false);

    async function redo() {
      await PT.redo();
      await ensureBookmarksTreeRestoredCorrectlyExceptDates(originalInfo);
      await PT.redo();
      await ensureBookmarksTreeRestoredCorrectlyExceptDates(duplicateInfo);
    }
    async function undo() {
      await PT.undo();
      // also undo the original item addition.
      await PT.undo();
      await ensureNonExistent(aOriginalGuid, duplicateGuid);
    }

    await undo();
    await redo();
    await undo();
    await redo();

    // Cleanup. This also remove the original item.
    await PT.undo();
    observer.reset();
    await PT.clearTransactionsHistory();
  }

  let timerPrecision = Preferences.get("privacy.reduceTimerPrecision");
  Preferences.set("privacy.reduceTimerPrecision", false);

  registerCleanupFunction(function() {
    Preferences.set("privacy.reduceTimerPrecision", timerPrecision);
  });

  // Test duplicating leafs (bookmark, separator, empty folder)
  PT.NewBookmark({ url: "http://test.item.duplicate",
                   parentGuid: PlacesUtils.bookmarks.unfiledGuid,
                   annos: [{ name: "Anno", value: "AnnoValue"}] });
  let sepTxn = PT.NewSeparator({
    parentGuid: PlacesUtils.bookmarks.unfiledGuid,
    index: 1
  });
  let livemarkTxn = PT.NewLivemark(
    { feedUrl: "http://test.feed.uri",
      parentGuid: PlacesUtils.bookmarks.unfiledGuid,
      title: "Test Livemark", index: 1 });
  let emptyFolderTxn = PT.NewFolder(createTestFolderInfo());
  for (let txn of [livemarkTxn, sepTxn, emptyFolderTxn]) {
    let guid = await txn.transact();
    await duplicate_and_test(guid);
  }

  // Test duplicating a folder having some contents.
  let filledFolderGuid = await PT.batch(async function() {
    let folderGuid = await PT.NewFolder(createTestFolderInfo()).transact();
    let nestedFolderGuid =
      await PT.NewFolder({ parentGuid: folderGuid,
                           title: "Nested Folder" }).transact();
    // Insert a bookmark under the nested folder.
    await PT.NewBookmark({ url: "http://nested.nested.bookmark",
                           parentGuid: nestedFolderGuid }).transact();
    // Insert a separator below the nested folder
    await PT.NewSeparator({ parentGuid: folderGuid }).transact();
    // And another bookmark.
    await PT.NewBookmark({ url: "http://nested.bookmark",
                           parentGuid: folderGuid }).transact();
    return folderGuid;
  });

  await duplicate_and_test(filledFolderGuid);

  // Cleanup
  await PT.clearTransactionsHistory();
});

add_task(async function test_array_input_for_batch() {
  let folderTxn = PT.NewFolder(createTestFolderInfo());
  let folderGuid = await folderTxn.transact();

  let sep1_txn = PT.NewSeparator({ parentGuid: folderGuid });
  let sep2_txn = PT.NewSeparator({ parentGuid: folderGuid });
  await PT.batch([sep1_txn, sep2_txn]);
  ensureUndoState([[sep2_txn, sep1_txn], [folderTxn]], 0);

  let ensureChildCount = async function(count) {
    let tree = await PlacesUtils.promiseBookmarksTree(folderGuid);
    if (count == 0)
      Assert.ok(!("children" in tree));
    else
      Assert.equal(tree.children.length, count);
  };

  await ensureChildCount(2);
  await PT.undo();
  await ensureChildCount(0);
  await PT.redo();
  await ensureChildCount(2);
  await PT.undo();
  await ensureChildCount(0);

  await PT.undo();
  Assert.equal((await PlacesUtils.promiseBookmarksTree(folderGuid)), null);

  // Cleanup
  await PT.clearTransactionsHistory();
});

add_task(async function test_copy_excluding_annotations() {
  let folderInfo = createTestFolderInfo();
  let anno = n => { return { name: n, value: 1 }; };
  folderInfo.annotations = [anno("a"), anno("b"), anno("c")];
  let folderGuid = await PT.NewFolder(folderInfo).transact();

  let ensureAnnosSet = async function(guid, ...expectedAnnoNames) {
    let tree = await PlacesUtils.promiseBookmarksTree(guid);
    let annoNames = "annos" in tree ?
                      tree.annos.map(a => a.name).sort() : [];
    Assert.deepEqual(annoNames, expectedAnnoNames);
  };

  await ensureAnnosSet(folderGuid, "a", "b", "c");

  let excluding_a_dupeGuid =
    await PT.Copy({ guid: folderGuid,
                    newParentGuid: PlacesUtils.bookmarks.unfiledGuid,
                    excludingAnnotation: "a" }).transact();
  await ensureAnnosSet(excluding_a_dupeGuid, "b", "c");

  let excluding_ac_dupeGuid =
    await PT.Copy({ guid: folderGuid,
                    newParentGuid: PlacesUtils.bookmarks.unfiledGuid,
                    excludingAnnotations: ["a", "c"] }).transact();
  await ensureAnnosSet(excluding_ac_dupeGuid, "b");

  // Cleanup
  await PT.undo();
  await PT.undo();
  await PT.undo();
  await PT.clearTransactionsHistory();
});

add_task(async function test_invalid_uri_spec_throws() {
  Assert.throws(() =>
    PT.NewBookmark({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
                     url:        "invalid uri spec",
                     title:      "test bookmark" }));
  Assert.throws(() =>
    PT.Tag({ tag: "TheTag",
             urls: ["invalid uri spec"] }));
  Assert.throws(() =>
    PT.Tag({ tag: "TheTag",
             urls: ["about:blank", "invalid uri spec"] }));
});

add_task(async function test_annotate_multiple_items() {
  let parentGuid = menuGuid;
  let guids = [
    await PT.NewBookmark({ url: "about:blank", parentGuid }).transact(),
    await PT.NewFolder({ title: "Test Folder", parentGuid }).transact()];

  let annotation = { name: "TestAnno", value: "TestValue" };
  await PT.Annotate({ guids, annotation }).transact();

  async function ensureAnnoSet() {
    for (let guid of guids) {
      let itemId = await PlacesUtils.promiseItemId(guid);
      Assert.equal(annosvc.getItemAnnotation(itemId, annotation.name),
                   annotation.value);
    }
  }
  async function ensureAnnoUnset() {
    for (let guid of guids) {
      let itemId = await PlacesUtils.promiseItemId(guid);
      Assert.ok(!annosvc.itemHasAnnotation(itemId, annotation.name));
    }
  }

  await ensureAnnoSet();
  await PT.undo();
  await ensureAnnoUnset();
  await PT.redo();
  await ensureAnnoSet();
  await PT.undo();
  await ensureAnnoUnset();

  // Cleanup
  await PT.undo();
  await PT.undo();
  await ensureNonExistent(...guids);
  await PT.clearTransactionsHistory();
  observer.reset();
});

add_task(async function test_remove_multiple() {
  let guids = [];
  await PT.batch(async function() {
    let folderGuid = await PT.NewFolder({ title: "Test Folder",
                                          parentGuid: menuGuid }).transact();
    let nestedFolderGuid =
      await PT.NewFolder({ title: "Nested Test Folder",
                           parentGuid: folderGuid }).transact();
    await PT.NewSeparator(nestedFolderGuid).transact();

    guids.push(folderGuid);

    let bmGuid =
      await PT.NewBookmark({ url: "http://test.bookmark.removed",
                             parentGuid: menuGuid }).transact();
    guids.push(bmGuid);
  });

  let originalInfos = [];
  for (let guid of guids) {
    originalInfos.push(await PlacesUtils.promiseBookmarksTree(guid));
  }

  await PT.Remove(guids).transact();
  await ensureNonExistent(...guids);
  await PT.undo();
  await ensureBookmarksTreeRestoredCorrectly(...originalInfos);
  await PT.redo();
  await ensureNonExistent(...guids);
  await PT.undo();
  await ensureBookmarksTreeRestoredCorrectly(...originalInfos);

  // Undo the New* transactions batch.
  await PT.undo();
  await ensureNonExistent(...guids);

  // Redo it.
  await PT.redo();
  await ensureBookmarksTreeRestoredCorrectlyExceptDates(...originalInfos);

  // Redo remove.
  await PT.redo();
  await ensureNonExistent(...guids);

  // Cleanup
  await PT.clearTransactionsHistory();
  observer.reset();
});

add_task(async function test_renameTag() {
  let url = "http://test.edit.keyword/";
  await PT.Tag({ url, tags: ["t1", "t2"] }).transact();
  ensureTagsForURI(url, ["t1", "t2"]);

  // Create bookmark queries that point to the modified tag.
  let bm1 = await PlacesUtils.bookmarks.insert({
    url: "place:tag=t2",
    parentGuid: PlacesUtils.bookmarks.unfiledGuid
  });
  let bm2 = await PlacesUtils.bookmarks.insert({
    url: "place:tag=t2&sort=1",
    parentGuid: PlacesUtils.bookmarks.unfiledGuid
  });
  // This points to 2 tags, and as such won't be touched.
  let bm3 = await PlacesUtils.bookmarks.insert({
    url: "place:tag=t2&tag=t1",
    parentGuid: PlacesUtils.bookmarks.unfiledGuid
  });

  await PT.RenameTag({ oldTag: "t2", tag: "t3" }).transact();
  ensureTagsForURI(url, ["t1", "t3"]);
  Assert.equal((await PlacesUtils.bookmarks.fetch(bm1.guid)).url.href, "place:tag=t3",
               "The fitst bookmark has been updated");
  Assert.equal((await PlacesUtils.bookmarks.fetch(bm2.guid)).url.href, "place:tag=t3&sort=1",
               "The second bookmark has been updated");
  Assert.equal((await PlacesUtils.bookmarks.fetch(bm3.guid)).url.href, "place:tag=t3&tag=t1",
               "The third bookmark has been updated");

  await PT.undo();
  ensureTagsForURI(url, ["t1", "t2"]);
  Assert.equal((await PlacesUtils.bookmarks.fetch(bm1.guid)).url.href, "place:tag=t2",
               "The fitst bookmark has been restored");
  Assert.equal((await PlacesUtils.bookmarks.fetch(bm2.guid)).url.href, "place:tag=t2&sort=1",
               "The second bookmark has been restored");
  Assert.equal((await PlacesUtils.bookmarks.fetch(bm3.guid)).url.href, "place:tag=t2&tag=t1",
               "The third bookmark has been restored");

  await PT.redo();
  ensureTagsForURI(url, ["t1", "t3"]);
  Assert.equal((await PlacesUtils.bookmarks.fetch(bm1.guid)).url.href, "place:tag=t3",
               "The fitst bookmark has been updated");
  Assert.equal((await PlacesUtils.bookmarks.fetch(bm2.guid)).url.href, "place:tag=t3&sort=1",
               "The second bookmark has been updated");
  Assert.equal((await PlacesUtils.bookmarks.fetch(bm3.guid)).url.href, "place:tag=t3&tag=t1",
               "The third bookmark has been updated");

  await PT.undo();
  ensureTagsForURI(url, ["t1", "t2"]);
  Assert.equal((await PlacesUtils.bookmarks.fetch(bm1.guid)).url.href, "place:tag=t2",
               "The fitst bookmark has been restored");
  Assert.equal((await PlacesUtils.bookmarks.fetch(bm2.guid)).url.href, "place:tag=t2&sort=1",
               "The second bookmark has been restored");
  Assert.equal((await PlacesUtils.bookmarks.fetch(bm3.guid)).url.href, "place:tag=t2&tag=t1",
               "The third bookmark has been restored");

  await PT.undo();
  ensureTagsForURI(url, []);

  await PT.clearTransactionsHistory();
  ensureUndoState();
  await PlacesUtils.bookmarks.eraseEverything();
});
