/* 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/. */
/* eslint no-unused-vars: [2, {"vars": "local"}] */

"use strict";

// Import the inspector's head.js first (which itself imports shared-head.js).
Services.scriptloader.loadSubScript(
  "chrome://mochitests/content/browser/devtools/client/inspector/test/head.js",
  this
);

var {
  getInplaceEditorForSpan: inplaceEditor,
} = require("resource://devtools/client/shared/inplace-editor.js");
var clipboard = require("resource://devtools/shared/platform/clipboard.js");

// If a test times out we want to see the complete log and not just the last few
// lines.
SimpleTest.requestCompleteLog();

// Toggle this pref on to see all DevTools event communication. This is hugely
// useful for fixing race conditions.
// Services.prefs.setBoolPref("devtools.dump.emit", true);

/**
 * Some tests may need to import one or more of the test helper scripts.
 * A test helper script is simply a js file that contains common test code that
 * is either not common-enough to be in head.js, or that is located in a
 * separate directory.
 * The script will be loaded synchronously and in the test's scope.
 * @param {String} filePath The file path, relative to the current directory.
 *                 Examples:
 *                 - "helper_attributes_test_runner.js"
 */
function loadHelperScript(filePath) {
  const testDir = gTestPath.substr(0, gTestPath.lastIndexOf("/"));
  Services.scriptloader.loadSubScript(testDir + "/" + filePath, this);
}

/**
 * Get the MarkupContainer object instance that corresponds to the given
 * NodeFront
 * @param {NodeFront} nodeFront
 * @param {InspectorPanel} inspector The instance of InspectorPanel currently
 * loaded in the toolbox
 * @return {MarkupContainer}
 */
function getContainerForNodeFront(nodeFront, { markup }) {
  return markup.getContainer(nodeFront);
}

/**
 * Get the MarkupContainer object instance that corresponds to the given
 * selector
 * @param {String|NodeFront} selector
 * @param {InspectorPanel} inspector The instance of InspectorPanel currently
 * loaded in the toolbox
 * @param {Boolean} Set to true in the event that the node shouldn't be found.
 * @return {MarkupContainer}
 */
var getContainerForSelector = async function (
  selector,
  inspector,
  expectFailure = false
) {
  info("Getting the markup-container for node " + selector);
  const nodeFront = await getNodeFront(selector, inspector);
  const container = getContainerForNodeFront(nodeFront, inspector);

  if (expectFailure) {
    ok(!container, "Shouldn't find markup-container for selector: " + selector);
  } else {
    ok(container, "Found markup-container for selector: " + selector);
  }

  return container;
};

/**
 * Retrieve the nodeValue for the firstChild of a provided selector on the content page.
 *
 * @param {String} selector
 * @return {String} the nodeValue of the first
 */
function getFirstChildNodeValue(selector) {
  return SpecialPowers.spawn(
    gBrowser.selectedBrowser,
    [selector],
    _selector => {
      return content.document.querySelector(_selector).firstChild.nodeValue;
    }
  );
}

/**
 * Using the markupview's _waitForChildren function, wait for all queued
 * children updates to be handled.
 * @param {InspectorPanel} inspector The instance of InspectorPanel currently
 * loaded in the toolbox
 * @return a promise that resolves when all queued children updates have been
 * handled
 */
function waitForChildrenUpdated({ markup }) {
  info("Waiting for queued children updates to be handled");
  return new Promise(resolve => {
    markup._waitForChildren().then(() => {
      executeSoon(resolve);
    });
  });
}

/**
 * Simulate a click on the markup-container (a line in the markup-view)
 * that corresponds to the selector passed.
 * @param {String|NodeFront} selector
 * @param {InspectorPanel} inspector The instance of InspectorPanel currently
 * loaded in the toolbox
 * @return {Promise} Resolves when the node has been selected.
 */
var clickContainer = async function (selector, inspector) {
  info("Clicking on the markup-container for node " + selector);

  const nodeFront = await getNodeFront(selector, inspector);
  const container = getContainerForNodeFront(nodeFront, inspector);

  const updated = container.selected
    ? Promise.resolve()
    : inspector.once("inspector-updated");
  EventUtils.synthesizeMouseAtCenter(
    container.tagLine,
    { type: "mousedown" },
    inspector.markup.doc.defaultView
  );
  EventUtils.synthesizeMouseAtCenter(
    container.tagLine,
    { type: "mouseup" },
    inspector.markup.doc.defaultView
  );
  return updated;
};

/**
 * Focus a given editable element, enter edit mode, set value, and commit
 * @param {DOMNode} field The element that gets editable after receiving focus
 * and <ENTER> keypress
 * @param {String} value The string value to be set into the edited field
 * @param {InspectorPanel} inspector The instance of InspectorPanel currently
 * loaded in the toolbox
 */
function setEditableFieldValue(field, value, inspector) {
  field.focus();
  EventUtils.sendKey("return", inspector.panelWin);
  const input = inplaceEditor(field).input;
  ok(input, "Found editable field for setting value: " + value);
  input.value = value;
  EventUtils.sendKey("return", inspector.panelWin);
}

/**
 * Focus the new-attribute inplace-editor field of a node's markup container
 * and enters the given text, then wait for it to be applied and the for the
 * node to mutates (when new attribute(s) is(are) created)
 * @param {String} selector The selector for the node to edit.
 * @param {String} text The new attribute text to be entered (e.g. "id='test'")
 * @param {InspectorPanel} inspector The instance of InspectorPanel currently
 * loaded in the toolbox
 * @return a promise that resolves when the node has mutated
 */
var addNewAttributes = async function (selector, text, inspector) {
  info(`Entering text "${text}" in new attribute field for node ${selector}`);

  const container = await focusNode(selector, inspector);
  ok(container, "The container for '" + selector + "' was found");

  info("Listening for the markupmutation event");
  const nodeMutated = inspector.once("markupmutation");
  setEditableFieldValue(container.editor.newAttr, text, inspector);
  await nodeMutated;
};

/**
 * Checks that a node has the given attributes.
 *
 * @param {String} selector The selector for the node to check.
 * @param {Object} expected An object containing the attributes to check.
 *        e.g. {id: "id1", class: "someclass"}
 *
 * Note that node.getAttribute() returns attribute values provided by the HTML
 * parser. The parser only provides unescaped entities so &amp; will return &.
 */
var assertAttributes = async function (selector, expected) {
  const actualAttributes = await getContentPageElementAttributes(selector);
  is(
    actualAttributes.length,
    Object.keys(expected).length,
    "The node " + selector + " has the expected number of attributes."
  );
  for (const attr in expected) {
    const foundAttr = actualAttributes.find(({ name }) => name === attr);
    const foundValue = foundAttr ? foundAttr.value : undefined;
    ok(foundAttr, "The node " + selector + " has the attribute " + attr);
    is(
      foundValue,
      expected[attr],
      "The node " + selector + " has the correct " + attr + " attribute value"
    );
  }
};

/**
 * Undo the last markup-view action and wait for the corresponding mutation to
 * occur
 * @param {InspectorPanel} inspector The instance of InspectorPanel currently
 * loaded in the toolbox
 * @return a promise that resolves when the markup-mutation has been treated or
 * rejects if no undo action is possible
 */
function undoChange(inspector) {
  const canUndo = inspector.markup.undo.canUndo();
  ok(canUndo, "The last change in the markup-view can be undone");
  if (!canUndo) {
    return Promise.reject();
  }

  const mutated = inspector.once("markupmutation");
  inspector.markup.undo.undo();
  return mutated;
}

/**
 * Redo the last markup-view action and wait for the corresponding mutation to
 * occur
 * @param {InspectorPanel} inspector The instance of InspectorPanel currently
 * loaded in the toolbox
 * @return a promise that resolves when the markup-mutation has been treated or
 * rejects if no redo action is possible
 */
function redoChange(inspector) {
  const canRedo = inspector.markup.undo.canRedo();
  ok(canRedo, "The last change in the markup-view can be redone");
  if (!canRedo) {
    return Promise.reject();
  }

  const mutated = inspector.once("markupmutation");
  inspector.markup.undo.redo();
  return mutated;
}

/**
 * Check to see if the inspector menu items for editing are disabled.
 * Things like Edit As HTML, Delete Node, etc.
 * @param {NodeFront} nodeFront
 * @param {InspectorPanel} inspector
 * @param {Boolean} assert Should this function run assertions inline.
 * @return A promise that resolves with a boolean indicating whether
 *         the menu items are disabled once the menu has been checked.
 */
var isEditingMenuDisabled = async function (
  nodeFront,
  inspector,
  assert = true
) {
  // To ensure clipboard contains something to paste.
  clipboard.copyString("<p>test</p>");

  await selectNode(nodeFront, inspector);
  const allMenuItems = openContextMenuAndGetAllItems(inspector);

  const deleteMenuItem = allMenuItems.find(i => i.id === "node-menu-delete");
  const editHTMLMenuItem = allMenuItems.find(
    i => i.id === "node-menu-edithtml"
  );
  const pasteHTMLMenuItem = allMenuItems.find(
    i => i.id === "node-menu-pasteouterhtml"
  );

  if (assert) {
    ok(deleteMenuItem.disabled, "Delete menu item is disabled");
    ok(editHTMLMenuItem.disabled, "Edit HTML menu item is disabled");
    ok(pasteHTMLMenuItem.disabled, "Paste HTML menu item is disabled");
  }

  return (
    deleteMenuItem.disabled &&
    editHTMLMenuItem.disabled &&
    pasteHTMLMenuItem.disabled
  );
};

/**
 * Check to see if the inspector menu items for editing are enabled.
 * Things like Edit As HTML, Delete Node, etc.
 * @param {NodeFront} nodeFront
 * @param {InspectorPanel} inspector
 * @param {Boolean} assert Should this function run assertions inline.
 * @return A promise that resolves with a boolean indicating whether
 *         the menu items are enabled once the menu has been checked.
 */
var isEditingMenuEnabled = async function (
  nodeFront,
  inspector,
  assert = true
) {
  // To ensure clipboard contains something to paste.
  clipboard.copyString("<p>test</p>");

  await selectNode(nodeFront, inspector);
  const allMenuItems = openContextMenuAndGetAllItems(inspector);

  const deleteMenuItem = allMenuItems.find(i => i.id === "node-menu-delete");
  const editHTMLMenuItem = allMenuItems.find(
    i => i.id === "node-menu-edithtml"
  );
  const pasteHTMLMenuItem = allMenuItems.find(
    i => i.id === "node-menu-pasteouterhtml"
  );

  if (assert) {
    ok(!deleteMenuItem.disabled, "Delete menu item is enabled");
    ok(!editHTMLMenuItem.disabled, "Edit HTML menu item is enabled");
    ok(!pasteHTMLMenuItem.disabled, "Paste HTML menu item is enabled");
  }

  return (
    !deleteMenuItem.disabled &&
    !editHTMLMenuItem.disabled &&
    !pasteHTMLMenuItem.disabled
  );
};

/**
 * Wait for all current promises to be resolved. See this as executeSoon that
 * can be used with yield.
 */
function promiseNextTick() {
  return new Promise(resolve => {
    executeSoon(resolve);
  });
}

/**
 * `await` with timeout.
 *
 * Usage:
 *   const badgeEventAdded = inspector.markup.once("badge-added-event");
 *   ...
 *   const result = await awaitWithTimeout(badgeEventAdded, 3000);
 *   is(result, "timeout", "Ensure that no event badges were added");
 *
 * @param  {Promise} promise
 *         Promise to resolve
 * @param  {Number} ms
 *         Milliseconds to wait.
 * @return "timeout" on timeout, otherwise the result of the fulfilled promise.
 */
async function awaitWithTimeout(promise, ms) {
  const timeout = new Promise(resolve => {
    // eslint-disable-next-line
    const wait = setTimeout(() => {
      clearTimeout(wait);
      resolve("timeout");
    }, ms);
  });

  return Promise.race([promise, timeout]);
}

/**
 * Collapses the current text selection in an input field and tabs to the next
 * field.
 */
function collapseSelectionAndTab(inspector) {
  // collapse selection and move caret to end
  EventUtils.sendKey("tab", inspector.panelWin);
  // next element
  EventUtils.sendKey("tab", inspector.panelWin);
}

/**
 * Collapses the current text selection in an input field and tabs to the
 * previous field.
 */
function collapseSelectionAndShiftTab(inspector) {
  // collapse selection and move caret to end
  EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, inspector.panelWin);
  // previous element
  EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, inspector.panelWin);
}

/**
 * Check that the current focused element is an attribute element in the markup
 * view.
 * @param {String} attrName The attribute name expected to be found
 * @param {Boolean} editMode Whether or not the attribute should be in edit mode
 */
function checkFocusedAttribute(attrName, editMode) {
  const focusedAttr = Services.focus.focusedElement;
  ok(focusedAttr, "Has a focused element");

  const dataAttr = focusedAttr.parentNode.dataset.attr;
  is(dataAttr, attrName, attrName + " attribute editor is currently focused.");
  if (editMode) {
    // Using a multiline editor for attributes, the focused element should be a textarea.
    is(focusedAttr.tagName, "textarea", attrName + "is in edit mode");
  } else {
    is(focusedAttr.tagName, "span", attrName + "is not in edit mode");
  }
}

/**
 * Get attributes for node as how they are represented in editor.
 *
 * @param  {String} selector
 * @param  {InspectorPanel} inspector
 * @return {Promise}
 *         A promise that resolves with an array of attribute names
 *         (e.g. ["id", "class", "href"])
 */
var getAttributesFromEditor = async function (selector, inspector) {
  const nodeList = (
    await getContainerForSelector(selector, inspector)
  ).tagLine.querySelectorAll("[data-attr]");

  return [...nodeList].map(node => node.getAttribute("data-attr"));
};

/**
 * Simulate dragging a MarkupContainer by calling its mousedown and mousemove
 * handlers.
 * @param {InspectorPanel} inspector The current inspector-panel instance.
 * @param {String|MarkupContainer} selector The selector to identify the node or
 * the MarkupContainer for this node.
 * @param {Number} xOffset Optional x offset to drag by.
 * @param {Number} yOffset Optional y offset to drag by.
 */
async function simulateNodeDrag(
  inspector,
  selector,
  xOffset = 10,
  yOffset = 10
) {
  const container =
    typeof selector === "string"
      ? await getContainerForSelector(selector, inspector)
      : selector;
  container.elt.scrollIntoView(true);
  const rect = container.tagLine.getBoundingClientRect();
  const scrollX = inspector.markup.doc.documentElement.scrollLeft;
  const scrollY = inspector.markup.doc.documentElement.scrollTop;

  info("Simulate mouseDown on element " + selector);
  container._onMouseDown({
    target: container.tagLine,
    button: 0,
    pageX: scrollX + rect.x,
    pageY: scrollY + rect.y,
    stopPropagation: () => {},
    preventDefault: () => {},
  });

  // _onMouseDown selects the node, so make sure to wait for the
  // inspector-updated event if the current selection was different.
  if (inspector.selection.nodeFront !== container.node) {
    await inspector.once("inspector-updated");
  }

  info("Simulate mouseMove on element " + selector);
  container.onMouseMove({
    pageX: scrollX + rect.x + xOffset,
    pageY: scrollY + rect.y + yOffset,
  });
}

/**
 * Simulate dropping a MarkupContainer by calling its mouseup handler. This is
 * meant to be called after simulateNodeDrag has been called.
 * @param {InspectorPanel} inspector The current inspector-panel instance.
 * @param {String|MarkupContainer} selector The selector to identify the node or
 * the MarkupContainer for this node.
 */
async function simulateNodeDrop(inspector, selector) {
  info("Simulate mouseUp on element " + selector);
  const container =
    typeof selector === "string"
      ? await getContainerForSelector(selector, inspector)
      : selector;
  container.onMouseUp();
  inspector.markup._onMouseUp();
}

/**
 * Simulate drag'n'dropping a MarkupContainer by calling its mousedown,
 * mousemove and mouseup handlers.
 * @param {InspectorPanel} inspector The current inspector-panel instance.
 * @param {String|MarkupContainer} selector The selector to identify the node or
 * the MarkupContainer for this node.
 * @param {Number} xOffset Optional x offset to drag by.
 * @param {Number} yOffset Optional y offset to drag by.
 */
async function simulateNodeDragAndDrop(inspector, selector, xOffset, yOffset) {
  await simulateNodeDrag(inspector, selector, xOffset, yOffset);
  await simulateNodeDrop(inspector, selector);
}

/**
 * Waits until the element has not scrolled for 30 consecutive frames.
 */
async function waitForScrollStop(doc) {
  const el = doc.documentElement;
  const win = doc.defaultView;
  let lastScrollTop = el.scrollTop;
  let stopFrameCount = 0;
  while (stopFrameCount < 30) {
    // Wait for a frame.
    await new Promise(resolve => win.requestAnimationFrame(resolve));

    // Check if the element has scrolled.
    if (lastScrollTop == el.scrollTop) {
      // No scrolling since the last frame.
      stopFrameCount++;
    } else {
      // The element has scrolled. Reset the frame counter.
      stopFrameCount = 0;
      lastScrollTop = el.scrollTop;
    }
  }

  return lastScrollTop;
}

/**
 * Select a node in the inspector and try to delete it using the provided key. After that,
 * check that the expected element is focused.
 *
 * @param {InspectorPanel} inspector
 *        The current inspector-panel instance.
 * @param {String} key
 *        The key to simulate to delete the node
 * @param {Object}
 *        - {String} selector: selector of the element to delete.
 *        - {String} focusedSelector: selector of the element that should be selected
 *        after deleting the node.
 *        - {String} pseudo: optional, "before" or "after" if the element focused after
 *        deleting the node is supposed to be a before/after pseudo-element.
 */
async function checkDeleteAndSelection(
  inspector,
  key,
  { selector, focusedSelector, pseudo }
) {
  info(
    "Test deleting node " +
      selector +
      " with " +
      key +
      ", " +
      "expecting " +
      focusedSelector +
      " to be focused"
  );

  info("Select node " + selector + " and make sure it is focused");
  await selectNode(selector, inspector);
  await clickContainer(selector, inspector);

  info("Delete the node with: " + key);
  const mutated = inspector.once("markupmutation");
  EventUtils.sendKey(key, inspector.panelWin);
  await Promise.all([mutated, inspector.once("inspector-updated")]);

  let nodeFront = await getNodeFront(focusedSelector, inspector);
  if (pseudo) {
    // Update the selector for logging in case of failure.
    focusedSelector = focusedSelector + "::" + pseudo;
    // Retrieve the :before or :after pseudo element of the nodeFront.
    const { nodes } = await inspector.walker.children(nodeFront);
    nodeFront = pseudo === "before" ? nodes[0] : nodes[nodes.length - 1];
  }

  is(
    inspector.selection.nodeFront,
    nodeFront,
    focusedSelector + " is selected after deletion"
  );

  info("Check that the node was really removed");
  let node = await getNodeFront(selector, inspector);
  ok(!node, "The node can't be found in the page anymore");

  info("Undo the deletion to restore the original markup");
  await undoChange(inspector);
  node = await getNodeFront(selector, inspector);
  ok(node, "The node is back");
}

/**
 * Click on the reveal link the provided slotted container.
 * Will resolve when selection emits "new-node-front".
 */
async function clickOnRevealLink(inspector, container) {
  const onSelection = inspector.selection.once("new-node-front");
  const revealLink = container.elt.querySelector(".reveal-link");
  const tagline = revealLink.closest(".tag-line");
  const win = inspector.markup.doc.defaultView;

  // First send a mouseover on the tagline to force the link to be displayed.
  EventUtils.synthesizeMouseAtCenter(tagline, { type: "mouseover" }, win);
  EventUtils.synthesizeMouseAtCenter(revealLink, {}, win);

  await onSelection;
}

/**
 * Hit `key` on the reveal link in the provided slotted container.
 * Will resolve when selection emits "new-node-front".
 */
async function keydownOnRevealLink(key, inspector, container) {
  const revealLink = container.elt.querySelector(".reveal-link");
  const win = inspector.markup.doc.defaultView;

  const root = inspector.markup.getContainer(inspector.markup._rootNode);
  root.elt.focus();

  // we need to go through a ENTER + TAB  key sequence to focus on
  // the .reveal-link element with the keyboard
  const revealFocused = once(revealLink, "focus");
  EventUtils.synthesizeKey("KEY_Enter", {}, win);
  EventUtils.synthesizeKey("KEY_Tab", {}, win);
  info("Waiting for .reveal-link to be focused");
  await revealFocused;

  // hit `key` on the .reveal-link
  const onSelection = inspector.selection.once("new-node-front");
  EventUtils.synthesizeKey(key, {}, win);
  await onSelection;
}
