Cu.import("resource://gre/modules/FileUtils.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");

const IS_MACOSX = ("nsILocalFileMac" in Ci);
const IS_WIN = ("@mozilla.org/windows-registry-key;1" in Cc);

const BIN_SUFFIX = (IS_WIN ? ".exe" : "");
const FILE_UPDATER_BIN = "updater" + (IS_MACOSX ? ".app" : BIN_SUFFIX);
const FILE_UPDATER_BIN_BAK = FILE_UPDATER_BIN + ".bak";

let gRembemberedPrefs = [];

const DATA_URI_SPEC =  "chrome://mochitests/content/browser/browser/base/content/test/appUpdate/";

var DEBUG_AUS_TEST = true;
var gUseTestUpdater = false;

const LOG_FUNCTION = info;

/* import-globals-from testConstants.js */
Services.scriptloader.loadSubScript(DATA_URI_SPEC + "testConstants.js", this);
/* import-globals-from sharedUpdateXML.js */
Services.scriptloader.loadSubScript(DATA_URI_SPEC + "shared.js", this);

var gURLData = URL_HOST + "/" + REL_PATH_DATA;
const URL_MANUAL_UPDATE = gURLData + "downloadPage.html";

const NOTIFICATIONS = [
  "update-available",
  "update-manual",
  "update-restart"
];

/**
 * Delay for a very short period. Useful for moving the code after this
 * to the back of the event loop.
 *
 * @return A promise which will resolve after a very short period.
 */
function delay() {
  return new Promise(resolve => executeSoon(resolve));
}

/**
 * Gets the update version info for the update url parameters to send to
 * update.sjs.
 *
 * @param  aAppVersion (optional)
 *         The application version for the update snippet. If not specified the
 *         current application version will be used.
 * @return The url parameters for the application and platform version to send
 *         to update.sjs.
 */
function getVersionParams(aAppVersion) {
  let appInfo = Services.appinfo;
  return "&appVersion=" + (aAppVersion ? aAppVersion : appInfo.version);
}

/**
 * Clean up updates list and the updates directory.
 */
function cleanUpUpdates() {
  gUpdateManager.activeUpdate = null;
  gUpdateManager.saveUpdates();

  removeUpdateDirsAndFiles();
}

/**
 * Runs a typical update test. Will set various common prefs for using the
 * updater doorhanger, runs the provided list of steps, and makes sure
 * everything is cleaned up afterwards.
 *
 * @param  updateParams
 *         URL-encoded params which will be sent to update.sjs.
 * @param  checkAttempts
 *         How many times to check for updates. Useful for testing the UI
 *         for check failures.
 * @param  steps
 *         A list of test steps to perform, specifying expected doorhangers
 *         and additional validation/cleanup callbacks.
 * @return A promise which will resolve once all of the steps have been run
 *         and cleanup has been performed.
 */
function runUpdateTest(updateParams, checkAttempts, steps) {
  return Task.spawn(function*() {
    registerCleanupFunction(() => {
      popPrefs();
      gMenuButtonUpdateBadge.uninit();
      gMenuButtonUpdateBadge.init();
      cleanUpUpdates();
    });

    pushPref("Int", PREF_APP_UPDATE_DOWNLOADPROMPTATTEMPTS, 0);
    pushPref("Bool", PREF_APP_UPDATE_ENABLED, true);
    pushPref("Int", PREF_APP_UPDATE_IDLETIME, 0);
    pushPref("Char", PREF_APP_UPDATE_URL_MANUAL, URL_MANUAL_UPDATE);
    if (DEBUG_AUS_TEST) {
      pushPref("Bool", PREF_APP_UPDATE_LOG, true);
    }

    yield setupTestUpdater();

    let url = URL_HTTP_UPDATE_SJS +
              "?" + updateParams +
              getVersionParams();

    setUpdateURL(url);

    executeSoon(() => {
      Task.spawn(function*() {
        gAUS.checkForBackgroundUpdates();
        for (var i = 0; i < checkAttempts - 1; i++) {
          yield waitForEvent("update-error", "check-attempt-failed");
          gAUS.checkForBackgroundUpdates();
        }
      });
    });

    for (let step of steps) {
      yield processStep(step);
    }

    yield finishTestRestoreUpdaterBackup();
  });
}

/**
 * Runs a test which processes an update. Similar to runUpdateTest.
 *
 * @param  updates
 *         A list of updates to process.
 * @param  steps
 *         A list of test steps to perform, specifying expected doorhangers
 *         and additional validation/cleanup callbacks.
 * @return A promise which will resolve once all of the steps have been run
 *         and cleanup has been performed.
 */
function runUpdateProcessingTest(updates, steps) {
  return Task.spawn(function*() {
    registerCleanupFunction(() => {
      popPrefs();
      gMenuButtonUpdateBadge.reset();
      cleanUpUpdates();
    });

    pushPref("Int", PREF_APP_UPDATE_DOWNLOADPROMPTATTEMPTS, 0);
    pushPref("Bool", PREF_APP_UPDATE_ENABLED, true);
    pushPref("Int", PREF_APP_UPDATE_IDLETIME, 0);
    pushPref("Char", PREF_APP_UPDATE_URL_MANUAL, URL_MANUAL_UPDATE);
    pushPref("Bool", PREF_APP_UPDATE_ENABLED, true);
    if (DEBUG_AUS_TEST) {
      pushPref("Bool", PREF_APP_UPDATE_LOG, true);
    }

    yield setupTestUpdater();

    writeUpdatesToXMLFile(getLocalUpdatesXMLString(updates), true);

    writeUpdatesToXMLFile(getLocalUpdatesXMLString(""), false);
    writeStatusFile(STATE_FAILED_CRC_ERROR);
    reloadUpdateManagerData();

    testPostUpdateProcessing();

    for (let step of steps) {
      yield processStep(step);
    }

    yield finishTestRestoreUpdaterBackup();
  });
}

function processStep({notificationId, button, beforeClick, cleanup}) {
  return Task.spawn(function*() {
    const promises = NOTIFICATIONS.map(n => waitForEvent(`panelUI-${n}`, "doorhanger-shown"));
    const topic = yield Promise.race(promises);

    is(topic, `panelUI-${notificationId}`, "The right notification showed up.");
    if (topic != `panelUI-${notificationId}`) {
      if (cleanup) {
        yield Task.spawn(cleanup);
      }
      return;
    }

    let notification = document.getElementById(`PanelUI-${notificationId}-notification`);
    is(notification.hidden, false, `${notificationId} notification is showing`);
    if (beforeClick) {
      yield Task.spawn(beforeClick);
    }

    let buttonEl = document.getAnonymousElementByAttribute(notification, "anonid", button);

    buttonEl.click();

    if (cleanup) {
      yield Task.spawn(cleanup);
    }
  });
}

/**
 * Sets a preference's value in a way that it is remembered and can be set
 * back to what it was previously.
 *
 * @param  type
 *         {Int, Char, Bool, etc.} as in getCharPref.
 * @param  name
 *         The name of the preference.
 * @param  value
 *         The value to temporarily give the preference.
 * @param  prefBranch
 *         Optional pref branch to use.
 */
function pushPref(type, name, value, prefBranch) {
  if (!prefBranch) {
    prefBranch = Services.prefs;
  }

  let oldValue;
  try {
    oldValue = prefBranch[`get${type}Pref`](name);
  } catch (e) {
    oldValue = null;
  }
  gRembemberedPrefs.push({type, name, oldValue, prefBranch});

  prefBranch[`set${type}Pref`](name, value);
}

/**
 * Pops all prefs which have been pushed with pushPref.
 */
function popPrefs() {
  for (let {type, name, oldValue, prefBranch} of gRembemberedPrefs.reverse()) {
    if (oldValue === null) {
      prefBranch.clearUserPref(name);
    } else {
      prefBranch[`set${type}Pref`](name, oldValue);
    }
  }

  gRembemberedPrefs = [];
}

/**
 * Resets the preference with the provided name to the value before pushPref
 * was called.
 *
 * @param  name
 *         The name of the preference.
 */
function popPref(name) {
  let remembered = gRembemberedPrefs.reverse().find(p => p.name == name);
  if (remembered) {
    let {type, oldValue} = remembered;

    if (oldValue === null) {
      Services.prefs.clearUserPref(name);
    } else {
      Services.prefs[`set${type}Pref`](name, oldValue);
    }

    gRembemberedPrefs = gRembemberedPrefs.slice(gRembemberedPrefs.indexOf(remembered), 1);
  }
}

/**
 * Waits for the specified topic and (optionally) status.
 * @param  topic
 *         String representing the topic to wait for.
 * @param  status
 *         Optional String representing the status on said topic to wait for.
 * @return A promise which will resolve the first time an event occurs on the
 *         specified topic, and (optionally) with the specified status.
 */
function waitForEvent(topic, status = null) {
  return new Promise(resolve => Services.obs.addObserver({
    observe(subject, innerTopic, innerStatus) {
      if (!status || status == innerStatus) {
        Services.obs.removeObserver(this, topic);
        resolve(topic);
      }
    }
  }, topic, false))
}

/* Triggers post-update processing */
function testPostUpdateProcessing() {
  gAUS.observe(null, "test-post-update-processing", "");
}

/**
 * Waits for a window with the specified url to load.
 *
 * @param  url
 *         The url to wait for.
 * @return A promise which will resolve when a window with the specified url
 *         is loaded. If another url is loaded, this will result in an
 *         assertion failure.
 */
function waitForWindow(url) {
  return new Promise(resolve => {
    Services.wm.addListener({
      onOpenWindow(xulWindow) {
        info("window opened, waiting for focus");
        Services.wm.removeListener(this);

        var domwindow = xulWindow.QueryInterface(Ci.nsIInterfaceRequestor)
                                 .getInterface(Ci.nsIDOMWindow);
        waitForFocus(function() {
          is(domwindow.document.location.href, url, "should have seen the right window open");
          resolve(domwindow);
        }, domwindow);
      },
      onCloseWindow(xulWindow) { },
      onWindowTitleChange(xulWindow, newTitle) { }
    });
  })
}

/**
 * Ensures that the "What's new" link with the provided ID is displayed and
 * matches the url parameter provided. If no URL is provided, it will instead
 * ensure that the link matches the default link URL.
 *
 * @param  id
 *         The ID of the "What's new" link element.
 * @param  url (optional)
 *         The URL to check against. If none is provided, a default will be used.
 */
function checkWhatsNewLink(id, url) {
  let whatsNewLink = document.getElementById(id);
  is(whatsNewLink.href,
     url || URL_HTTP_UPDATE_SJS + "?uiURL=DETAILS",
     "What's new link points to the test_details URL");
  is(whatsNewLink.hidden, false, "What's new link is not hidden.");
}

/**
 * For tests that use the test updater restores the backed up real updater if
 * it exists and tries again on failure since Windows debug builds at times
 * leave the file in use. After success moveRealUpdater is called to continue
 * the setup of the test updater. For tests that don't use the test updater
 * runTest will be called.
 */
function setupTestUpdater() {
  return Task.spawn(function*() {
    if (gUseTestUpdater) {
      try {
        restoreUpdaterBackup();
      } catch (e) {
        logTestInfo("Attempt to restore the backed up updater failed... " +
                    "will try again, Exception: " + e);
        yield delay();
        yield setupTestUpdater();
        return;
      }
      yield moveRealUpdater();
    }
  });
}

/**
 * Backs up the real updater and tries again on failure since Windows debug
 * builds at times leave the file in use. After success it will call
 * copyTestUpdater to continue the setup of the test updater.
 */
function moveRealUpdater() {
  return Task.spawn(function*() {
    try {
      // Move away the real updater
      let baseAppDir = getAppBaseDir();
      let updater = baseAppDir.clone();
      updater.append(FILE_UPDATER_BIN);
      updater.moveTo(baseAppDir, FILE_UPDATER_BIN_BAK);
    } catch (e) {
      logTestInfo("Attempt to move the real updater out of the way failed... " +
                  "will try again, Exception: " + e);
      yield delay();
      yield moveRealUpdater();
      return;
    }

    yield copyTestUpdater();
  });
}

/**
 * Copies the test updater so it can be used by tests and tries again on failure
 * since Windows debug builds at times leave the file in use. After success it
 * will call runTest to continue the test.
 */
function copyTestUpdater() {
  return Task.spawn(function*() {
    try {
      // Copy the test updater
      let baseAppDir = getAppBaseDir();
      let testUpdaterDir = Services.dirsvc.get("CurWorkD", Ci.nsILocalFile);
      let relPath = REL_PATH_DATA;
      let pathParts = relPath.split("/");
      for (let i = 0; i < pathParts.length; ++i) {
        testUpdaterDir.append(pathParts[i]);
      }

      let testUpdater = testUpdaterDir.clone();
      testUpdater.append(FILE_UPDATER_BIN);
      testUpdater.copyToFollowingLinks(baseAppDir, FILE_UPDATER_BIN);
    } catch (e) {
      logTestInfo("Attempt to copy the test updater failed... " +
                  "will try again, Exception: " + e);
      yield delay();
      yield copyTestUpdater();
    }
  });
}

/**
 * Restores the updater that was backed up. This is called in setupTestUpdater
 * before the backup of the real updater is done in case the previous test
 * failed to restore the updater, in finishTestDefaultWaitForWindowClosed when
 * the test has finished, and in test_9999_cleanup.xul after all tests have
 * finished.
 */
function restoreUpdaterBackup() {
  let baseAppDir = getAppBaseDir();
  let updater = baseAppDir.clone();
  let updaterBackup = baseAppDir.clone();
  updater.append(FILE_UPDATER_BIN);
  updaterBackup.append(FILE_UPDATER_BIN_BAK);
  if (updaterBackup.exists()) {
    if (updater.exists()) {
      updater.remove(true);
    }
    updaterBackup.moveTo(baseAppDir, FILE_UPDATER_BIN);
  }
}

/**
 * When a test finishes this will repeatedly attempt to restore the real updater
 * for tests that use the test updater and then call
 * finishTestDefaultWaitForWindowClosed after the restore is successful.
 */
function finishTestRestoreUpdaterBackup() {
  return Task.spawn(function*() {
    if (gUseTestUpdater) {
      try {
        // Windows debug builds keep the updater file in use for a short period of
        // time after the updater process exits.
        restoreUpdaterBackup();
      } catch (e) {
        logTestInfo("Attempt to restore the backed up updater failed... " +
                    "will try again, Exception: " + e);

        yield delay();
        yield finishTestRestoreUpdaterBackup();
        return;
      }
    }
  });
}