/* -*- 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/. */

/**
 * Tests bookmark queries with tags.  See bug 399799.
 */

"use strict";

add_task(async function tags_getter_setter() {
  info("Tags getter/setter should work correctly");
  info("Without setting tags, tags getter should return empty array");
  var [query] = makeQuery();
  Assert.equal(query.tags.length, 0);

  info("Setting tags to an empty array, tags getter should return empty array");
  [query] = makeQuery([]);
  Assert.equal(query.tags.length, 0);

  info("Setting a few tags, tags getter should return correct array");
  var tags = ["bar", "baz", "foo"];
  [query] = makeQuery(tags);
  setsAreEqual(query.tags, tags, true);

  info("Setting some dupe tags, tags getter return unique tags");
  [query] = makeQuery(["foo", "foo", "bar", "foo", "baz", "bar"]);
  setsAreEqual(query.tags, ["bar", "baz", "foo"], true);
});

add_task(async function invalid_setter_calls() {
  info("Invalid calls to tags setter should fail");
  try {
    var query = PlacesUtils.history.getNewQuery();
    query.tags = null;
    do_throw("Passing null to SetTags should fail");
  } catch (exc) {}

  try {
    query = PlacesUtils.history.getNewQuery();
    query.tags = "this should not work";
    do_throw("Passing a string to SetTags should fail");
  } catch (exc) {}

  try {
    makeQuery([null]);
    do_throw("Passing one-element array with null to SetTags should fail");
  } catch (exc) {}

  try {
    makeQuery([undefined]);
    do_throw("Passing one-element array with undefined to SetTags should fail");
  } catch (exc) {}

  try {
    makeQuery(["foo", null, "bar"]);
    do_throw("Passing mixture of tags and null to SetTags should fail");
  } catch (exc) {}

  try {
    makeQuery(["foo", undefined, "bar"]);
    do_throw("Passing mixture of tags and undefined to SetTags should fail");
  } catch (exc) {}

  try {
    makeQuery([1, 2, 3]);
    do_throw("Passing numbers to SetTags should fail");
  } catch (exc) {}

  try {
    makeQuery(["foo", 1, 2, 3]);
    do_throw("Passing mixture of tags and numbers to SetTags should fail");
  } catch (exc) {}

  try {
    var str = PlacesUtils.toISupportsString("foo");
    query = PlacesUtils.history.getNewQuery();
    query.tags = str;
    do_throw("Passing nsISupportsString to SetTags should fail");
  } catch (exc) {}

  try {
    makeQuery([str]);
    do_throw("Passing array of nsISupportsStrings to SetTags should fail");
  } catch (exc) {}
});

add_task(async function not_setting_tags() {
  info("Not setting tags at all should not affect query URI");
  checkQueryURI();
});

add_task(async function empty_array_tags() {
  info("Setting tags with an empty array should not affect query URI");
  checkQueryURI([]);
});

add_task(async function set_tags() {
  info("Setting some tags should result in correct query URI");
  checkQueryURI([
    "foo",
    "七難",
    "",
    "いっぱいおっぱい",
    "Abracadabra",
    "１２３",
    "Here's a pretty long tag name with some = signs and 1 2 3s and spaces oh jeez will it work I hope so!",
    "アスキーでございません",
    "あいうえお",
  ]);
});

add_task(async function no_tags_tagsAreNot() {
  info(
    "Not setting tags at all but setting tagsAreNot should " +
      "affect query URI"
  );
  checkQueryURI(null, true);
});

add_task(async function empty_array_tags_tagsAreNot() {
  info(
    "Setting tags with an empty array and setting tagsAreNot " +
      "should affect query URI"
  );
  checkQueryURI([], true);
});

add_task(async function () {
  info(
    "Setting some tags and setting tagsAreNot should result in " +
      "correct query URI"
  );
  checkQueryURI(
    [
      "foo",
      "七難",
      "",
      "いっぱいおっぱい",
      "Abracadabra",
      "１２３",
      "Here's a pretty long tag name with some = signs and 1 2 3s and spaces oh jeez will it work I hope so!",
      "アスキーでございません",
      "あいうえお",
    ],
    true
  );
});

add_task(async function tag() {
  info("Querying on tag associated with a URI should return that URI");
  await task_doWithBookmark(["foo", "bar", "baz"], function (aURI) {
    var [query, opts] = makeQuery(["foo"]);
    executeAndCheckQueryResults(query, opts, [aURI.spec]);
    [query, opts] = makeQuery(["bar"]);
    executeAndCheckQueryResults(query, opts, [aURI.spec]);
    [query, opts] = makeQuery(["baz"]);
    executeAndCheckQueryResults(query, opts, [aURI.spec]);
  });
});

add_task(async function many_tags() {
  info("Querying on many tags associated with a URI should return that URI");
  await task_doWithBookmark(["foo", "bar", "baz"], function (aURI) {
    var [query, opts] = makeQuery(["foo", "bar"]);
    executeAndCheckQueryResults(query, opts, [aURI.spec]);
    [query, opts] = makeQuery(["foo", "baz"]);
    executeAndCheckQueryResults(query, opts, [aURI.spec]);
    [query, opts] = makeQuery(["bar", "baz"]);
    executeAndCheckQueryResults(query, opts, [aURI.spec]);
    [query, opts] = makeQuery(["foo", "bar", "baz"]);
    executeAndCheckQueryResults(query, opts, [aURI.spec]);
  });
});

add_task(async function repeated_tag() {
  info("Specifying the same tag multiple times should not matter");
  await task_doWithBookmark(["foo", "bar", "baz"], function (aURI) {
    var [query, opts] = makeQuery(["foo", "foo"]);
    executeAndCheckQueryResults(query, opts, [aURI.spec]);
    [query, opts] = makeQuery(["foo", "foo", "foo", "bar", "bar", "baz"]);
    executeAndCheckQueryResults(query, opts, [aURI.spec]);
  });
});

add_task(async function many_tags_no_bookmark() {
  info(
    "Querying on many tags associated with a URI and tags not associated " +
      "with that URI should not return that URI"
  );
  await task_doWithBookmark(["foo", "bar", "baz"], function () {
    var [query, opts] = makeQuery(["foo", "bogus"]);
    executeAndCheckQueryResults(query, opts, []);
    [query, opts] = makeQuery(["foo", "bar", "bogus"]);
    executeAndCheckQueryResults(query, opts, []);
    [query, opts] = makeQuery(["foo", "bar", "baz", "bogus"]);
    executeAndCheckQueryResults(query, opts, []);
  });
});

add_task(async function nonexistent_tags() {
  info("Querying on nonexistent tag should return no results");
  await task_doWithBookmark(["foo", "bar", "baz"], function () {
    var [query, opts] = makeQuery(["bogus"]);
    executeAndCheckQueryResults(query, opts, []);
    [query, opts] = makeQuery(["bogus", "gnarly"]);
    executeAndCheckQueryResults(query, opts, []);
  });
});

add_task(async function tagsAreNot() {
  info("Querying bookmarks using tagsAreNot should work correctly");
  var urisAndTags = {
    "http://example.com/1": ["foo", "bar"],
    "http://example.com/2": ["baz", "qux"],
    "http://example.com/3": null,
  };

  info("Add bookmarks and tag the URIs");
  for (let [pURI, tags] of Object.entries(urisAndTags)) {
    let nsiuri = uri(pURI);
    await addBookmark(nsiuri);
    if (tags) {
      PlacesUtils.tagging.tagURI(nsiuri, tags);
    }
  }

  info('  Querying for "foo" should match only /2 and /3');
  var [query, opts] = makeQuery(["foo"], true);
  queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root, [
    "http://example.com/2",
    "http://example.com/3",
  ]);

  info('  Querying for "foo" and "bar" should match only /2 and /3');
  [query, opts] = makeQuery(["foo", "bar"], true);
  queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root, [
    "http://example.com/2",
    "http://example.com/3",
  ]);

  info('  Querying for "foo" and "bogus" should match only /2 and /3');
  [query, opts] = makeQuery(["foo", "bogus"], true);
  queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root, [
    "http://example.com/2",
    "http://example.com/3",
  ]);

  info('  Querying for "foo" and "baz" should match only /3');
  [query, opts] = makeQuery(["foo", "baz"], true);
  queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root, [
    "http://example.com/3",
  ]);

  info('  Querying for "bogus" should match all');
  [query, opts] = makeQuery(["bogus"], true);
  queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root, [
    "http://example.com/1",
    "http://example.com/2",
    "http://example.com/3",
  ]);

  // Clean up.
  for (let [pURI, tags] of Object.entries(urisAndTags)) {
    let nsiuri = uri(pURI);
    if (tags) {
      PlacesUtils.tagging.untagURI(nsiuri, tags);
    }
  }
  await task_cleanDatabase();
});

add_task(async function duplicate_tags() {
  info(
    "Duplicate existing tags (i.e., multiple tag folders with " +
      "same name) should not throw off query results"
  );
  var tagName = "foo";

  info("Add bookmark and tag it normally");
  await addBookmark(TEST_URI);
  PlacesUtils.tagging.tagURI(TEST_URI, [tagName]);

  info("Manually create tag folder with same name as tag and insert bookmark");
  let dupTag = await PlacesUtils.bookmarks.insert({
    parentGuid: PlacesUtils.bookmarks.tagsGuid,
    type: PlacesUtils.bookmarks.TYPE_FOLDER,
    title: tagName,
  });

  await PlacesUtils.bookmarks.insert({
    parentGuid: dupTag.guid,
    title: "title",
    url: TEST_URI,
  });

  info("Querying for tag should match URI");
  var [query, opts] = makeQuery([tagName]);
  queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root, [
    TEST_URI.spec,
  ]);

  PlacesUtils.tagging.untagURI(TEST_URI, [tagName]);
  await task_cleanDatabase();
});

add_task(async function folder_named_as_tag() {
  info(
    "Regular folders with the same name as tag should not throw " +
      "off query results"
  );
  var tagName = "foo";

  info("Add bookmark and tag it");
  await addBookmark(TEST_URI);
  PlacesUtils.tagging.tagURI(TEST_URI, [tagName]);

  info("Create folder with same name as tag");
  await PlacesUtils.bookmarks.insert({
    parentGuid: PlacesUtils.bookmarks.unfiledGuid,
    type: PlacesUtils.bookmarks.TYPE_FOLDER,
    title: tagName,
  });

  info("Querying for tag should match URI");
  var [query, opts] = makeQuery([tagName]);
  queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root, [
    TEST_URI.spec,
  ]);

  PlacesUtils.tagging.untagURI(TEST_URI, [tagName]);
  await task_cleanDatabase();
});

add_task(async function ORed_queries() {
  info("Multiple queries ORed together should work");
  var urisAndTags = {
    "http://example.com/1": [],
    "http://example.com/2": [],
  };

  // Search with lots of tags to make sure tag parameter substitution in SQL
  // can handle it with more than one query.
  for (let i = 0; i < 11; i++) {
    urisAndTags["http://example.com/1"].push("/1 tag " + i);
    urisAndTags["http://example.com/2"].push("/2 tag " + i);
  }

  info("Add bookmarks and tag the URIs");
  for (let [pURI, tags] of Object.entries(urisAndTags)) {
    let nsiuri = uri(pURI);
    await addBookmark(nsiuri);
    if (tags) {
      PlacesUtils.tagging.tagURI(nsiuri, tags);
    }
  }

  // Clean up.
  for (let [pURI, tags] of Object.entries(urisAndTags)) {
    let nsiuri = uri(pURI);
    if (tags) {
      PlacesUtils.tagging.untagURI(nsiuri, tags);
    }
  }
  await task_cleanDatabase();
});

add_task(async function tag_casing() {
  info(
    "Querying on associated tags should return " +
      "correct results irrespective of casing of tags."
  );
  await task_doWithBookmark(["fOo", "bAr"], function (aURI) {
    let [query, opts] = makeQuery(["Foo"]);
    executeAndCheckQueryResults(query, opts, [aURI.spec]);
    [query, opts] = makeQuery(["Foo", "Bar"]);
    executeAndCheckQueryResults(query, opts, [aURI.spec]);
    [query, opts] = makeQuery(["Foo"], true);
    executeAndCheckQueryResults(query, opts, []);
    [query, opts] = makeQuery(["Bogus"], true);
    executeAndCheckQueryResults(query, opts, [aURI.spec]);
  });
});

add_task(async function tag_casing_l10n() {
  info(
    "Querying on associated tags should return " +
      "correct results irrespective of casing of tags with international strings."
  );
  // \u041F is a lowercase \u043F
  await task_doWithBookmark(
    ["\u041F\u0442\u0438\u0446\u044B"],
    function (aURI) {
      let [query, opts] = makeQuery(["\u041F\u0442\u0438\u0446\u044B"]);
      executeAndCheckQueryResults(query, opts, [aURI.spec]);
      [query, opts] = makeQuery(["\u043F\u0442\u0438\u0446\u044B"]);
      executeAndCheckQueryResults(query, opts, [aURI.spec]);
    }
  );
  await task_doWithBookmark(
    ["\u043F\u0442\u0438\u0446\u044B"],
    function (aURI) {
      let [query, opts] = makeQuery(["\u041F\u0442\u0438\u0446\u044B"]);
      executeAndCheckQueryResults(query, opts, [aURI.spec]);
      [query, opts] = makeQuery(["\u043F\u0442\u0438\u0446\u044B"]);
      executeAndCheckQueryResults(query, opts, [aURI.spec]);
    }
  );
});

add_task(async function tag_special_char() {
  info(
    "Querying on associated tags should return " +
      "correct results even if tags contain special characters."
  );
  await task_doWithBookmark(["Space ☺️ Between"], function (aURI) {
    let [query, opts] = makeQuery(["Space ☺️ Between"]);
    executeAndCheckQueryResults(query, opts, [aURI.spec]);
    [query, opts] = makeQuery(["Space ☺️ Between"], true);
    executeAndCheckQueryResults(query, opts, []);
    [query, opts] = makeQuery(["Bogus"], true);
    executeAndCheckQueryResults(query, opts, [aURI.spec]);
  });
});

// The tag keys in query URIs, i.e., "place:tag=foo&!tags=1"
//                                          ---     -----
const QUERY_KEY_TAG = "tag";
const QUERY_KEY_NOT_TAGS = "!tags";

const TEST_URI = uri("http://example.com/");

/**
 * Adds a bookmark.
 *
 * @param aURI
 *        URI of the page (an nsIURI)
 */
function addBookmark(aURI) {
  return PlacesUtils.bookmarks.insert({
    parentGuid: PlacesUtils.bookmarks.unfiledGuid,
    title: aURI.spec,
    url: aURI,
  });
}

/**
 * Asynchronous task that removes all pages from history and bookmarks.
 */
async function task_cleanDatabase() {
  await PlacesUtils.bookmarks.eraseEverything();
  await PlacesUtils.history.clear();
}

/**
 * Sets up a query with the specified tags, converts it to a URI, and makes sure
 * the URI is what we expect it to be.
 *
 * @param aTags
 *        The query's tags will be set to those in this array
 * @param aTagsAreNot
 *        The query's tagsAreNot property will be set to this
 */
function checkQueryURI(aTags, aTagsAreNot) {
  var pairs = (aTags || []).sort().map(t => QUERY_KEY_TAG + "=" + encodeTag(t));
  if (aTagsAreNot) {
    pairs.push(QUERY_KEY_NOT_TAGS + "=1");
  }
  var expURI = "place:" + pairs.join("&");
  var [query, opts] = makeQuery(aTags, aTagsAreNot);
  var actualURI = queryURI(query, opts);
  info("Query URI should be what we expect for the given tags");
  Assert.equal(actualURI, expURI);
}

/**
 * Asynchronous task that executes a callback task in a "scoped" database state.
 * A bookmark is added and tagged before the callback is called, and afterward
 * the database is cleared.
 *
 * @param aTags
 *        A bookmark will be added and tagged with this array of tags
 * @param aCallback
 *        A task function that will be called after the bookmark has been tagged
 */
async function task_doWithBookmark(aTags, aCallback) {
  await addBookmark(TEST_URI);
  PlacesUtils.tagging.tagURI(TEST_URI, aTags);
  await aCallback(TEST_URI);
  PlacesUtils.tagging.untagURI(TEST_URI, aTags);
  await task_cleanDatabase();
}

/**
 * queryToQueryString() encodes every character in the query URI that doesn't
 * match /[a-zA-Z]/.  There's no simple JavaScript function that does the same,
 * but encodeURIComponent() comes close, only missing some punctuation.  This
 * function takes care of all of that.
 *
 * @param  aTag
 *         A tag name to encode
 * @return A UTF-8 escaped string suitable for inclusion in a query URI
 */
function encodeTag(aTag) {
  return encodeURIComponent(aTag).replace(
    /[-_.!~*'()]/g, // '
    s => "%" + s.charCodeAt(0).toString(16)
  );
}

/**
 * Executes the given query and compares the results to the given URIs.
 * See queryResultsAre().
 *
 * @param aQuery
 *        An nsINavHistoryQuery
 * @param aQueryOpts
 *        An nsINavHistoryQueryOptions
 * @param aExpectedURIs
 *        Array of URIs (as strings) that aResultRoot should contain
 */
function executeAndCheckQueryResults(aQuery, aQueryOpts, aExpectedURIs) {
  var root = PlacesUtils.history.executeQuery(aQuery, aQueryOpts).root;
  root.containerOpen = true;
  queryResultsAre(root, aExpectedURIs);
  root.containerOpen = false;
}

/**
 * Returns new query and query options objects.  The query's tags will be
 * set to aTags.  aTags may be null, in which case setTags() is not called at
 * all on the query.
 *
 * @param  aTags
 *         The query's tags will be set to those in this array
 * @param  aTagsAreNot
 *         The query's tagsAreNot property will be set to this
 * @return [query, queryOptions]
 */
function makeQuery(aTags, aTagsAreNot) {
  aTagsAreNot = !!aTagsAreNot;
  info(
    "Making a query " +
      (aTags
        ? "with tags " + aTags.toSource()
        : "without calling setTags() at all") +
      " and with tagsAreNot=" +
      aTagsAreNot
  );
  var query = PlacesUtils.history.getNewQuery();
  query.tagsAreNot = aTagsAreNot;
  if (aTags) {
    query.tags = aTags;
    var uniqueTags = [];
    aTags.forEach(function (t) {
      if (typeof t === "string" && !uniqueTags.includes(t)) {
        uniqueTags.push(t);
      }
    });
    uniqueTags.sort();
  }

  info("Made query should be correct for tags and tagsAreNot");
  if (uniqueTags) {
    setsAreEqual(query.tags, uniqueTags, true);
  }
  var expCount = uniqueTags ? uniqueTags.length : 0;
  Assert.equal(query.tags.length, expCount);
  Assert.equal(query.tagsAreNot, aTagsAreNot);

  return [query, PlacesUtils.history.getNewQueryOptions()];
}

/**
 * Ensures that the URIs of aResultRoot are the same as those in aExpectedURIs.
 *
 * @param aResultRoot
 *        The nsINavHistoryContainerResultNode root of an nsINavHistoryResult
 * @param aExpectedURIs
 *        Array of URIs (as strings) that aResultRoot should contain
 */
function queryResultsAre(aResultRoot, aExpectedURIs) {
  var rootWasOpen = aResultRoot.containerOpen;
  if (!rootWasOpen) {
    aResultRoot.containerOpen = true;
  }
  var actualURIs = [];
  for (let i = 0; i < aResultRoot.childCount; i++) {
    actualURIs.push(aResultRoot.getChild(i).uri);
  }
  setsAreEqual(actualURIs, aExpectedURIs);
  if (!rootWasOpen) {
    aResultRoot.containerOpen = false;
  }
}

/**
 * Converts the given query into its query URI.
 *
 * @param  aQuery
 *         An nsINavHistoryQuery
 * @param  aQueryOpts
 *         An nsINavHistoryQueryOptions
 * @return The query's URI
 */
function queryURI(aQuery, aQueryOpts) {
  return PlacesUtils.history.queryToQueryString(aQuery, aQueryOpts);
}

/**
 * Ensures that the arrays contain the same elements and, optionally, in the
 * same order.
 */
function setsAreEqual(aArr1, aArr2, aIsOrdered) {
  Assert.equal(aArr1.length, aArr2.length);
  if (aIsOrdered) {
    for (let i = 0; i < aArr1.length; i++) {
      Assert.equal(aArr1[i], aArr2[i]);
    }
  } else {
    aArr1.forEach(u => Assert.ok(aArr2.includes(u)));
    aArr2.forEach(u => Assert.ok(aArr1.includes(u)));
  }
}
