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

/* globals synthesizeMouseAtCenterAndRetry, awaitBrowserLoaded, closeMenuPopup, clickItemInMenuPopup, openSubMenuPopup */

var { ExtensionPermissions } = ChromeUtils.importESModule(
  "resource://gre/modules/ExtensionPermissions.sys.mjs"
);
var { mailTestUtils } = ChromeUtils.importESModule(
  "resource://testing-common/mailnews/MailTestUtils.sys.mjs"
);

const treeClick = mailTestUtils.treeClick.bind(null, EventUtils, window);

const URL_BASE =
  "http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data";

/**
 * Right-click on something in a content document and wait for the context
 * menu to appear.
 *
 * @param {Element} menu - The <menu> that should appear.
 * @param {string} selector - CSS selector of the element to be clicked on.
 * @param {Element} browser - <browser> containing the element.
 * @returns {Promise} A promise that resolves when the menu appears.
 */
async function rightClickOnContent(menu, selector, browser) {
  const shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
  await synthesizeMouseAtCenterAndRetry(
    selector,
    { type: "contextmenu" },
    browser
  );
  return shownPromise;
}

/**
 * Check the parameters of a browser.onShown event was fired.
 *
 * @see mail/components/extensions/schemas/menus.json
 *
 * @param {ExtensionWrapper} extension
 * @param {object} expectedInfo
 * @param {?Array} expectedInfo.menuIds
 * @param {?Array} expectedInfo.contexts
 * @param {?Array} expectedInfo.attachments
 * @param {?object} expectedInfo.displayedFolder
 * @param {?object} expectedInfo.selectedFolder
 * @param {?object} expectedInfo.selectedFolders
 * @param {?Array} expectedInfo.selectedMessages
 * @param {?RegExp} expectedInfo.pageUrl
 * @param {?string} expectedInfo.selectionText
 * @param {object} expectedTab
 * @param {boolean} expectedTab.active
 * @param {integer} expectedTab.index
 * @param {string} expectedTab.type
 */
async function checkShownEvent(extension, expectedInfo, expectedTab) {
  const [info, tab] = await extension.awaitMessage("onShown");
  Assert.deepEqual(info.menuIds, expectedInfo.menuIds);
  Assert.deepEqual(info.contexts, expectedInfo.contexts);

  Assert.equal(
    !!info.attachments,
    !!expectedInfo.attachments,
    "attachments in info"
  );
  if (expectedInfo.attachments) {
    Assert.equal(info.attachments.length, expectedInfo.attachments.length);
    for (let i = 0; i < expectedInfo.attachments.length; i++) {
      Assert.equal(info.attachments[i].name, expectedInfo.attachments[i].name);
      Assert.equal(info.attachments[i].size, expectedInfo.attachments[i].size);
    }
  }

  for (const infoKey of ["displayedFolder", "selectedFolder"]) {
    Assert.equal(
      !!info[infoKey],
      !!expectedInfo[infoKey],
      `${infoKey} in info`
    );

    Assert.ok(
      !!extension.manifest,
      "The manifest needs to be manually attached to the extension object for this test."
    );

    if (expectedInfo[infoKey]) {
      Assert.equal(info[infoKey].accountId, expectedInfo[infoKey].accountId);
      Assert.equal(info[infoKey].path, expectedInfo[infoKey].path);
      if (
        infoKey == "displayedFolder" &&
        extension.manifest.manifest_version > 2
      ) {
        Assert.ok(
          !Array.isArray(info[infoKey].subFolders),
          `${infoKey} should not have subFolders in Manifest V3 or later`
        );
      } else {
        Assert.ok(
          Array.isArray(info[infoKey].subFolders),
          `${infoKey} should have subFolders in Manifest V2`
        );
      }
    }
  }

  for (const infoKey of ["selectedFolders"]) {
    Assert.equal(
      !!info[infoKey],
      !!expectedInfo[infoKey],
      `${infoKey} in info`
    );
    if (expectedInfo[infoKey]) {
      for (let i = 0; i < expectedInfo[infoKey].length; i++) {
        Assert.equal(
          info[infoKey][i].accountId,
          expectedInfo[infoKey][i].accountId
        );
        Assert.equal(info[infoKey][i].path, expectedInfo[infoKey][i].path);
        Assert.ok(
          !info[infoKey][i].subFolders,
          `${infoKey}[${i}] should not have subFolders`
        );
      }
    }
  }

  Assert.equal(
    !!info.selectedMessages,
    !!expectedInfo.selectedMessages,
    "selectedMessages in info"
  );
  if (expectedInfo.selectedMessages) {
    Assert.equal(info.selectedMessages.id, null);
    Assert.equal(
      info.selectedMessages.messages.length,
      expectedInfo.selectedMessages.messages.length
    );
    for (let i = 0; i < expectedInfo.selectedMessages.messages.length; i++) {
      Assert.equal(
        info.selectedMessages.messages[i].subject,
        expectedInfo.selectedMessages.messages[i].subject
      );
    }
  }

  Assert.equal(!!info.pageUrl, !!expectedInfo.pageUrl, "pageUrl in info");
  if (expectedInfo.pageUrl) {
    if (typeof expectedInfo.pageUrl == "string") {
      Assert.equal(info.pageUrl, expectedInfo.pageUrl);
    } else {
      Assert.ok(info.pageUrl.match(expectedInfo.pageUrl));
    }
  }

  Assert.equal(
    !!info.selectionText,
    !!expectedInfo.selectionText,
    "selectionText in info"
  );
  if (expectedInfo.selectionText) {
    Assert.equal(info.selectionText, expectedInfo.selectionText);
  }

  Assert.equal(tab.active, expectedTab.active, "tab is active");
  Assert.equal(tab.index, expectedTab.index, "tab index");
  Assert.equal(tab.type, expectedTab.type, "tab type is correct");
}

/**
 * Check the parameters of a browser.onClicked event was fired.
 *
 * @see mail/components/extensions/schemas/menus.json
 *
 * @param {ExtensionWrapper} extension
 * @param {object} expectedInfo
 * @param {?string} expectedInfo.selectionText
 * @param {?string} expectedInfo.linkText
 * @param {?RegExp} expectedInfo.pageUrl
 * @param {?RegExp} expectedInfo.linkUrl
 * @param {?RegExp} expectedInfo.srcUrl
 * @param {object} expectedTab
 * @param {boolean} expectedTab.active
 * @param {integer} expectedTab.index
 * @param {string} expectedTab.type
 */
async function checkClickedEvent(extension, expectedInfo, expectedTab) {
  const [info, tab] = await extension.awaitMessage("onClicked");

  Assert.equal(info.selectionText, expectedInfo.selectionText, "selectionText");
  Assert.equal(info.linkText, expectedInfo.linkText, "linkText");
  if (expectedInfo.menuItemId) {
    Assert.equal(info.menuItemId, expectedInfo.menuItemId, "menuItemId");
  }

  for (const infoKey of ["pageUrl", "linkUrl", "srcUrl"]) {
    Assert.equal(
      !!info[infoKey],
      !!expectedInfo[infoKey],
      `${infoKey} in info`
    );
    if (expectedInfo[infoKey]) {
      if (typeof expectedInfo[infoKey] == "string") {
        Assert.equal(info[infoKey], expectedInfo[infoKey]);
      } else {
        Assert.ok(info[infoKey].match(expectedInfo[infoKey]));
      }
    }
  }

  Assert.equal(tab.active, expectedTab.active, "tab is active");
  Assert.equal(tab.index, expectedTab.index, "tab index");
  Assert.equal(tab.type, expectedTab.type, "tab type is correct");
}

async function getMenuExtension(manifest) {
  // Default to Manifest V2, if none provided.
  if (!manifest.manifest_version) {
    manifest.manifest_version = 2;
  }

  const details = {
    files: {
      "background.js": async () => {
        // Register listeners before the first await, so they get registered as
        // persistent listeners.
        browser.menus.onClicked.addListener((...args) => {
          browser.test.sendMessage("onClicked", args);
        });

        browser.menus.onShown.addListener((...args) => {
          browser.test.sendMessage("onShown", args);
        });

        if (browser.runtime.getManifest().manifest_version > 2) {
          browser.runtime.onSuspend.addListener(() => {
            browser.test.sendMessage("suspended-test_menu_onclick");
          });
        }

        const contexts = [
          "audio",
          "compose_action",
          "compose_action_menu",
          "message_display_action",
          "message_display_action_menu",
          "editable",
          "frame",
          "image",
          "link",
          "page",
          "password",
          "selection",
          "tab",
          "video",
          "message_list",
          "folder_pane",
          "compose_attachments",
          "compose_body",
          "tools_menu",
        ];
        if (browser.runtime.getManifest().manifest_version > 2) {
          contexts.push("action", "action_menu");
        } else {
          contexts.push("browser_action", "browser_action_menu");
        }
        for (const context of contexts) {
          await new Promise(resolve =>
            browser.menus.create(
              {
                id: context,
                title: context,
                contexts: [context],
              },
              resolve
            )
          );
        }

        browser.test.sendMessage("menus-created");
      },
    },
    manifest: {
      browser_specific_settings: {
        gecko: {
          id: "menus@mochi.test",
        },
      },
      background: { scripts: ["background.js"] },
      ...manifest,
    },
    useAddonManager: "temporary",
  };

  if (!details.manifest.permissions) {
    details.manifest.permissions = [];
  }
  details.manifest.permissions.push("menus");
  console.log(JSON.stringify(details, 2));
  const extension = ExtensionTestUtils.loadExtension(details);
  if (details.manifest.host_permissions) {
    // MV3 has to manually grant the requested permission.
    await ExtensionPermissions.add("menus@mochi.test", {
      permissions: [],
      origins: details.manifest.host_permissions,
    });
  }

  // Manually attach the manifest to the extension object.
  extension.manifest = details.manifest;
  return extension;
}

async function subtest_content(
  extension,
  extensionHasPermission,
  browser,
  pageUrl,
  tab
) {
  await awaitBrowserLoaded(browser, url => url != "about:blank");

  const menuId = browser.getAttribute("context") || "mailContext";
  let ownerDocument;
  if (browser.ownerGlobal.parent.location.href == "about:3pane") {
    ownerDocument = browser.ownerGlobal.parent.document;
  } else if (menuId == "browserContext") {
    ownerDocument = browser.ownerGlobal.top.document;
  } else {
    ownerDocument = browser.ownerDocument;
  }
  const menu = ownerDocument.getElementById(menuId);

  await synthesizeMouseAtCenterAndRetry("body", {}, browser);

  info("Test a part of the page with no content.");

  await rightClickOnContent(menu, "body", browser);
  Assert.ok(menu.querySelector("#menus_mochi_test-menuitem-_page"));
  await closeMenuPopup(menu);

  await checkShownEvent(
    extension,
    {
      menuIds: ["page"],
      contexts: ["page", "all"],
      pageUrl: extensionHasPermission ? pageUrl : undefined,
    },
    tab
  );

  info("Test selection.");

  await SpecialPowers.spawn(browser, [], () => {
    const text = content.document.querySelector("p");
    content.getSelection().selectAllChildren(text);
  });
  await rightClickOnContent(menu, "p", browser);
  Assert.ok(menu.querySelector("#menus_mochi_test-menuitem-_selection"));
  await checkShownEvent(
    extension,
    {
      pageUrl: extensionHasPermission ? pageUrl : undefined,
      selectionText: extensionHasPermission ? "This is text." : undefined,
      menuIds: ["selection"],
      contexts: ["selection", "all"],
    },
    tab
  );

  let clickedPromise = checkClickedEvent(
    extension,
    {
      pageUrl,
      selectionText: "This is text.",
    },
    tab
  );
  await clickItemInMenuPopup(
    menu.querySelector("#menus_mochi_test-menuitem-_selection")
  );
  await clickedPromise;
  await synthesizeMouseAtCenterAndRetry("body", {}, browser); // Select nothing.

  info("Test link.");

  await rightClickOnContent(menu, "a", browser);
  Assert.ok(menu.querySelector("#menus_mochi_test-menuitem-_link"));
  await checkShownEvent(
    extension,
    {
      pageUrl: extensionHasPermission ? pageUrl : undefined,
      menuIds: ["link"],
      contexts: ["link", "all"],
    },
    tab
  );

  clickedPromise = checkClickedEvent(
    extension,
    {
      pageUrl,
      linkUrl: "http://mochi.test:8888/",
      linkText: "This is a link with text.",
    },
    tab
  );
  await clickItemInMenuPopup(
    menu.querySelector("#menus_mochi_test-menuitem-_link")
  );
  await clickedPromise;

  info("Test image.");

  await rightClickOnContent(menu, "img", browser);
  Assert.ok(menu.querySelector("#menus_mochi_test-menuitem-_image"));
  await checkShownEvent(
    extension,
    {
      pageUrl: extensionHasPermission ? pageUrl : undefined,
      menuIds: ["image"],
      contexts: ["image", "all"],
    },
    tab
  );

  clickedPromise = checkClickedEvent(
    extension,
    {
      pageUrl,
      srcUrl: `${URL_BASE}/tb-logo.png`,
    },
    tab
  );
  await clickItemInMenuPopup(
    menu.querySelector("#menus_mochi_test-menuitem-_image")
  );
  await clickedPromise;
}

async function openExtensionSubMenu(menu) {
  // The extension submenu ends with a number, which increases over time, but it
  // does not have a underscore.
  let submenu;
  for (const item of menu.querySelectorAll(
    "[id^=menus_mochi_test-menuitem-]"
  )) {
    if (!item.id.includes("-_")) {
      submenu = item;
      break;
    }
  }
  Assert.ok(submenu, `Found submenu: ${submenu.id}`);

  // Open submenu.
  await openSubMenuPopup(submenu);
  return submenu;
}

async function subtest_compose_body(
  extension,
  extensionHasPermission,
  browser,
  pageUrl,
  tab
) {
  await awaitBrowserLoaded(browser, url => url != "about:blank");

  const ownerDocument = browser.ownerDocument;
  const menu = ownerDocument.getElementById(browser.getAttribute("context"));

  await synthesizeMouseAtCenterAndRetry("body", {}, browser);

  info("Test a part of the page with no content.");
  await rightClickOnContent(menu, "body", browser);
  Assert.ok(menu.querySelector(`#menus_mochi_test-menuitem-_compose_body`));
  Assert.ok(menu.querySelector(`#menus_mochi_test-menuitem-_editable`));
  await closeMenuPopup(menu);

  await checkShownEvent(
    extension,
    {
      menuIds: ["editable", "compose_body"],
      contexts: ["editable", "compose_body", "all"],
      pageUrl: extensionHasPermission ? pageUrl : undefined,
    },
    tab
  );

  info("Test selection.");
  {
    await SpecialPowers.spawn(browser, [], () => {
      const text = content.document.querySelector("p");
      content.getSelection().selectAllChildren(text);
    });

    await rightClickOnContent(menu, "p", browser);
    const submenu = await openExtensionSubMenu(menu);

    await checkShownEvent(
      extension,
      {
        pageUrl: extensionHasPermission ? pageUrl : undefined,
        selectionText: extensionHasPermission ? "This is text." : undefined,
        menuIds: ["editable", "selection", "compose_body"],
        contexts: ["editable", "selection", "compose_body", "all"],
      },
      tab
    );
    Assert.ok(submenu.querySelector("#menus_mochi_test-menuitem-_selection"));
    Assert.ok(
      submenu.querySelector("#menus_mochi_test-menuitem-_compose_body")
    );
    Assert.ok(submenu.querySelector("#menus_mochi_test-menuitem-_editable"));

    const clickedPromise = checkClickedEvent(
      extension,
      {
        pageUrl,
        selectionText: "This is text.",
      },
      tab
    );
    await clickItemInMenuPopup(
      submenu.querySelector("#menus_mochi_test-menuitem-_selection")
    );
    await clickedPromise;
    await synthesizeMouseAtCenterAndRetry("body", {}, browser); // Select nothing.
  }

  info("Test link.");
  {
    await rightClickOnContent(menu, "a", browser);
    const submenu = await openExtensionSubMenu(menu);

    await checkShownEvent(
      extension,
      {
        pageUrl: extensionHasPermission ? pageUrl : undefined,
        menuIds: ["editable", "link", "compose_body"],
        contexts: ["editable", "link", "compose_body", "all"],
      },
      tab
    );
    Assert.ok(submenu.querySelector("#menus_mochi_test-menuitem-_link"));
    Assert.ok(submenu.querySelector("#menus_mochi_test-menuitem-_editable"));
    Assert.ok(
      submenu.querySelector("#menus_mochi_test-menuitem-_compose_body")
    );

    const clickedPromise = checkClickedEvent(
      extension,
      {
        pageUrl,
        linkUrl: "http://mochi.test:8888/",
        linkText: "This is a link with text.",
      },
      tab
    );
    await clickItemInMenuPopup(
      submenu.querySelector("#menus_mochi_test-menuitem-_link")
    );
    await clickedPromise;
    await synthesizeMouseAtCenterAndRetry("body", {}, browser); // Select nothing.
  }

  info("Test image.");
  {
    await rightClickOnContent(menu, "img", browser);
    const submenu = await openExtensionSubMenu(menu);

    await checkShownEvent(
      extension,
      {
        pageUrl: extensionHasPermission ? pageUrl : undefined,
        menuIds: ["editable", "image", "compose_body"],
        contexts: ["editable", "image", "compose_body", "all"],
      },
      tab
    );
    Assert.ok(submenu.querySelector("#menus_mochi_test-menuitem-_image"));
    Assert.ok(submenu.querySelector("#menus_mochi_test-menuitem-_editable"));
    Assert.ok(
      submenu.querySelector("#menus_mochi_test-menuitem-_compose_body")
    );

    const clickedPromise = checkClickedEvent(
      extension,
      {
        pageUrl,
        srcUrl: `${URL_BASE}/tb-logo.png`,
      },
      tab
    );
    await clickItemInMenuPopup(
      submenu.querySelector("#menus_mochi_test-menuitem-_image")
    );
    await clickedPromise;
    await synthesizeMouseAtCenterAndRetry("body", {}, browser); // Select nothing.
  }
}

// Test UI elements which have been made accessible for the menus API.
// Assumed to be run after subtest_content, so we know everything has finished
// loading.
async function subtest_element(
  extension,
  extensionHasPermission,
  element,
  pageUrl,
  tab
) {
  /**
   * Function to trigger a context click on the specified element. The provided
   * observerElement is used to wait for the popupshown event.
   * This function cannot be replaced by BrowserTestUtils.waitForPopupEvent(),
   * because the context menu may not exist yet. It is created on the fly here:
   * https://searchfox.org/comm-central/rev/7e60bfd71efc4a4a3aece6a0ab87f3ffb75803a2/mozilla/toolkit/content/editMenuOverlay.js#108-126
   *
   * @param {Element} observerElement - An element which can observe the expected
   *   popupshown event, which will be triggered by the click.
   * @param {Element} elementToClick - The element to click on.
   *
   * @returns {Promise<event>} The captured popupshown event.
   */
  const rightClick = (observerElement, elementToClick) => {
    const shownPromise = BrowserTestUtils.waitForEvent(
      observerElement,
      "popupshown"
    );
    EventUtils.synthesizeMouseAtCenter(
      elementToClick,
      { type: "contextmenu" },
      elementToClick.ownerGlobal
    );
    return shownPromise;
  };

  for (const selectedTest of [false, true]) {
    element.focus();
    if (selectedTest) {
      element.value = "This is selected text.";
      element.select();
    } else {
      element.value = "";
    }
    const event = await rightClick(element.ownerGlobal, element);
    const menu = event.target;
    const trigger = menu.triggerNode;
    const menuitem = menu.querySelector("#menus_mochi_test-menuitem-_editable");
    Assert.equal(
      element.id,
      trigger.id,
      "Contextmenu of correct element has been triggered."
    );
    Assert.equal(
      menuitem.id,
      "menus_mochi_test-menuitem-_editable",
      "Contextmenu includes menu."
    );

    await checkShownEvent(
      extension,
      {
        menuIds: selectedTest ? ["editable", "selection"] : ["editable"],
        contexts: selectedTest
          ? ["editable", "selection", "all"]
          : ["editable", "all"],
        pageUrl: extensionHasPermission ? pageUrl : undefined,
        selectionText:
          extensionHasPermission && selectedTest
            ? "This is selected text."
            : undefined,
      },
      tab
    );

    // With text being selected, there will be two "context" entries in an
    // extension submenu. Open the submenu.
    let submenu = null;
    if (selectedTest) {
      for (const foundMenu of menu.querySelectorAll(
        "[id^='menus_mochi_test-menuitem-']"
      )) {
        if (!foundMenu.id.startsWith("menus_mochi_test-menuitem-_")) {
          submenu = foundMenu;
        }
      }
      await openSubMenuPopup(submenu);
    }

    const clickedPromise = checkClickedEvent(
      extension,
      {
        pageUrl,
        selectionText: selectedTest ? "This is selected text." : undefined,
      },
      tab
    );

    await clickItemInMenuPopup(menuitem);
    await clickedPromise;
  }
}
