/* Any copyright is dedicated to the Public Domain.
   http://creativecommons.org/publicdomain/zero/1.0/ */

const PREF_NEWTAB_ENABLED = "browser.newtabpage.enabled";
const PREF_NEWTAB_DIRECTORYSOURCE = "browser.newtabpage.directory.source";
const PREF_NEWTAB_ACTIVITY_STREAM = "browser.newtabpage.activity-stream.enabled";

Services.prefs.setBoolPref(PREF_NEWTAB_ENABLED, true);
Services.prefs.setBoolPref(PREF_NEWTAB_ACTIVITY_STREAM, false);

// Opens and closes a new tab to clear any existing preloaded ones. This is
// necessary to prevent any left-over activity-stream preloaded new tabs from
// affecting these tests.
BrowserOpenTab();
const initialTab = gBrowser.selectedTab;
gBrowser.removeTab(initialTab);

var tmp = {};
Cu.import("resource://gre/modules/NewTabUtils.jsm", tmp);
Cu.import("resource:///modules/DirectoryLinksProvider.jsm", tmp);
Cu.import("resource://testing-common/PlacesTestUtils.jsm", tmp);
Cc["@mozilla.org/moz/jssubscript-loader;1"]
  .getService(Ci.mozIJSSubScriptLoader)
  .loadSubScript("chrome://browser/content/sanitize.js", tmp);
var {NewTabUtils, Sanitizer, DirectoryLinksProvider, PlacesTestUtils} = tmp;

var gWindow = window;

// Default to dummy/empty directory links
var gDirectorySource = 'data:application/json,{"test":1}';
var gOrigDirectorySource;

// The tests assume all 3 rows and all 3 columns of sites are shown, but the
// window may be too small to actually show everything.  Resize it if necessary.
var requiredSize = {};
requiredSize.innerHeight =
  40 + 32 + // undo container + bottom margin
  44 + 32 + // search bar + bottom margin
  (3 * (180 + 32)) + // 3 rows * (tile height + title and bottom margin)
  100; // breathing room
requiredSize.innerWidth =
  (3 * (290 + 20)) + // 3 cols * (tile width + side margins)
  100; // breathing room

var oldSize = {};
Object.keys(requiredSize).forEach(prop => {
  info([prop, gBrowser.contentWindow[prop], requiredSize[prop]]);
  if (gBrowser.contentWindow[prop] < requiredSize[prop]) {
    oldSize[prop] = gBrowser.contentWindow[prop];
    info("Changing browser " + prop + " from " + oldSize[prop] + " to " +
         requiredSize[prop]);
    gBrowser.contentWindow[prop] = requiredSize[prop];
  }
});

var screenHeight = {};
var screenWidth = {};
Cc["@mozilla.org/gfx/screenmanager;1"].
  getService(Ci.nsIScreenManager).
  primaryScreen.
  GetAvailRectDisplayPix({}, {}, screenWidth, screenHeight);
screenHeight = screenHeight.value;
screenWidth = screenWidth.value;

if (screenHeight < gBrowser.contentWindow.outerHeight) {
  info("Warning: Browser outer height is now " +
       gBrowser.contentWindow.outerHeight + ", which is larger than the " +
       "available screen height, " + screenHeight +
       ". That may cause problems.");
}

if (screenWidth < gBrowser.contentWindow.outerWidth) {
  info("Warning: Browser outer width is now " +
       gBrowser.contentWindow.outerWidth + ", which is larger than the " +
       "available screen width, " + screenWidth +
       ". That may cause problems.");
}

registerCleanupFunction(function() {
  while (gWindow.gBrowser.tabs.length > 1)
    gWindow.gBrowser.removeTab(gWindow.gBrowser.tabs[1]);

  Object.keys(oldSize).forEach(prop => {
    if (oldSize[prop]) {
      gBrowser.contentWindow[prop] = oldSize[prop];
    }
  });

  Services.prefs.clearUserPref(PREF_NEWTAB_ENABLED);
  Services.prefs.clearUserPref(PREF_NEWTAB_ACTIVITY_STREAM);
  Services.prefs.setCharPref(PREF_NEWTAB_DIRECTORYSOURCE, gOrigDirectorySource);

  return watchLinksChangeOnce();
});

function pushPrefs(...aPrefs) {
  return SpecialPowers.pushPrefEnv({"set": aPrefs});
}

/**
 * Resolves promise when directory links are downloaded and written to disk
 */
function watchLinksChangeOnce() {
  return new Promise(resolve => {
    let observer = {
      onManyLinksChanged: () => {
        DirectoryLinksProvider.removeObserver(observer);
        resolve();
      }
    };
    observer.onDownloadFail = observer.onManyLinksChanged;
    DirectoryLinksProvider.addObserver(observer);
  });
}

add_task(async function setup() {
  registerCleanupFunction(function() {
    return new Promise(resolve => {
      function cleanupAndFinish() {
        PlacesTestUtils.clearHistory().then(() => {
          whenPagesUpdated().then(resolve);
          NewTabUtils.restore();
        });
      }

      let callbacks = NewTabUtils.links._populateCallbacks;
      let numCallbacks = callbacks.length;

      if (numCallbacks)
        callbacks.splice(0, numCallbacks, cleanupAndFinish);
      else
        cleanupAndFinish();
    });
  });

  let promiseReady = (async function() {
    await watchLinksChangeOnce();
    await whenPagesUpdated();
  })();

  // Save the original directory source (which is set globally for tests)
  gOrigDirectorySource = Services.prefs.getCharPref(PREF_NEWTAB_DIRECTORYSOURCE);
  Services.prefs.setCharPref(PREF_NEWTAB_DIRECTORYSOURCE, gDirectorySource);
  await promiseReady;
});

/** Perform an action on a cell within the newtab page.
  * @param aIndex index of cell
  * @param aFn function to call in child process or tab.
  * @returns result of calling the function.
  */
function performOnCell(aIndex, aFn) {
  return ContentTask.spawn(gWindow.gBrowser.selectedBrowser,
                           { index: aIndex, fn: aFn.toString() }, async function(args) {
    let cell = content.gGrid.cells[args.index];
    // eslint-disable-next-line no-eval
    return eval(args.fn)(cell);
  });
}

/**
 * Allows to provide a list of links that is used to construct the grid.
 * @param aLinksPattern the pattern (see below)
 *
 * Example: setLinks("-1,0,1,2,3")
 * Result: [{url: "http://example.com/", title: "site#-1"},
 *          {url: "http://example0.com/", title: "site#0"},
 *          {url: "http://example1.com/", title: "site#1"},
 *          {url: "http://example2.com/", title: "site#2"},
 *          {url: "http://example3.com/", title: "site#3"}]
 */
function setLinks(aLinks) {
  return new Promise(resolve => {
    let links = aLinks;

    if (typeof links == "string") {
      links = aLinks.split(/\s*,\s*/).map(function(id) {
        return {url: "http://example" + (id != "-1" ? id : "") + ".com/",
                title: "site#" + id};
      });
    }

    // Call populateCache() once to make sure that all link fetching that is
    // currently in progress has ended. We clear the history, fill it with the
    // given entries and call populateCache() now again to make sure the cache
    // has the desired contents.
    NewTabUtils.links.populateCache(function() {
      PlacesTestUtils.clearHistory().then(() => {
        fillHistory(links).then(() => {
          NewTabUtils.links.populateCache(function() {
            NewTabUtils.allPages.update();
            resolve();
          }, true);
        });
      });
    });
  });
}

function fillHistory(aLinks) {
  return new Promise(resolve => {
    let numLinks = aLinks.length;
    if (!numLinks) {
      executeSoon(resolve);
      return;
    }

    let transitionLink = Ci.nsINavHistoryService.TRANSITION_LINK;

    // Important: To avoid test failures due to clock jitter on Windows XP, call
    // Date.now() once here, not each time through the loop.
    let now = Date.now() * 1000;

    for (let i = 0; i < aLinks.length; i++) {
      let link = aLinks[i];
      let place = {
        uri: makeURI(link.url),
        title: link.title,
        // Links are secondarily sorted by visit date descending, so decrease the
        // visit date as we progress through the array so that links appear in the
        // grid in the order they're present in the array.
        visits: [{visitDate: now - i, transitionType: transitionLink}]
      };

      PlacesUtils.asyncHistory.updatePlaces(place, {
        handleError: () => ok(false, "couldn't add visit to history"),
        handleResult() {},
        handleCompletion() {
          if (--numLinks == 0) {
            resolve();
          }
        }
      });
    }
  });
}

/**
 * Allows to specify the list of pinned links (that have a fixed position in
 * the grid.
 * @param aLinksPattern the pattern (see below)
 *
 * Example: setPinnedLinks("3,,1")
 * Result: 'http://example3.com/' is pinned in the first cell. 'http://example1.com/' is
 *         pinned in the third cell.
 */
function setPinnedLinks(aLinks) {
  let links = aLinks;

  if (typeof links == "string") {
    links = aLinks.split(/\s*,\s*/).map(function(id) {
      if (id)
        return {url: "http://example" + (id != "-1" ? id : "") + ".com/",
                title: "site#" + id,
                type: "history"};
      return undefined;
    });
  }

  Services.prefs.setStringPref("browser.newtabpage.pinned", JSON.stringify(links));

  NewTabUtils.pinnedLinks.resetCache();
  NewTabUtils.allPages.update();
}

/**
 * Restore the grid state.
 */
function restore() {
  return new Promise(resolve => {
    whenPagesUpdated().then(resolve);
    NewTabUtils.restore();
  });
}

/**
 * Wait until a given condition becomes true.
 */
function waitForCondition(aConditionFn, aMaxTries = 50, aCheckInterval = 100) {
  return new Promise((resolve, reject) => {
    let tries = 0;

    function tryNow() {
      tries++;

      if (aConditionFn()) {
        resolve();
      } else if (tries < aMaxTries) {
        tryAgain();
      } else {
        reject("Condition timed out: " + aConditionFn.toSource());
      }
    }

    function tryAgain() {
      setTimeout(tryNow, aCheckInterval);
    }

    tryAgain();
  });
}

/**
 * Creates a new tab containing 'about:newtab'.
 */
async function addNewTabPageTab() {
  let tab = await BrowserTestUtils.openNewForegroundTab(gWindow.gBrowser, "about:newtab", false);
  let browser = tab.linkedBrowser;

  // Wait for the document to become visible in case it was preloaded.
  await waitForCondition(() => !browser.contentDocument.hidden)

  await new Promise(resolve => {
    if (NewTabUtils.allPages.enabled) {
      // Continue when the link cache has been populated.
      NewTabUtils.links.populateCache(function() {
        whenSearchInitDone().then(resolve);
      });
    } else {
      resolve();
    }
  });

  return tab;
}

/**
 * Compares the current grid arrangement with the given pattern.
 * @param the pattern (see below)
 *
 * Example: checkGrid("3p,2,,4p")
 * Result: We expect the first cell to contain the pinned site 'http://example3.com/'.
 *         The second cell contains 'http://example2.com/'. The third cell is empty.
 *         The fourth cell contains the pinned site 'http://example4.com/'.
 */
async function checkGrid(pattern) {
  let length = pattern.split(",").length;

  await ContentTask.spawn(gWindow.gBrowser.selectedBrowser,
                          { length, pattern }, async function(args) {
    let grid = content.wrappedJSObject.gGrid;

    let sites = grid.sites.slice(0, args.length);
    let foundPattern = sites.map(function(aSite) {
      if (!aSite)
        return "";

      let pinned = aSite.isPinned();
      let hasPinnedAttr = aSite.node.hasAttribute("pinned");

      if (pinned != hasPinnedAttr)
        ok(false, "invalid state (site.isPinned() != site[pinned])");

      return aSite.url.replace(/^http:\/\/example(\d+)\.com\/$/, "$1") + (pinned ? "p" : "");
    });

    Assert.equal(foundPattern, args.pattern, "grid status = " + args.pattern);
  });
}

/**
 * Blocks a site from the grid.
 * @param aIndex The cell index.
 */
function blockCell(aIndex) {
  return new Promise(resolve => {
    whenPagesUpdated().then(resolve);
    performOnCell(aIndex, cell => {
      return cell.site.block();
    });
  });
}

/**
 * Pins a site on a given position.
 * @param aIndex The cell index.
 * @param aPinIndex The index the defines where the site should be pinned.
 */
function pinCell(aIndex) {
  performOnCell(aIndex, cell => {
    cell.site.pin();
  });
}

/**
 * Unpins the given cell's site.
 * @param aIndex The cell index.
 */
function unpinCell(aIndex) {
  return new Promise(resolve => {
    whenPagesUpdated().then(resolve);
    performOnCell(aIndex, cell => {
      cell.site.unpin();
    });
  });
}

/**
 * Simulates a drag and drop operation. Instead of rearranging a site that is
 * is already contained in the newtab grid, this is used to simulate dragging
 * an external link onto the grid e.g. the text from the URL bar.
 * @param aDestIndex The cell index of the drop target.
 */
async function simulateExternalDrop(aDestIndex) {
  let pagesUpdatedPromise = whenPagesUpdated();

  await ContentTask.spawn(gWindow.gBrowser.selectedBrowser, aDestIndex, async function(dropIndex) {
    return new Promise(resolve => {
      const url = "data:text/html;charset=utf-8," +
                  "<a id='link' href='http://example99.com/'>link</a>";

      let doc = content.document;
      let iframe = doc.createElement("iframe");

      function iframeLoaded() {
        let dataTransfer = new iframe.contentWindow.DataTransfer("dragstart", false);
        dataTransfer.mozSetDataAt("text/x-moz-url", "http://example99.com/", 0);

        let event = content.document.createEvent("DragEvent");
        event.initDragEvent("drop", true, true, content, 0, 0, 0, 0, 0,
                            false, false, false, false, 0, null, dataTransfer);

        let target = content.gGrid.cells[dropIndex].node;
        target.dispatchEvent(event);

        iframe.remove();

        resolve();
      }

      iframe.addEventListener("load", function() {
        content.setTimeout(iframeLoaded, 0);
      }, {once: true});

      iframe.setAttribute("src", url);
      iframe.style.width = "50px";
      iframe.style.height = "50px";
      iframe.style.position = "absolute";
      iframe.style.zIndex = 50;

      // the frame has to be attached to a visible element
      let margin = doc.getElementById("newtab-search-container");
      margin.appendChild(iframe);
    });
  });

  await pagesUpdatedPromise;
}

/**
 * Resumes testing when all pages have been updated.
 */
function whenPagesUpdated() {
  return new Promise(resolve => {
    let page = {
      observe: _ => _,

      update() {
        NewTabUtils.allPages.unregister(this);
        executeSoon(resolve);
      }
    };

    NewTabUtils.allPages.register(page);
    registerCleanupFunction(function() {
      NewTabUtils.allPages.unregister(page);
    });
  });
}

/**
 * Waits for the response to the page's initial search state request.
 */
function whenSearchInitDone() {
  return ContentTask.spawn(gWindow.gBrowser.selectedBrowser, {}, async function() {
    return new Promise(resolve => {
      if (content.gSearch) {
        let searchController = content.gSearch._contentSearchController;
        if (searchController.defaultEngine) {
          resolve();
          return;
        }
      }

      let eventName = "ContentSearchService";
      content.addEventListener(eventName, function onEvent(event) {
        if (event.detail.type == "State") {
          content.removeEventListener(eventName, onEvent);
          let resolver = function() {
            // Wait for the search controller to receive the event, then resolve.
            if (content.gSearch._contentSearchController.defaultEngine) {
              resolve();
            }
          }
          content.setTimeout(resolver, 0);
        }
      });
    });
  });
}

/**
 * Changes the newtab customization option and waits for the panel to open and close
 *
 * @param {string} aTheme
 *        Can be any of("blank"|"classic"|"enhanced")
 */
function customizeNewTabPage(aTheme) {
  return ContentTask.spawn(gWindow.gBrowser.selectedBrowser, aTheme, async function(contentTheme) {

    let document = content.document;
    let panel = document.getElementById("newtab-customize-panel");
    let customizeButton = document.getElementById("newtab-customize-button");

    function panelOpened(opened) {
      return new Promise( (resolve) => {
        let options = {attributes: true, oldValue: true};
        let observer = new content.MutationObserver(function(mutations) {
          mutations.forEach(function(mutation) {
            document.getElementById("newtab-customize-" + contentTheme).click();
            observer.disconnect();
            if (opened == panel.hasAttribute("open")) {
              resolve();
            }
          });
        });
        observer.observe(panel, options);
      });
    }

    let opened = panelOpened(true);
    customizeButton.click();
    await opened;

    let closed = panelOpened(false);
    customizeButton.click();
    await closed;
  });
}

/**
 * Reports presence of a scrollbar
 */
function hasScrollbar() {
  return ContentTask.spawn(gWindow.gBrowser.selectedBrowser, {}, async function() {
    let docElement = content.document.documentElement;
    return docElement.scrollHeight > docElement.clientHeight;
  });
}
