/* 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";

/**
 * Wait for a single requestAnimationFrame tick.
 */
function tick() {
  return new Promise(resolve => requestAnimationFrame(resolve));
}

/**
 * It can be confusing when waiting for something asynchronously. This function
 * logs out a message periodically (every 1 second) in order to create helpful
 * log messages.
 * @param {string} message
 * @returns {Function}
 */
function createPeriodicLogger() {
  let startTime = Date.now();
  let lastCount = 0;
  let lastMessage = null;

  return message => {
    if (lastMessage === message) {
      // The messages are the same, check if we should log them.
      const now = Date.now();
      const count = Math.floor((now - startTime) / 1000);
      if (count !== lastCount) {
        info(
          `${message} (After ${count} ${count === 1 ? "second" : "seconds"})`
        );
        lastCount = count;
      }
    } else {
      // The messages are different, log them now, and reset the waiting time.
      info(message);
      startTime = Date.now();
      lastCount = 0;
      lastMessage = message;
    }
  };
}

/**
 * Wait until a condition is fullfilled.
 * @param {Function} condition
 * @param {string?} logMessage
 * @return The truthy result of the condition.
 */
async function waitUntil(condition, message) {
  const logPeriodically = createPeriodicLogger();

  // Loop through the condition.
  while (true) {
    if (message) {
      logPeriodically(message);
    }
    const result = condition();
    if (result) {
      return result;
    }

    await tick();
  }
}

/**
 * This function looks inside of a container for some element that has a label.
 * It runs in a loop every requestAnimationFrame until it finds the element. If
 * it doesn't find the element it throws an error.
 *
 * @param {Element} container
 * @param {string} label
 * @returns {Promise<HTMLElement>}
 */
function getElementByLabel(container, label) {
  return waitUntil(
    () => container.querySelector(`[label="${label}"]`),
    `Trying to find the button with the label "${label}".`
  );
}
/* exported getElementByLabel */

/**
 * This function looks inside of a container for some element that has a tooltip.
 * It runs in a loop every requestAnimationFrame until it finds the element. If
 * it doesn't find the element it throws an error.
 *
 * @param {Element} container
 * @param {string} tooltip
 * @returns {Promise<HTMLElement>}
 */
function getElementByTooltip(container, tooltip) {
  return waitUntil(
    () => container.querySelector(`[tooltiptext="${tooltip}"]`),
    `Trying to find the button with the tooltip "${tooltip}".`
  );
}
/* exported getElementByTooltip */

/**
 * This function will select a node from the XPath.
 * @returns {HTMLElement?}
 */
function getElementByXPath(document, path) {
  return document.evaluate(
    path,
    document,
    null,
    XPathResult.FIRST_ORDERED_NODE_TYPE,
    null
  ).singleNodeValue;
}
/* exported getElementByXPath */

/**
 * This function looks inside of a document for some element that contains
 * the given text. It runs in a loop every requestAnimationFrame until it
 * finds the element. If it doesn't find the element it throws an error.
 *
 * @param {HTMLDocument} document
 * @param {string} text
 * @returns {Promise<HTMLElement>}
 */
async function getElementFromDocumentByText(document, text) {
  // Fallback on aria-label if there are no results for the text xpath.
  const xpath = `//*[contains(text(), '${text}')] | //*[contains(@aria-label, '${text}')]`;
  return waitUntil(
    () => getElementByXPath(document, xpath),
    `Trying to find the element with the text "${text}".`
  );
}
/* exported getElementFromDocumentByText */

/**
 * This function is similar to getElementFromDocumentByText, but it immediately
 * returns and does not wait for an element to exist.
 * @param {HTMLDocument} document
 * @param {string} text
 * @returns {HTMLElement?}
 */
function maybeGetElementFromDocumentByText(document, text) {
  info(`Immediately trying to find the element with the text "${text}".`);
  const xpath = `//*[contains(text(), '${text}')]`;
  return getElementByXPath(document, xpath);
}
/* exported maybeGetElementFromDocumentByText */

/**
 * Make sure the profiler popup is enabled.
 */
async function makeSureProfilerPopupIsEnabled() {
  info("Make sure the profiler popup is enabled.");

  info("> Load the profiler menu button.");
  const { ProfilerMenuButton } = ChromeUtils.import(
    "resource://devtools/client/performance-new/popup/menu-button.jsm.js"
  );

  if (!ProfilerMenuButton.isInNavbar()) {
    // Make sure the feature flag is enabled.
    SpecialPowers.pushPrefEnv({
      set: [["devtools.performance.popup.feature-flag", true]],
    });

    info("> The menu button is not in the nav bar, add it.");
    ProfilerMenuButton.addToNavbar(document);

    await waitUntil(
      () => gBrowser.ownerDocument.getElementById("profiler-button"),
      "> Waiting until the profiler button is added to the browser."
    );

    await SimpleTest.promiseFocus(gBrowser.ownerGlobal);

    registerCleanupFunction(() => {
      info(
        "Clean up after the test by disabling the profiler popup menu button."
      );
      if (!ProfilerMenuButton.isInNavbar()) {
        throw new Error(
          "Expected the profiler popup to still be in the navbar during the test cleanup."
        );
      }
      ProfilerMenuButton.remove();
    });
  } else {
    info("> The menu button was already enabled.");
  }
}
/* exported makeSureProfilerPopupIsEnabled */

/**
 * XUL popups will fire the popupshown and popuphidden events. These will fire for
 * any type of popup in the browser. This function waits for one of those events, and
 * checks that the viewId of the popup is PanelUI-profiler
 *
 * @param {Window} window
 * @param {"popupshown" | "popuphidden"} eventName
 * @returns {Promise<void>}
 */
function waitForProfilerPopupEvent(window, eventName) {
  return new Promise(resolve => {
    function handleEvent(event) {
      if (event.target.getAttribute("viewId") === "PanelUI-profiler") {
        window.removeEventListener(eventName, handleEvent);
        resolve();
      }
    }
    window.addEventListener(eventName, handleEvent);
  });
}
/* exported waitForProfilerPopupEvent */

/**
 * Do not use this directly in a test. Prefer withPopupOpen and openPopupAndEnsureCloses.
 *
 * This function toggles the profiler menu button, and then uses user gestures
 * to click it open. It waits a tick to make sure it has a chance to initialize.
 * @param {Window} window
 * @return {Promise<void>}
 */
async function _toggleOpenProfilerPopup(window) {
  info("Toggle open the profiler popup.");

  info("> Find the profiler menu button.");
  const profilerDropmarker = window.document.getElementById(
    "profiler-button-dropmarker"
  );
  if (!profilerDropmarker) {
    throw new Error(
      "Could not find the profiler button dropmarker in the toolbar."
    );
  }

  const popupShown = waitForProfilerPopupEvent(window, "popupshown");

  info("> Trigger a click on the profiler button dropmarker.");
  await EventUtils.synthesizeMouseAtCenter(profilerDropmarker, {}, window);

  if (profilerDropmarker.getAttribute("open") !== "true") {
    throw new Error(
      "This test assumes that the button will have an open=true attribute after clicking it."
    );
  }

  info("> Wait for the popup to be shown.");
  await popupShown;
  // Also wait a tick in case someone else is subscribing to the "popupshown" event
  // and is doing synchronous work with it.
  await tick();
}

/**
 * Do not use this directly in a test. Prefer withPopupOpen.
 *
 * This function uses a keyboard shortcut to close the profiler popup.
 * @param {Window} window
 * @return {Promise<void>}
 */
async function _closePopup(window) {
  const popupHiddenPromise = waitForProfilerPopupEvent(window, "popuphidden");
  info("> Trigger an escape key to hide the popup");
  EventUtils.synthesizeKey("KEY_Escape");

  info("> Wait for the popup to be hidden.");
  await popupHiddenPromise;
  // Also wait a tick in case someone else is subscribing to the "popuphidden" event
  // and is doing synchronous work with it.
  await tick();
}

/**
 * Perform some action on the popup, and close it afterwards.
 * @param {Window} window
 * @param {() => Promise<void>} callback
 */
async function withPopupOpen(window, callback) {
  await _toggleOpenProfilerPopup(window);
  await callback();
  await _closePopup(window);
}
/* exported withPopupOpen */

/**
 * This function opens the profiler popup, but also ensures that something else closes
 * it before the end of the test. This is useful for tests that trigger the profiler
 * popup to close through an implicit action, like opening a tab.
 *
 * @param {Window} window
 * @param {() => Promise<void>} callback
 */
async function openPopupAndEnsureCloses(window, callback) {
  await _toggleOpenProfilerPopup(window);
  // We want to ensure the popup gets closed by the test, during the callback.
  const popupHiddenPromise = waitForProfilerPopupEvent(window, "popuphidden");
  await callback();
  info("> Verifying that the popup was closed by the test.");
  await popupHiddenPromise;
}
/* exported openPopupAndEnsureCloses */

/**
 * This function overwrites the default profiler.firefox.com URL for tests. This
 * ensures that the tests do not attempt to access external URLs.
 * The origin needs to be on the allowlist in validateProfilerWebChannelUrl,
 * otherwise the WebChannel won't work. ("http://example.com" is on that list.)
 *
 * @param {string} origin - For example: http://example.com
 * @param {string} pathname - For example: /my/testing/frontend.html
 * @returns {Promise}
 */
function setProfilerFrontendUrl(origin, pathname) {
  return SpecialPowers.pushPrefEnv({
    set: [
      // Make sure observer and testing function run in the same process
      ["devtools.performance.recording.ui-base-url", origin],
      ["devtools.performance.recording.ui-base-url-path", pathname],
    ],
  });
}
/* exported setProfilerFrontendUrl */

/**
 * This function checks the document title of a tab to see what the state is.
 * This creates a simple messaging mechanism between the content page and the
 * test harness. This function runs in a loop every requestAnimationFrame, and
 * checks for a sucess title. In addition, an "initialTitle" and "errorTitle"
 * can be specified for nicer test output.
 * @param {object}
 *   {
 *     initialTitle: string,
 *     successTitle: string,
 *     errorTitle: string
 *   }
 */
async function checkTabLoadedProfile({
  initialTitle,
  successTitle,
  errorTitle,
}) {
  const logPeriodically = createPeriodicLogger();

  info("Attempting to see if the selected tab can receive a profile.");

  return waitUntil(() => {
    switch (gBrowser.selectedTab.label) {
      case initialTitle:
        logPeriodically(`> Waiting for the profile to be received.`);
        return false;
      case successTitle:
        ok(true, "The profile was successfully injected to the page");
        BrowserTestUtils.removeTab(gBrowser.selectedTab);
        return true;
      case errorTitle:
        throw new Error(
          "The fake frontend indicated that there was an error injecting the profile."
        );
      default:
        logPeriodically(`> Waiting for the fake frontend tab to be loaded.`);
        return false;
    }
  });
}
/* exported checkTabLoadedProfile */

/**
 * This function checks the url of a tab so we can assert the frontend's url
 * with our expected url. This function runs in a loop every
 * requestAnimationFrame, and checks for a initialTitle. Asserts as soon as it
 * finds that title. We don't have to look for success title or error title
 * since we only care about the url.
 * @param {{
 *     initialTitle: string,
 *     successTitle: string,
 *     errorTitle: string,
 *     expectedUrl: string
 *   }}
 */
async function waitForTabUrl({
  initialTitle,
  successTitle,
  errorTitle,
  expectedUrl,
}) {
  const logPeriodically = createPeriodicLogger();

  info(`Waiting for the selected tab to have the url "${expectedUrl}".`);

  return waitUntil(() => {
    switch (gBrowser.selectedTab.label) {
      case initialTitle:
      case successTitle:
        if (gBrowser.currentURI.spec === expectedUrl) {
          ok(true, `The selected tab has the url ${expectedUrl}`);
          BrowserTestUtils.removeTab(gBrowser.selectedTab);
          return true;
        }
        throw new Error(
          `Found a different url on the fake frontend: ${gBrowser.currentURI.spec} (expecting ${expectedUrl})`
        );
      case errorTitle:
        throw new Error(
          "The fake frontend indicated that there was an error injecting the profile."
        );
      default:
        logPeriodically(`> Waiting for the fake frontend tab to be loaded.`);
        return false;
    }
  });
}
/* exported waitForTabUrl */

/**
 * This function checks the document title of a tab as an easy way to pass
 * messages from a content page to the mochitest.
 * @param {string} title
 */
async function waitForTabTitle(title) {
  const logPeriodically = createPeriodicLogger();

  info(`Waiting for the selected tab to have the title "${title}".`);

  return waitUntil(() => {
    if (gBrowser.selectedTab.label === title) {
      ok(true, `The selected tab has the title ${title}`);
      return true;
    }
    logPeriodically(`> Waiting for the tab title to change.`);
    return false;
  });
}
/* exported waitForTabTitle */

/**
 * Open about:profiling in a new tab, and output helpful log messages.
 *
 * @template T
 * @param {(Document) => T} callback
 * @returns {Promise<T>}
 */
function withAboutProfiling(callback) {
  info("Begin to open about:profiling in a new tab.");
  return BrowserTestUtils.withNewTab(
    "about:profiling",
    async contentBrowser => {
      info("about:profiling is now open in a tab.");
      await TestUtils.waitForCondition(
        () =>
          contentBrowser.contentDocument.getElementById("root")
            .firstElementChild,
        "Document's root has been populated"
      );
      return callback(contentBrowser.contentDocument);
    }
  );
}
/* exported withAboutProfiling */

/**
 * Open DevTools and view the performance-new tab. After running the callback, clean
 * up the test.
 *
 * @param {string} [url="about:blank"] url for the new tab
 * @param {(Document, Document) => unknown} callback: the first parameter is the
 *                                          devtools panel's document, the
 *                                          second parameter is the opened tab's
 *                                          document.
 * @param {Window} [aWindow] The browser's window object we target
 * @returns {Promise<void>}
 */
async function withDevToolsPanel(url, callback, aWindow = window) {
  if (typeof url === "function") {
    aWindow = callback ?? window;
    callback = url;
    url = "about:blank";
  }

  const { gBrowser } = aWindow;

  const { gDevTools } = require("devtools/client/framework/devtools");

  info(`Create a new tab with url "${url}".`);
  const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);

  info("Begin to open the DevTools and the performance-new panel.");
  const toolbox = await gDevTools.showToolboxForTab(tab, {
    toolId: "performance",
  });

  const { document } = toolbox.getCurrentPanel().panelWin;

  info("The performance-new panel is now open and ready to use.");
  await callback(document, tab.linkedBrowser.contentDocument);

  info("About to remove the about:blank tab");
  await toolbox.destroy();
  BrowserTestUtils.removeTab(tab);
  info("The about:blank tab is now removed.");
  await new Promise(resolve => setTimeout(resolve, 500));
}
/* exported withDevToolsPanel */

/**
 * Start and stop the profiler to get the current active configuration. This is
 * done programmtically through the nsIProfiler interface, rather than through click
 * interactions, since the about:profiling page does not include buttons to control
 * the recording.
 *
 * @returns {Object}
 */
function getActiveConfiguration() {
  const BackgroundJSM = ChromeUtils.import(
    "resource://devtools/client/performance-new/popup/background.jsm.js"
  );

  const { startProfiler, stopProfiler } = BackgroundJSM;

  info("Start the profiler with the current about:profiling configuration.");
  startProfiler("aboutprofiling");

  // Immediately pause the sampling, to make sure the test runs fast. The profiler
  // only needs to be started to initialize the configuration.
  Services.profiler.Pause();

  const { activeConfiguration } = Services.profiler;
  if (!activeConfiguration) {
    throw new Error(
      "Expected to find an active configuration for the profile."
    );
  }

  info("Stop the profiler after getting the active configuration.");
  stopProfiler();

  return activeConfiguration;
}
/* exported getActiveConfiguration */

/**
 * Start the profiler programmatically and check that the active configuration has
 * a feature enabled
 *
 * @param {string} feature
 * @return {boolean}
 */
function activeConfigurationHasFeature(feature) {
  const { features } = getActiveConfiguration();
  return features.includes(feature);
}
/* exported activeConfigurationHasFeature */

/**
 * Start the profiler programmatically and check that the active configuration is
 * tracking a thread.
 *
 * @param {string} thread
 * @return {boolean}
 */
function activeConfigurationHasThread(thread) {
  const { threads } = getActiveConfiguration();
  return threads.includes(thread);
}
/* exported activeConfigurationHasThread */

/**
 * Use user driven events to start the profiler, and then get the active configuration
 * of the profiler. This is similar to functions in the head.js file, but is specific
 * for the DevTools situation. The UI complains if the profiler stops unexpectedly.
 *
 * @param {Document} document
 * @param {string} feature
 * @returns {boolean}
 */
async function devToolsActiveConfigurationHasFeature(document, feature) {
  info("Get the active configuration of the profiler via user driven events.");
  const start = await getActiveButtonFromText(document, "Start recording");
  info("Click the button to start recording.");
  start.click();

  // Get the cancel button first, so that way we know the profile has actually
  // been recorded.
  const cancel = await getActiveButtonFromText(document, "Cancel recording");

  const { activeConfiguration } = Services.profiler;
  if (!activeConfiguration) {
    throw new Error(
      "Expected to find an active configuration for the profile."
    );
  }

  info("Click the cancel button to discard the profile..");
  cancel.click();

  // Wait until the start button is back.
  await getActiveButtonFromText(document, "Start recording");

  return activeConfiguration.features.includes(feature);
}
/* exported devToolsActiveConfigurationHasFeature */

/**
 * This adapts the expectation using the current build's available profiler
 * features.
 * @param {string} fixture It can be either already trimmed or untrimmed.
 * @returns {string}
 */
function _adaptCustomPresetExpectationToCustomBuild(fixture) {
  const supportedFeatures = Services.profiler.GetFeatures();
  info("Supported features are: " + supportedFeatures.join(", "));

  // Some platforms do not support stack walking, we can adjust the passed
  // fixture so that tests are passing in these platforms too.
  // Most notably MacOS outside of Nightly and DevEdition.
  if (!supportedFeatures.includes("stackwalk")) {
    info(
      "Supported features do not include stackwalk, let's remove the Native Stacks from the expected output."
    );
    fixture = fixture.replace(/^.*Native Stacks.*\n/m, "");
  }

  return fixture;
}

/**
 * Get the content of the preset description.
 * @param {Element} devtoolsDocument
 * @returns {string}
 */
function getDevtoolsCustomPresetContent(devtoolsDocument) {
  return devtoolsDocument.querySelector(".perf-presets-custom").innerText;
}
/* exported getDevtoolsCustomPresetContent */

/**
 * This checks if the content of the preset description equals the fixture in
 * string form.
 * @param {Element} devtoolsDocument
 * @param {string} fixture
 */
function checkDevtoolsCustomPresetContent(devtoolsDocument, fixture) {
  // This removes all indentations and any start or end new line and other space characters.
  fixture = fixture.replace(/^\s+/gm, "").trim();
  // This removes unavailable features from the fixture content.
  fixture = _adaptCustomPresetExpectationToCustomBuild(fixture);
  is(getDevtoolsCustomPresetContent(devtoolsDocument), fixture);
}
/* exported checkDevtoolsCustomPresetContent */

/**
 * Selects an element with some given text, then it walks up the DOM until it finds
 * an input or select element via a call to querySelector.
 *
 * @param {Document} document
 * @param {string} text
 * @param {HTMLInputElement}
 */
async function getNearestInputFromText(document, text) {
  const textElement = await getElementFromDocumentByText(document, text);
  if (textElement.control) {
    // This is a label, just grab the input.
    return textElement.control;
  }
  // A non-label node
  let next = textElement;
  while ((next = next.parentElement)) {
    const input = next.querySelector("input, select");
    if (input) {
      return input;
    }
  }
  throw new Error("Could not find an input or select near the text element.");
}
/* exported getNearestInputFromText */

/**
 * Grabs the closest button element from a given snippet of text, and make sure
 * the button is not disabled.
 *
 * @param {Document} document
 * @param {string} text
 * @param {HTMLButtonElement}
 */
async function getActiveButtonFromText(document, text) {
  // This could select a span inside the button, or the button itself.
  let button = await getElementFromDocumentByText(document, text);

  while (button.tagName !== "button") {
    // Walk up until a button element is found.
    button = button.parentElement;
    if (!button) {
      throw new Error(`Unable to find a button from the text "${text}"`);
    }
  }

  await waitUntil(
    () => !button.disabled,
    "Waiting until the button is not disabled."
  );

  return button;
}
/* exported getActiveButtonFromText */

/**
 * Wait until the profiler menu button is added.
 *
 * @returns Promise<void>
 */
async function waitForProfilerMenuButton() {
  info("Checking if the profiler menu button is enabled.");
  await waitUntil(
    () => gBrowser.ownerDocument.getElementById("profiler-button"),
    "> Waiting until the profiler button is added to the browser."
  );
}
/* exported waitForProfilerMenuButton */

/**
 * Make sure the profiler popup is disabled for the test.
 */
async function makeSureProfilerPopupIsDisabled() {
  info("Make sure the profiler popup is dsiabled.");

  info("> Load the profiler menu button module.");
  const { ProfilerMenuButton } = ChromeUtils.import(
    "resource://devtools/client/performance-new/popup/menu-button.jsm.js"
  );

  const isOriginallyInNavBar = ProfilerMenuButton.isInNavbar();

  if (isOriginallyInNavBar) {
    info("> The menu button is in the navbar, remove it for this test.");
    ProfilerMenuButton.remove();
  } else {
    info("> The menu button was not in the navbar yet.");
  }

  registerCleanupFunction(() => {
    info("Revert the profiler menu button to be back in its original place");
    if (isOriginallyInNavBar !== ProfilerMenuButton.isInNavbar()) {
      ProfilerMenuButton.remove();
    }
  });
}
/* exported makeSureProfilerPopupIsDisabled */

/**
 * Open the WebChannel test document, that will enable the profiler popup via
 * WebChannel.
 * @param {Function} callback
 */
function withWebChannelTestDocument(callback) {
  return BrowserTestUtils.withNewTab(
    {
      gBrowser,
      url:
        "http://example.com/browser/devtools/client/performance-new/test/browser/webchannel.html",
    },
    callback
  );
}
/* exported withWebChannelTestDocument */

// This has been stolen from the great library dom-testing-library.
// See https://github.com/testing-library/dom-testing-library/blob/91b9dc3b6f5deea88028e97aab15b3b9f3289a2a/src/events.js#L104-L123
// function written after some investigation here:
// https://github.com/facebook/react/issues/10135#issuecomment-401496776
function setNativeValue(element, value) {
  const { set: valueSetter } =
    Object.getOwnPropertyDescriptor(element, "value") || {};
  const prototype = Object.getPrototypeOf(element);
  const { set: prototypeValueSetter } =
    Object.getOwnPropertyDescriptor(prototype, "value") || {};
  if (prototypeValueSetter && valueSetter !== prototypeValueSetter) {
    prototypeValueSetter.call(element, value);
  } else {
    /* istanbul ignore if */
    // eslint-disable-next-line no-lonely-if -- Can't be ignored by istanbul otherwise
    if (valueSetter) {
      valueSetter.call(element, value);
    } else {
      throw new Error("The given element does not have a value setter");
    }
  }
}
/* exported setNativeValue */

/**
 * Set a React-friendly input value. Doing this the normal way doesn't work.
 * This reuses the previous function setNativeValue stolen from
 * dom-testing-library.
 *
 * See https://github.com/facebook/react/issues/10135
 *
 * @param {HTMLInputElement} input
 * @param {string} value
 */
function setReactFriendlyInputValue(input, value) {
  setNativeValue(input, value);

  // 'change' instead of 'input', see https://github.com/facebook/react/issues/11488#issuecomment-381590324
  input.dispatchEvent(new Event("change", { bubbles: true }));
}
/* exported setReactFriendlyInputValue */

/**
 * The recording state is the internal state machine that represents the async
 * operations that are going on in the profiler. This function sets up a helper
 * that will obtain the Redux store and query this internal state. This is useful
 * for unit testing purposes.
 *
 * @param {Document} document
 */
function setupGetRecordingState(document) {
  const selectors = require("devtools/client/performance-new/store/selectors");
  const store = document.defaultView.gStore;
  if (!store) {
    throw new Error("Could not find the redux store on the window object.");
  }
  return () => selectors.getRecordingState(store.getState());
}
/* exported setupGetRecordingState */
