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

"use strict";

let tempScope = {};
Cu.import("resource://gre/modules/devtools/Loader.jsm", tempScope);
Cu.import("resource://gre/modules/devtools/Console.jsm", tempScope);
const console = tempScope.console;
const devtools = tempScope.devtools;
tempScope = null;
const require = devtools.require;
const TargetFactory = devtools.TargetFactory;

const SPLIT_CONSOLE_PREF = "devtools.toolbox.splitconsoleEnabled";
const STORAGE_PREF = "devtools.storage.enabled";
const PATH = "browser/browser/devtools/storage/test/";
const MAIN_DOMAIN = "http://test1.example.org/" + PATH;
const ALT_DOMAIN = "http://sectest1.example.org/" + PATH;
const ALT_DOMAIN_SECURED = "https://sectest1.example.org:443/" + PATH;

let {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {});

waitForExplicitFinish();

let gToolbox, gPanelWindow, gWindow, gUI;

Services.prefs.setBoolPref(STORAGE_PREF, true);
gDevTools.testing = true;
registerCleanupFunction(() => {
  gToolbox = gPanelWindow = gWindow = gUI = null;
  Services.prefs.clearUserPref(STORAGE_PREF);
  Services.prefs.clearUserPref(SPLIT_CONSOLE_PREF);
  gDevTools.testing = false;
  while (gBrowser.tabs.length > 1) {
    gBrowser.removeCurrentTab();
  }
});

/**
 * Add a new test tab in the browser and load the given url.
 *
 * @param {String} url The url to be loaded in the new tab
 *
 * @return a promise that resolves to the content window when the url is loaded
 */
function addTab(url) {
  info("Adding a new tab with URL: '" + url + "'");
  let def = promise.defer();

  // Bug 921935 should bring waitForFocus() support to e10s, which would
  // probably cover the case of the test losing focus when the page is loading.
  // For now, we just make sure the window is focused.
  window.focus();

  let tab = window.gBrowser.selectedTab = window.gBrowser.addTab(url);
  let linkedBrowser = tab.linkedBrowser;

  linkedBrowser.addEventListener("load", function onload(event) {
    if (event.originalTarget.location.href != url) {
      return;
    }
    linkedBrowser.removeEventListener("load", onload, true);
    info("URL '" + url + "' loading complete");
    def.resolve(tab.linkedBrowser.contentWindow);
  }, true);

  return def.promise;
}

/**
 * Opens the given url in a new tab, then sets up the page by waiting for
 * all cookies, indexedDB items etc. to be created; Then opens the storage
 * inspector and waits for the storage tree and table to be populated
 *
 * @param url {String} The url to be opened in the new tab
 *
 * @return {Promise} A promise that resolves after storage inspector is ready
 */
let openTabAndSetupStorage = Task.async(function*(url) {
  /**
   * This method iterates over iframes in a window and setups the indexed db
   * required for this test.
   */
  let setupIDBInFrames = (w, i, c) => {
    if (w[i] && w[i].idbGenerator) {
      w[i].setupIDB = w[i].idbGenerator(() => setupIDBInFrames(w, i + 1, c));
      w[i].setupIDB.next();
    }
    else if (w[i] && w[i + 1]) {
      setupIDBInFrames(w, i + 1, c);
    }
    else {
      c();
    }
  };

  let content = yield addTab(url);

  let def = promise.defer();
  // Setup the indexed db in main window.
  gWindow = content.wrappedJSObject;
  if (gWindow.idbGenerator) {
    gWindow.setupIDB = gWindow.idbGenerator(() => {
      setupIDBInFrames(gWindow, 0, () => {
        def.resolve();
      });
    });
    gWindow.setupIDB.next();
    yield def.promise;
  }

  // open storage inspector
  return yield openStoragePanel();
});

/**
 * Open the toolbox, with the storage tool visible.
 *
 * @param cb {Function} Optional callback, if you don't want to use the returned
 *                      promise
 *
 * @return {Promise} a promise that resolves when the storage inspector is ready
 */
let openStoragePanel = Task.async(function*(cb) {
  info("Opening the storage inspector");
  let target = TargetFactory.forTab(gBrowser.selectedTab);

  let storage, toolbox;

  // Checking if the toolbox and the storage are already loaded
  // The storage-updated event should only be waited for if the storage
  // isn't loaded yet
  toolbox = gDevTools.getToolbox(target);
  if (toolbox) {
    storage = toolbox.getPanel("storage");
    if (storage) {
      gPanelWindow = storage.panelWindow;
      gUI = storage.UI;
      gToolbox = toolbox;
      info("Toolbox and storage already open");
      if (cb) {
        return cb(storage, toolbox);
      } else {
        return {
          toolbox: toolbox,
          storage: storage
        };
      }
    }
  }

  info("Opening the toolbox");
  toolbox = yield gDevTools.showToolbox(target, "storage");
  storage = toolbox.getPanel("storage");
  gPanelWindow = storage.panelWindow;
  gUI = storage.UI;
  gToolbox = toolbox;

  info("Waiting for the stores to update");
  yield gUI.once("store-objects-updated");

  yield waitForToolboxFrameFocus(toolbox);

  if (cb) {
    return cb(storage, toolbox);
  } else {
    return {
      toolbox: toolbox,
      storage: storage
    };
  }
});

/**
 * Wait for the toolbox frame to receive focus after it loads
 *
 * @param toolbox {Toolbox}
 *
 * @return a promise that resolves when focus has been received
 */
function waitForToolboxFrameFocus(toolbox) {
  info("Making sure that the toolbox's frame is focused");
  let def = promise.defer();
  let win = toolbox.frame.contentWindow;
  waitForFocus(def.resolve, win);
  return def.promise;
}

/**
 * Forces GC, CC and Shrinking GC to get rid of disconnected docshells and
 * windows.
 */
function forceCollections() {
  Cu.forceGC();
  Cu.forceCC();
  Cu.forceShrinkingGC();
}

/**
 * Cleans up and finishes the test
 */
function finishTests() {
  // Cleanup so that indexed db created from this test do not interfere next ones

  /**
   * This method iterates over iframes in a window and clears the indexed db
   * created by this test.
   */
  let clearIDB = (w, i, c) => {
    if (w[i] && w[i].clear) {
      w[i].clearIterator = w[i].clear(() => clearIDB(w, i + 1, c));
      w[i].clearIterator.next();
    }
    else if (w[i] && w[i + 1]) {
      clearIDB(w, i + 1, c);
    }
    else {
      c();
    }
  };

  gWindow.clearIterator = gWindow.clear(() => {
    clearIDB(gWindow, 0, () => {
      // Forcing GC/CC to get rid of docshells and windows created by this test.
      forceCollections();
      finish();
    });
  });
  gWindow.clearIterator.next();
}

// Sends a click event on the passed DOM node in an async manner
function click(node) {
  node.scrollIntoView();
  executeSoon(() => EventUtils.synthesizeMouseAtCenter(node, {}, gPanelWindow));
}



/**
 * Recursively expand the variables view up to a given property.
 *
 * @param aOptions
 *        Options for view expansion:
 *        - rootVariable: start from the given scope/variable/property.
 *        - expandTo: string made up of property names you want to expand.
 *        For example: "body.firstChild.nextSibling" given |rootVariable:
 *        document|.
 * @return object
 *         A promise that is resolved only when the last property in |expandTo|
 *         is found, and rejected otherwise. Resolution reason is always the
 *         last property - |nextSibling| in the example above. Rejection is
 *         always the last property that was found.
 */
function variablesViewExpandTo(aOptions) {
  let root = aOptions.rootVariable;
  let expandTo = aOptions.expandTo.split(".");
  let lastDeferred = promise.defer();

  function getNext(aProp) {
    let name = expandTo.shift();
    let newProp = aProp.get(name);

    if (expandTo.length > 0) {
      ok(newProp, "found property " + name);
      if (newProp && newProp.expand) {
        newProp.expand();
        getNext(newProp);
      }
      else {
        lastDeferred.reject(aProp);
      }
    }
    else {
      if (newProp) {
        lastDeferred.resolve(newProp);
      }
      else {
        lastDeferred.reject(aProp);
      }
    }
  }

  function fetchError(aProp) {
    lastDeferred.reject(aProp);
  }

  if (root && root.expand) {
    root.expand();
    getNext(root);
  }
  else {
    lastDeferred.resolve(root)
  }

  return lastDeferred.promise;
}

/**
 * Find variables or properties in a VariablesView instance.
 *
 * @param array aRules
 *        The array of rules you want to match. Each rule is an object with:
 *        - name (string|regexp): property name to match.
 *        - value (string|regexp): property value to match.
 *        - dontMatch (boolean): make sure the rule doesn't match any property.
 * @param boolean aParsed
 *        true if we want to test the rules in the parse value section of the
 *        storage sidebar
 * @return object
 *         A promise object that is resolved when all the rules complete
 *         matching. The resolved callback is given an array of all the rules
 *         you wanted to check. Each rule has a new property: |matchedProp|
 *         which holds a reference to the Property object instance from the
 *         VariablesView. If the rule did not match, then |matchedProp| is
 *         undefined.
 */
function findVariableViewProperties(aRules, aParsed) {
  // Initialize the search.
  function init() {
    // If aParsed is true, we are checking rules in the parsed value section of
    // the storage sidebar. That scope uses a blank variable as a placeholder
    // Thus, adding a blank parent to each name
    if (aParsed) {
      aRules = aRules.map(({name, value, dontMatch}) => {
        return {name: "." + name, value, dontMatch}
      });
    }
    // Separate out the rules that require expanding properties throughout the
    // view.
    let expandRules = [];
    let rules = aRules.filter((aRule) => {
      if (typeof aRule.name == "string" && aRule.name.indexOf(".") > -1) {
        expandRules.push(aRule);
        return false;
      }
      return true;
    });

    // Search through the view those rules that do not require any properties to
    // be expanded. Build the array of matchers, outstanding promises to be
    // resolved.
    let outstanding = [];

    finder(rules, gUI.view, outstanding);

    // Process the rules that need to expand properties.
    let lastStep = processExpandRules.bind(null, expandRules);

    // Return the results - a promise resolved to hold the updated aRules array.
    let returnResults = onAllRulesMatched.bind(null, aRules);

    return promise.all(outstanding).then(lastStep).then(returnResults);
  }

  function onMatch(aProp, aRule, aMatched) {
    if (aMatched && !aRule.matchedProp) {
      aRule.matchedProp = aProp;
    }
  }

  function finder(aRules, aView, aPromises) {
    for (let scope of aView) {
      for (let [id, prop] of scope) {
        for (let rule of aRules) {
          let matcher = matchVariablesViewProperty(prop, rule);
          aPromises.push(matcher.then(onMatch.bind(null, prop, rule)));
        }
      }
    }
  }

  function processExpandRules(aRules) {
    let rule = aRules.shift();
    if (!rule) {
      return promise.resolve(null);
    }

    let deferred = promise.defer();
    let expandOptions = {
      rootVariable: gUI.view.getScopeAtIndex(aParsed ? 1: 0),
      expandTo: rule.name
    };

    variablesViewExpandTo(expandOptions).then(function onSuccess(aProp) {
      let name = rule.name;
      let lastName = name.split(".").pop();
      rule.name = lastName;

      let matched = matchVariablesViewProperty(aProp, rule);
      return matched.then(onMatch.bind(null, aProp, rule)).then(function() {
        rule.name = name;
      });
    }, function onFailure() {
      return promise.resolve(null);
    }).then(processExpandRules.bind(null, aRules)).then(function() {
      deferred.resolve(null);
    });

    return deferred.promise;
  }

  function onAllRulesMatched(aRules) {
    for (let rule of aRules) {
      let matched = rule.matchedProp;
      if (matched && !rule.dontMatch) {
        ok(true, "rule " + rule.name + " matched for property " + matched.name);
      }
      else if (matched && rule.dontMatch) {
        ok(false, "rule " + rule.name + " should not match property " +
           matched.name);
      }
      else {
        ok(rule.dontMatch, "rule " + rule.name + " did not match any property");
      }
    }
    return aRules;
  }

  return init();
}

/**
 * Check if a given Property object from the variables view matches the given
 * rule.
 *
 * @param object aProp
 *        The variable's view Property instance.
 * @param object aRule
 *        Rules for matching the property. See findVariableViewProperties() for
 *        details.
 * @return object
 *         A promise that is resolved when all the checks complete. Resolution
 *         result is a boolean that tells your promise callback the match
 *         result: true or false.
 */
function matchVariablesViewProperty(aProp, aRule) {
  function resolve(aResult) {
    return promise.resolve(aResult);
  }

  if (!aProp) {
    return resolve(false);
  }

  if (aRule.name) {
    let match = aRule.name instanceof RegExp ?
                aRule.name.test(aProp.name) :
                aProp.name == aRule.name;
    if (!match) {
      return resolve(false);
    }
  }

  if ("value" in aRule) {
    let displayValue = aProp.displayValue;
    if (aProp.displayValueClassName == "token-string") {
      displayValue = displayValue.substring(1, displayValue.length - 1);
    }

    let match = aRule.value instanceof RegExp ?
                aRule.value.test(displayValue) :
                displayValue == aRule.value;
    if (!match) {
      info("rule " + aRule.name + " did not match value, expected '" +
           aRule.value + "', found '" + displayValue  + "'");
      return resolve(false);
    }
  }

  return resolve(true);
}

/**
 * Click selects a row in the table.
 *
 * @param {[String]} ids
 *        The array id of the item in the tree
 */
function selectTreeItem(ids) {
  // Expand tree as some/all items could be collapsed leading to click on an
  // incorrect tree item
  gUI.tree.expandAll();
  let target = gPanelWindow.document.querySelector(
                 "[data-id='" + JSON.stringify(ids) + "'] > .tree-widget-item");

  // Look for an animating list
  //
  // If the list is still expanding we won't be able to click the requested
  // item. We detect if a list (or one of its ancestor lists) is animating
  // by checking if max-height is set. If the animation effect in
  // widgets.inc.css changes this will likely break but until the Web
  // Animations API is available (specifically, bug 1074630 and bug 1112422)
  // there is no reliable way to detect if animations are running, particularly
  // in the face of interactions that can cancel an animation without triggering
  // an animationend event.
  let animatingList = target.nextElementSibling;
  while (animatingList) {
    if (window.getComputedStyle(animatingList).maxHeight != "none") {
      break;
    }
    animatingList = animatingList.parentNode.closest(".tree-widget-item + ul");
  }

  if (animatingList) {
    animatingList.addEventListener("animationend", function animationend() {
      animatingList.removeEventListener("animationend", animationend);
      click(target);
    });
  } else {
    click(target);
  }
}

/**
 * Click selects a row in the table.
 *
 * @param {String} id
 *        The id of the row in the table widget
 */
function selectTableItem(id) {
  click(gPanelWindow.document.querySelector(".table-widget-cell[data-id='" +
        id + "']"));
}
