/* -*- 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 obsvc = PlacesUtils.observers;
const tagssvc = PlacesUtils.tagging;
const PT = PlacesTransactions;
const menuGuid = PlacesUtils.bookmarks.menuGuid;

ChromeUtils.defineESModuleGetters(this, {
  Preferences: "resource://gre/modules/Preferences.sys.mjs",
});

// Create and add bookmarks observer.
var observer = {
  tagRelatedGuids: new Set(),

  reset() {
    this.itemsAdded = new Map();
    this.itemsRemoved = new Map();
    this.itemsMoved = new Map();
    this.itemsKeywordChanged = new Map();
    this.itemsTitleChanged = new Map();
    this.itemsUrlChanged = new Map();
  },

  handlePlacesEvents(events) {
    for (let event of events) {
      switch (event.type) {
        case "bookmark-added":
          // Ignore tag items.
          if (event.isTagging) {
            this.tagRelatedGuids.add(event.guid);
            return;
          }

          this.itemsAdded.set(event.guid, {
            itemId: event.id,
            parentGuid: event.parentGuid,
            index: event.index,
            itemType: event.itemType,
            title: event.title,
            url: event.url,
          });
          break;
        case "bookmark-removed":
          if (this.tagRelatedGuids.has(event.guid)) {
            return;
          }

          this.itemsRemoved.set(event.guid, {
            parentGuid: event.parentGuid,
            index: event.index,
            itemType: event.itemType,
          });
          break;
        case "bookmark-moved":
          this.itemsMoved.set(event.guid, {
            oldParentGuid: event.oldParentGuid,
            oldIndex: event.oldIndex,
            newParentGuid: event.parentGuid,
            newIndex: event.index,
            itemType: event.itemType,
          });
          break;
        case "bookmark-keyword-changed":
          if (this.tagRelatedGuids.has(event.guid)) {
            return;
          }

          this.itemsKeywordChanged.set(event.guid, {
            keyword: event.keyword,
          });
          break;
        case "bookmark-title-changed":
          if (this.tagRelatedGuids.has(event.guid)) {
            return;
          }

          this.itemsTitleChanged.set(event.guid, {
            title: event.title,
            parentGuid: event.parentGuid,
          });
          break;
        case "bookmark-url-changed":
          if (this.tagRelatedGuids.has(event.guid)) {
            return;
          }

          this.itemsUrlChanged.set(event.guid, {
            url: event.url,
          });
          break;
      }
    }
  },
};
observer.reset();

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

function run_test() {
  observer.handlePlacesEvents = observer.handlePlacesEvents.bind(observer);
  obsvc.addListener(
    [
      "bookmark-added",
      "bookmark-removed",
      "bookmark-moved",
      "bookmark-keyword-changed",
      "bookmark-title-changed",
      "bookmark-url-changed",
    ],
    observer.handlePlacesEvents
  );
  registerCleanupFunction(function () {
    obsvc.removeListener(
      [
        "bookmark-added",
        "bookmark-removed",
        "bookmark-moved",
        "bookmark-keyword-changed",
        "bookmark-title-changed",
        "bookmark-url-changed",
      ],
      observer.handlePlacesEvents
    );
  });

  run_next_test();
}

function sanityCheckTransactionHistory() {
  Assert.lessOrEqual(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(
        Services.io.newURI(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 ensureItemsMoved(...items) {
  Assert.equal(
    observer.itemsMoved.size,
    items.length,
    "Should have received the correct number of moved notifications"
  );
  for (let item of items) {
    Assert.ok(
      observer.itemsMoved.has(item.guid),
      `Observer should have a move for ${item.guid}`
    );
    let info = observer.itemsMoved.get(item.guid);
    Assert.equal(
      info.oldParentGuid,
      item.oldParentGuid,
      "Should have the correct old parent guid"
    );
    Assert.equal(
      info.oldIndex,
      item.oldIndex,
      "Should have the correct old index"
    );
    Assert.equal(
      info.newParentGuid,
      item.newParentGuid,
      "Should have the correct new parent guid"
    );
    Assert.equal(
      info.newIndex,
      item.newIndex,
      "Should have the correct new index"
    );
  }
}

function ensureItemsKeywordChanged(...items) {
  for (const item of items) {
    Assert.ok(
      observer.itemsKeywordChanged.has(item.guid),
      `Observer should have a keyword changed for ${item.guid}`
    );
    const info = observer.itemsKeywordChanged.get(item.guid);
    Assert.equal(info.keyword, item.keyword, "Should have the correct keyword");
  }
}

function ensureItemsTitleChanged(...items) {
  Assert.equal(
    observer.itemsTitleChanged.size,
    items.length,
    "Should have received the correct number of bookmark-title-changed notifications"
  );
  for (const item of items) {
    Assert.ok(
      observer.itemsTitleChanged.has(item.guid),
      `Observer should have a title changed for ${item.guid}`
    );
    const info = observer.itemsTitleChanged.get(item.guid);
    Assert.equal(info.title, item.title, "Should have the correct title");
    Assert.equal(
      info.parentGuid,
      item.parentGuid,
      "Should have the correct parent guid"
    );
  }
}

function ensureItemsUrlChanged(...items) {
  Assert.equal(
    observer.itemsUrlChanged.size,
    items.length,
    "Should have received the correct number of bookmark-url-changed notifications"
  );
  for (const item of items) {
    Assert.ok(
      observer.itemsUrlChanged.has(item.guid),
      `Observer should have a title changed for ${item.guid}`
    );
    const info = observer.itemsUrlChanged.get(item.guid);
    Assert.equal(info.url, item.url, "Should have the correct url");
  }
}

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 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);
    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]);
    }
  }
}

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());
  let results = await PT.batch([
    txn_a, // Shouldn't be able to use the same transaction twice.
    txn_b,
  ]);
  Assert.strictEqual(
    results[0],
    undefined,
    "First transaction should fail as it can't be reused"
  );
  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_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 folderTxn = PT.NewFolder(folder_info);
  let bmTxn;
  let results = await PT.batch([
    folderTxn,
    previousResults => {
      bm_info.parentGuid = previousResults[0];
      return (bmTxn = PT.NewBookmark(bm_info));
    },
  ]);
  folder_info.guid = results[0];
  bm_info.guid = results[1];

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

  let ensureUndo = () => {
    ensureUndoState([[bmTxn, folderTxn]], 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 bm_a_info = { url: "http://test_move_items.com", title: "Bookmark A" };
  let bm_b_info = { url: "http://test_move_items.com", title: "Bookmark B" };

  // Test moving items within the same folder.
  let folder_a_txn = PT.NewFolder(folder_a_info);
  let bm_a_txn, bm_b_txn;
  let results = await PT.batch([
    folder_a_txn,
    previousResults => {
      bm_a_info.parentGuid = previousResults[0];
      return (bm_a_txn = PT.NewBookmark(bm_a_info));
    },
    previousResults => {
      bm_b_info.parentGuid = previousResults[0];
      return (bm_b_txn = PT.NewBookmark(bm_b_info));
    },
  ]);
  console.log("results: " + results);
  folder_a_info.guid = results[0];
  bm_a_info.guid = results[1];
  bm_b_info.guid = results[2];

  ensureUndoState([[bm_b_txn, bm_a_txn, folder_a_txn]], 0);

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

  let ensureDo = () => {
    ensureUndoState([[moveTxn], [bm_b_txn, bm_a_txn, folder_a_txn]], 0);
    ensureItemsMoved({
      guid: bm_a_info.guid,
      oldParentGuid: folder_a_info.guid,
      newParentGuid: folder_a_info.guid,
      oldIndex: 0,
      newIndex: 1,
    });
    observer.reset();
  };
  let ensureUndo = () => {
    ensureUndoState([[moveTxn], [bm_b_txn, bm_a_txn, folder_a_txn]], 1);
    ensureItemsMoved({
      guid: bm_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([[bm_b_txn, bm_a_txn, folder_a_txn]], 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], [bm_b_txn, bm_a_txn, folder_a_txn]], 0);

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

  ensureDo = () => {
    ensureUndoState(
      [[moveTxn], [folder_b_txn], [bm_b_txn, bm_a_txn, folder_a_txn]],
      0
    );
    ensureItemsMoved({
      guid: bm_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], [bm_b_txn, bm_a_txn, folder_a_txn]],
      1
    );
    ensureItemsMoved({
      guid: bm_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], [bm_b_txn, bm_a_txn, folder_a_txn]],
    3
  );
  await PT.clearTransactionsHistory();
  ensureUndoState();
});

add_task(async function test_move_multiple_items_to_folder() {
  let folder_a_info = createTestFolderInfo("Folder A");
  let bm_a_info = { url: "http://test_move_items.com", title: "Bookmark A" };
  let bm_b_info = { url: "http://test_move_items.com", title: "Bookmark B" };
  let bm_c_info = { url: "http://test_move_items.com", title: "Bookmark C" };

  // Test moving items within the same folder.

  let folder_a_txn = PT.NewFolder(folder_a_info);
  let bm_a_txn, bm_b_txn, bm_c_txn;
  let results = await PT.batch([
    folder_a_txn,
    previousResults => {
      bm_a_info.parentGuid = previousResults[0];
      return (bm_a_txn = PT.NewBookmark(bm_a_info));
    },
    previousResults => {
      bm_b_info.parentGuid = previousResults[0];
      return (bm_b_txn = PT.NewBookmark(bm_b_info));
    },
    previousResults => {
      bm_c_info.parentGuid = previousResults[0];
      return (bm_c_txn = PT.NewBookmark(bm_c_info));
    },
  ]);

  folder_a_info.guid = results[0];
  bm_a_info.guid = results[1];
  bm_b_info.guid = results[2];
  bm_c_info.guid = results[3];

  ensureUndoState([[bm_c_txn, bm_b_txn, bm_a_txn, folder_a_txn]], 0);

  let moveTxn = PT.Move({
    guids: [bm_a_info.guid, bm_b_info.guid],
    newParentGuid: folder_a_info.guid,
  });
  await moveTxn.transact();

  let ensureDo = () => {
    ensureUndoState(
      [[moveTxn], [bm_c_txn, bm_b_txn, bm_a_txn, folder_a_txn]],
      0
    );
    ensureItemsMoved(
      {
        guid: bm_a_info.guid,
        oldParentGuid: folder_a_info.guid,
        newParentGuid: folder_a_info.guid,
        oldIndex: 0,
        newIndex: 2,
      },
      {
        guid: bm_b_info.guid,
        oldParentGuid: folder_a_info.guid,
        newParentGuid: folder_a_info.guid,
        oldIndex: 1,
        newIndex: 2,
      }
    );
    observer.reset();
  };
  let ensureUndo = () => {
    ensureUndoState(
      [[moveTxn], [bm_c_txn, bm_b_txn, bm_a_txn, folder_a_txn]],
      1
    );
    ensureItemsMoved(
      {
        guid: bm_a_info.guid,
        oldParentGuid: folder_a_info.guid,
        newParentGuid: folder_a_info.guid,
        oldIndex: 1,
        newIndex: 0,
      },
      {
        guid: bm_b_info.guid,
        oldParentGuid: folder_a_info.guid,
        newParentGuid: folder_a_info.guid,
        oldIndex: 2,
        newIndex: 1,
      }
    );
    observer.reset();
  };

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

  await PT.clearTransactionsHistory(false, true);
  ensureUndoState([[bm_c_txn, bm_b_txn, bm_a_txn, folder_a_txn]], 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], [bm_c_txn, bm_b_txn, bm_a_txn, folder_a_txn]],
    0
  );

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

  ensureDo = () => {
    ensureUndoState(
      [[moveTxn], [folder_b_txn], [bm_c_txn, bm_b_txn, bm_a_txn, folder_a_txn]],
      0
    );
    ensureItemsMoved({
      guid: bm_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], [bm_c_txn, bm_b_txn, bm_a_txn, folder_a_txn]],
      1
    );
    ensureItemsMoved({
      guid: bm_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, 5);
  ensureUndoState(
    [[moveTxn], [folder_b_txn], [bm_c_txn, bm_b_txn, bm_a_txn, folder_a_txn]],
    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 = PT.NewFolder(folder_level_1_info);
  let folder_level_2_txn;
  let results = await PT.batch([
    folder_level_1_txn,
    previousResults => {
      folder_level_2_info.parentGuid = previousResults[0];
      return (folder_level_2_txn = PT.NewFolder(folder_level_2_info));
    },
  ]);
  folder_level_1_info.guid = results[0];
  folder_level_2_info.guid = results[1];

  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, folder_level_1_txn]]);
  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, folder_level_1_txn],
  ]);
  await ensureItemsRemoved(folder_level_2_info);

  // Undo Remove "Folder Level 2"
  await PT.undo();
  ensureUndoState(
    [[remove_folder_2_txn], [folder_level_2_txn, folder_level_1_txn]],
    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, folder_level_1_txn],
  ]);
  await ensureItemsRemoved(folder_level_2_info);
  observer.reset();

  // Undo it again
  await PT.undo();
  ensureUndoState(
    [[remove_folder_2_txn], [folder_level_2_txn, folder_level_1_txn]],
    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, folder_level_1_txn]],
    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, folder_level_1_txn]],
    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, folder_level_1_txn],
  ]);
  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, folder_level_1_txn]],
    1
  );
  await ensureItemsAdded(folder_level_2_info);
  observer.reset();

  await PT.undo();
  ensureUndoState(
    [[remove_folder_2_txn], [folder_level_2_txn, folder_level_1_txn]],
    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";

  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],
  };
  b2_info.guid = await PT.NewBookmark(b2_info).transact();
  ensureTags([TAG_1, TAG_2]);

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

  // Check if Remove correctly restores tags.
  observer.reset();
  await PT.redo();
  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();
  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();
  // The tag containers are removed in async and take some time
  let oldCountTag1 = 0;
  let oldCountTag2 = 0;
  let allTags = await bmsvc.fetchTags();
  for (let i of allTags) {
    if (i.name == TAG_1) {
      oldCountTag1 = i.count;
    }
    if (i.name == TAG_2) {
      oldCountTag2 = i.count;
    }
  }
  await TestUtils.waitForCondition(async () => {
    allTags = await bmsvc.fetchTags();
    let newCountTag1 = 0;
    let newCountTag2 = 0;
    for (let i of allTags) {
      if (i.name == TAG_1) {
        newCountTag1 = i.count;
      }
      if (i.name == TAG_2) {
        newCountTag2 = i.count;
      }
    }
    return newCountTag1 == oldCountTag1 - 1 && newCountTag2 == oldCountTag2 - 1;
  });
  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 folder_txn = PT.NewFolder(folder_info);
  let separator_txn;
  let results = await PT.batch([
    folder_txn,
    previousResults => {
      separator_info.parentGuid = previousResults[0];
      return (separator_txn = PT.NewSeparator(separator_info));
    },
  ]);
  folder_info.guid = results[0];
  separator_info.guid = results[1];

  undoEntries.unshift([separator_txn, folder_txn]);
  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_edit_title() {
  let bm_info = {
    parentGuid: PlacesUtils.bookmarks.unfiledGuid,
    url: "http://test_create_item.com",
    title: "Original Title",
  };

  function ensureTitleChange(aCurrentTitle) {
    ensureItemsTitleChanged({
      guid: bm_info.guid,
      title: aCurrentTitle,
      parentGuid: PlacesUtils.bookmarks.unfiledGuid,
    });
  }

  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
  ) {
    ensureItemsUrlChanged({
      guid: bm_info.guid,
      url: 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 = "") {
    ensureItemsKeywordChanged({
      guid: bm_info.guid,
      keyword: 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 = "") {
    ensureItemsKeywordChanged({
      guid: bm_info.guid,
      keyword: 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 = "") {
    ensureItemsKeywordChanged({
      guid: bm_info.guid,
      keyword: 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";

  let results = await PT.batch([
    PT.NewBookmark(bm_info_a),
    PT.NewBookmark(bm_info_b),
  ]);
  bm_info_a.guid = results[0];
  bm_info_b.guid = results[1];

  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",
  };

  let results = await PT.batch([
    PT.NewBookmark(bm_info_a),
    PT.NewBookmark(bm_info_b),
  ]);
  ensureTagsForURI(bm_info_a.url, bm_info_a.tags);
  ensureTagsForURI(bm_info_b.url, [bm_info_b.tag]);
  bm_info_a.guid = results[0];

  bm_info_b.guid = results[1];

  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
          ? []
          : 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_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(),
  ];

  let folder_txn = await PT.NewFolder(folder_info);
  let transactions = [folder_txn];
  for (let info of originalOrder) {
    transactions.push(previousResults => {
      info.parentGuid = previousResults[0];
      return info == sep ? PT.NewSeparator(info) : PT.NewBookmark(info);
    });
  }
  let results = await PT.batch(transactions);
  folder_info.guid = results[0];
  for (let i = 0; i < originalOrder.length; ++i) {
    originalOrder[i].guid = results[i + 1];
  }

  let folderContainer = PlacesUtils.getFolderContents(folder_info.guid).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_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 emptyFolderTxn = PT.NewFolder(createTestFolderInfo());
  for (let txn of [sepTxn, emptyFolderTxn]) {
    let guid = await txn.transact();
    await duplicate_and_test(guid);
  }

  // Test duplicating a folder having some contents.
  let results = await PT.batch([
    PT.NewFolder(createTestFolderInfo()),
    previousResults =>
      PT.NewFolder({
        parentGuid: previousResults[0],
        title: "Nested Folder",
      }),
    // Insert a bookmark under the nested folder.
    previousResults =>
      PT.NewBookmark({
        url: "http://nested.nested.bookmark",
        parentGuid: previousResults[1],
      }),
    // Insert a separator below the nested folder
    previousResults => PT.NewSeparator({ parentGuid: previousResults[0] }),
    // And another bookmark.
    previousResults =>
      PT.NewBookmark({
        url: "http://nested.bookmark",
        parentGuid: previousResults[0],
      }),
  ]);
  let filledFolderGuid = results[0];

  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_invalid_uri_spec_throws() {
  Assert.throws(
    () =>
      PT.NewBookmark({
        parentGuid: PlacesUtils.bookmarks.unfiledGuid,
        url: "invalid uri spec",
        title: "test bookmark",
      }),
    /invalid uri spec is not a valid URL/
  );
  Assert.throws(
    () => PT.Tag({ tag: "TheTag", urls: ["invalid uri spec"] }),
    /TypeError: URL constructor: invalid uri spec is not a valid URL/
  );
  Assert.throws(
    () => PT.Tag({ tag: "TheTag", urls: ["about:blank", "invalid uri spec"] }),
    /TypeError: URL constructor: invalid uri spec is not a valid URL/
  );
});

add_task(async function test_remove_multiple() {
  let results = await PT.batch([
    PT.NewFolder({
      title: "Test Folder",
      parentGuid: menuGuid,
    }),
    previousResults =>
      PT.NewFolder({
        title: "Nested Test Folder",
        parentGuid: previousResults[0],
      }),
    previousResults => PT.NewSeparator(previousResults[1]),
    PT.NewBookmark({
      url: "http://test.bookmark.removed",
      parentGuid: menuGuid,
    }),
  ]);
  let guids = [results[0], results[3]];

  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();
});

add_task(async function test_remove_invalid_url() {
  let folderGuid = await PT.NewFolder({
    title: "Test Folder",
    parentGuid: menuGuid,
  }).transact();

  let guid = "invalid_____";
  let folderedGuid = "invalid____2";
  let url = "invalid-uri";
  await PlacesUtils.withConnectionWrapper("test_bookmarks_remove", async db => {
    await db.execute(
      `
      INSERT INTO moz_places(url, url_hash, title, rev_host, guid)
      VALUES (:url, hash(:url), 'Invalid URI', '.', GENERATE_GUID())
    `,
      { url }
    );
    await db.execute(
      `INSERT INTO moz_bookmarks (type, fk, parent, position, guid)
        VALUES (:type,
                (SELECT id FROM moz_places WHERE url = :url),
                (SELECT id FROM moz_bookmarks WHERE guid = :parentGuid),
                (SELECT MAX(position) + 1 FROM moz_bookmarks WHERE parent = (SELECT id FROM moz_bookmarks WHERE guid = :parentGuid)),
                :guid)
      `,
      {
        type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
        url,
        parentGuid: PlacesUtils.bookmarks.unfiledGuid,
        guid,
      }
    );
    await db.execute(
      `INSERT INTO moz_bookmarks (type, fk, parent, position, guid)
        VALUES (:type,
                (SELECT id FROM moz_places WHERE url = :url),
                (SELECT id FROM moz_bookmarks WHERE guid = :parentGuid),
                (SELECT MAX(position) + 1 FROM moz_bookmarks WHERE parent = (SELECT id FROM moz_bookmarks WHERE guid = :parentGuid)),
                :guid)
      `,
      {
        type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
        url,
        parentGuid: folderGuid,
        guid: folderedGuid,
      }
    );
  });

  await PT.Remove([folderGuid, guid]).transact();
  await ensureNonExistent(folderGuid, guid, folderedGuid);
  // Shouldn't throw, should restore the folder but not the bookmarks.
  await PT.undo();
  await ensureNonExistent(guid, folderedGuid);
  Assert.ok(
    await PlacesUtils.bookmarks.fetch(folderGuid),
    "The folder should have been re-created"
  );
  await PT.redo();
  await ensureNonExistent(folderGuid, guid, folderedGuid);
  // Cleanup
  await PT.clearTransactionsHistory();
  observer.reset();
});
