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

// Array of visits we will add to the database, will be populated later
// in the test.
var visits = [];

/**
 * Takes a sequence of query options, and compare query results obtained through
 * them with a custom filtered array of visits, based on the values we are
 * expecting from the query.
 *
 * @param  aSequence
 *         an array that contains query options in the form:
 *         [includeHidden, maxResults, sortingMode]
 */
function check_results_callback(aSequence) {
  // Sanity check: we should receive 3 parameters.
  Assert.equal(aSequence.length, 3);
  let includeHidden = aSequence[0];
  let maxResults = aSequence[1];
  let sortingMode = aSequence[2];
  print(
    "\nTESTING: includeHidden(" +
      includeHidden +
      ")," +
      " maxResults(" +
      maxResults +
      ")," +
      " sortingMode(" +
      sortingMode +
      ")."
  );

  function isHidden(aVisit) {
    return (
      aVisit.transType == Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK ||
      aVisit.isRedirect
    );
  }

  // Build expectedData array.
  let expectedData = visits.filter(function (aVisit, aIndex, aArray) {
    // Embed visits never appear in results.
    if (aVisit.transType == Ci.nsINavHistoryService.TRANSITION_EMBED) {
      return false;
    }

    if (!includeHidden && isHidden(aVisit)) {
      // If the page has any non-hidden visit, then it's visible.
      if (
        !visits.filter(function (refVisit) {
          return refVisit.uri == aVisit.uri && !isHidden(refVisit);
        }).length
      ) {
        return false;
      }
    }

    return true;
  });

  // Remove duplicates, since queries are RESULTS_AS_URI (unique pages).
  let seen = [];
  expectedData = expectedData.filter(function (aData) {
    if (seen.includes(aData.uri)) {
      return false;
    }
    seen.push(aData.uri);
    return true;
  });

  // Sort expectedData.
  function getFirstIndexFor(aEntry) {
    for (let i = 0; i < visits.length; i++) {
      if (visits[i].uri == aEntry.uri) {
        return i;
      }
    }
    return undefined;
  }
  function comparator(a, b) {
    if (sortingMode == Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING) {
      return b.lastVisit - a.lastVisit;
    }
    if (
      sortingMode == Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_DESCENDING
    ) {
      return b.visitCount - a.visitCount;
    }
    return getFirstIndexFor(a) - getFirstIndexFor(b);
  }
  expectedData.sort(comparator);

  // Crop results to maxResults if it's defined.
  if (maxResults) {
    expectedData = expectedData.slice(0, maxResults);
  }

  // Create a new query with required options.
  let query = PlacesUtils.history.getNewQuery();
  let options = PlacesUtils.history.getNewQueryOptions();
  options.includeHidden = includeHidden;
  options.sortingMode = sortingMode;
  if (maxResults) {
    options.maxResults = maxResults;
  }

  // Compare resultset with expectedData.
  let result = PlacesUtils.history.executeQuery(query, options);
  let root = result.root;
  root.containerOpen = true;
  compareArrayToResult(expectedData, root);
  root.containerOpen = false;
}

/**
 * Enumerates all the sequences of the cartesian product of the arrays contained
 * in aSequences.  Examples:
 *
 *   cartProd([[1, 2, 3], ["a", "b"]], callback);
 *   // callback is called 3 * 2 = 6 times with the following arrays:
 *   // [1, "a"], [1, "b"], [2, "a"], [2, "b"], [3, "a"], [3, "b"]
 *
 *   cartProd([["a"], [1, 2, 3], ["X", "Y"]], callback);
 *   // callback is called 1 * 3 * 2 = 6 times with the following arrays:
 *   // ["a", 1, "X"], ["a", 1, "Y"], ["a", 2, "X"], ["a", 2, "Y"],
 *   // ["a", 3, "X"], ["a", 3, "Y"]
 *
 *   cartProd([[1], [2], [3], [4]], callback);
 *   // callback is called 1 * 1 * 1 * 1 = 1 time with the following array:
 *   // [1, 2, 3, 4]
 *
 *   cartProd([], callback);
 *   // callback is 0 times
 *
 *   cartProd([[1, 2, 3, 4]], callback);
 *   // callback is called 4 times with the following arrays:
 *   // [1], [2], [3], [4]
 *
 * @param  aSequences
 *         an array that contains an arbitrary number of arrays
 * @param  aCallback
 *         a function that is passed each sequence of the product as it's
 *         computed
 * @return the total number of sequences in the product
 */
function cartProd(aSequences, aCallback) {
  if (aSequences.length === 0) {
    return 0;
  }

  // For each sequence in aSequences, we maintain a pointer (an array index,
  // really) to the element we're currently enumerating in that sequence
  let seqEltPtrs = aSequences.map(i => 0);

  let numProds = 0;
  let done = false;
  while (!done) {
    numProds++;

    // prod = sequence in product we're currently enumerating
    let prod = [];
    for (let i = 0; i < aSequences.length; i++) {
      prod.push(aSequences[i][seqEltPtrs[i]]);
    }
    aCallback(prod);

    // The next sequence in the product differs from the current one by just a
    // single element.  Determine which element that is.  We advance the
    // "rightmost" element pointer to the "right" by one.  If we move past the
    // end of that pointer's sequence, reset the pointer to the first element
    // in its sequence and then try the sequence to the "left", and so on.

    // seqPtr = index of rightmost input sequence whose element pointer is not
    // past the end of the sequence
    let seqPtr = aSequences.length - 1;
    while (!done) {
      // Advance the rightmost element pointer.
      seqEltPtrs[seqPtr]++;

      // The rightmost element pointer is past the end of its sequence.
      if (seqEltPtrs[seqPtr] >= aSequences[seqPtr].length) {
        seqEltPtrs[seqPtr] = 0;
        seqPtr--;

        // All element pointers are past the ends of their sequences.
        if (seqPtr < 0) {
          done = true;
        }
      } else {
        break;
      }
    }
  }
  return numProds;
}

/**
 * Populate the visits array and add visits to the database.
 * We will generate visit-chains like:
 *   visit -> redirect_temp -> redirect_perm
 */
add_task(async function test_add_visits_to_database() {
  await PlacesUtils.bookmarks.eraseEverything();

  // We don't really bother on this, but we need a time to add visits.
  let timeInMicroseconds = Date.now() * 1000;
  let visitCount = 1;

  // Array of all possible transition types we could be redirected from.
  let t = [
    Ci.nsINavHistoryService.TRANSITION_LINK,
    Ci.nsINavHistoryService.TRANSITION_TYPED,
    Ci.nsINavHistoryService.TRANSITION_BOOKMARK,
    // Embed visits are not added to the database and we don't want redirects
    // to them, thus just avoid addition.
    // Ci.nsINavHistoryService.TRANSITION_EMBED,
    Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK,
    // Would make hard sorting by visit date because last_visit_date is actually
    // calculated excluding download transitions, but the query includes
    // downloads.
    // TODO: Bug 488966 could fix this behavior.
    // Ci.nsINavHistoryService.TRANSITION_DOWNLOAD,
  ];

  function newTimeInMicroseconds() {
    timeInMicroseconds = timeInMicroseconds - 1000;
    return timeInMicroseconds;
  }

  // we add a visit for each of the above transition types.
  t.forEach(transition =>
    visits.push({
      isVisit: true,
      transType: transition,
      uri: "http://" + transition + ".example.com/",
      title: transition + "-example",
      isRedirect: true,
      lastVisit: newTimeInMicroseconds(),
      visitCount:
        transition == Ci.nsINavHistoryService.TRANSITION_EMBED ||
        transition == Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK
          ? 0
          : visitCount++,
      isInQuery: true,
    })
  );

  // Add a REDIRECT_TEMPORARY layer of visits for each of the above visits.
  t.forEach(transition =>
    visits.push({
      isVisit: true,
      transType: Ci.nsINavHistoryService.TRANSITION_REDIRECT_TEMPORARY,
      uri: "http://" + transition + ".redirect.temp.example.com/",
      title: transition + "-redirect-temp-example",
      lastVisit: newTimeInMicroseconds(),
      isRedirect: true,
      referrer: "http://" + transition + ".example.com/",
      visitCount: visitCount++,
      isInQuery: true,
    })
  );

  // Add a REDIRECT_PERMANENT layer of visits for each of the above redirects.
  t.forEach(transition =>
    visits.push({
      isVisit: true,
      transType: Ci.nsINavHistoryService.TRANSITION_REDIRECT_PERMANENT,
      uri: "http://" + transition + ".redirect.perm.example.com/",
      title: transition + "-redirect-perm-example",
      lastVisit: newTimeInMicroseconds(),
      isRedirect: true,
      referrer: "http://" + transition + ".redirect.temp.example.com/",
      visitCount: visitCount++,
      isInQuery: true,
    })
  );

  // Add a REDIRECT_PERMANENT layer of visits that loop to the first visit.
  // These entries should not change visitCount or lastVisit, otherwise
  // guessing an order would be a nightmare.
  function getLastValue(aURI, aProperty) {
    for (let i = 0; i < visits.length; i++) {
      if (visits[i].uri == aURI) {
        return visits[i][aProperty];
      }
    }
    do_throw("Unknown uri.");
    return null;
  }
  t.forEach(transition =>
    visits.push({
      isVisit: true,
      transType: Ci.nsINavHistoryService.TRANSITION_REDIRECT_PERMANENT,
      uri: "http://" + transition + ".example.com/",
      title: getLastValue("http://" + transition + ".example.com/", "title"),
      lastVisit: getLastValue(
        "http://" + transition + ".example.com/",
        "lastVisit"
      ),
      isRedirect: true,
      referrer: "http://" + transition + ".redirect.perm.example.com/",
      visitCount: getLastValue(
        "http://" + transition + ".example.com/",
        "visitCount"
      ),
      isInQuery: true,
    })
  );

  // Add an unvisited bookmark in the database, it should never appear.
  visits.push({
    isBookmark: true,
    uri: "http://unvisited.bookmark.com/",
    parentGuid: PlacesUtils.bookmarks.menuGuid,
    index: PlacesUtils.bookmarks.DEFAULT_INDEX,
    title: "Unvisited Bookmark",
    isInQuery: false,
  });

  // Put visits in the database.
  await task_populateDB(visits);
});

add_task(async function test_redirects() {
  // Frecency and hidden are updated asynchronously, wait for them.
  await PlacesTestUtils.promiseAsyncUpdates();

  // This array will be used by cartProd to generate a matrix of all possible
  // combinations.
  let includeHidden_options = [true, false];
  let maxResults_options = [5, 10, 20, null];
  // These sortingMode are choosen to toggle using special queries for history
  // menu.
  let sorting_options = [
    Ci.nsINavHistoryQueryOptions.SORT_BY_NONE,
    Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_DESCENDING,
    Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING,
  ];
  // Will execute check_results_callback() for each generated combination.
  cartProd(
    [includeHidden_options, maxResults_options, sorting_options],
    check_results_callback
  );

  await PlacesUtils.bookmarks.eraseEverything();

  await PlacesUtils.history.clear();
});
