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

function getVisibleChildrenIds(menuElem) {
  return Array.from(menuElem.children)
    .filter(elem => !elem.hidden)
    .map(elem => (elem.tagName == "menuseparator" ? elem.tagName : elem.id));
}

function checkIsLinkMenuItemVisible(visibleMenuItemIds) {
  // In most of this test file, we open a menu on a link. Assume that all
  // relevant menu items are shown if one link-specific menu item is shown.
  ok(
    visibleMenuItemIds.includes("context-openlink"),
    `The default 'Open Link in New Tab' menu item should be in ${visibleMenuItemIds}.`
  );
}

// Tests the following:
// - Calling overrideContext({}) during oncontextmenu forces the menu to only
//   show an extension's own items.
// - These menu items all appear in the root menu.
// - The usual extension filtering behavior (e.g. documentUrlPatterns and
//   targetUrlPatterns) is still applied; some menu items are therefore hidden.
// - Calling overrideContext({showDefaults:true}) causes the default menu items
//   to be shown, but only after the extension's.
// - overrideContext expires after the menu is opened once.
// - overrideContext can be called from shadow DOM.
add_task(async function overrideContext_in_extension_tab() {
  await SpecialPowers.pushPrefEnv({
    set: [["security.allow_eval_with_system_principal", true]],
  });

  function extensionTabScript() {
    document.addEventListener(
      "contextmenu",
      () => {
        browser.menus.overrideContext({});
        browser.test.sendMessage("oncontextmenu_in_dom_part_1");
      },
      { once: true }
    );

    let shadowRoot = document
      .getElementById("shadowHost")
      .attachShadow({ mode: "open" });
    shadowRoot.innerHTML = `<a href="http://example.com/">Link</a>`;
    shadowRoot.firstChild.addEventListener(
      "contextmenu",
      () => {
        browser.menus.overrideContext({});
        browser.test.sendMessage("oncontextmenu_in_shadow_dom");
      },
      { once: true }
    );

    document.querySelector("p").addEventListener(
      "contextmenu",
      () => {
        browser.menus.overrideContext({ showDefaults: true });
      },
      { once: true }
    );

    browser.menus.create({
      id: "tab_1",
      title: "tab_1",
      documentUrlPatterns: [document.URL],
      onclick() {
        document.addEventListener(
          "contextmenu",
          () => {
            // Verifies that last call takes precedence.
            browser.menus.overrideContext({ showDefaults: false });
            browser.menus.overrideContext({ showDefaults: true });
            browser.test.sendMessage("oncontextmenu_in_dom_part_2");
          },
          { once: true }
        );
        browser.test.sendMessage("onClicked_tab_1");
      },
    });
    browser.menus.create(
      {
        id: "tab_2",
        title: "tab_2",
        onclick() {
          browser.test.sendMessage("onClicked_tab_2");
        },
      },
      () => {
        browser.test.sendMessage("menu-registered");
      }
    );
  }

  let extension = ExtensionTestUtils.loadExtension({
    manifest: {
      permissions: ["menus", "menus.overrideContext"],
    },
    files: {
      "tab.html": `
        <!DOCTYPE html><meta charset="utf-8">
        <a href="http://example.com/">Link</a>
        <p>Some text</p>
        <div id="shadowHost"></div>
        <script src="tab.js"></script>
      `,
      "tab.js": extensionTabScript,
    },
    background() {
      // Expected to match and thus be visible.
      browser.menus.create({ id: "bg_1", title: "bg_1" });
      browser.menus.create({
        id: "bg_2",
        title: "bg_2",
        targetUrlPatterns: ["*://example.com/*"],
      });

      // Expected to not match and be hidden.
      browser.menus.create({
        id: "bg_3",
        title: "bg_3",
        targetUrlPatterns: ["*://nomatch/*"],
      });
      browser.menus.create({
        id: "bg_4",
        title: "bg_4",
        documentUrlPatterns: [document.URL],
      });

      browser.menus.onShown.addListener(info => {
        browser.test.assertEq("tab", info.viewType, "Expected viewType");
        let sortedContexts = info.contexts.sort().join(",");
        if (info.contexts.includes("link")) {
          browser.test.assertEq(
            "bg_1,bg_2,tab_1,tab_2",
            info.menuIds.join(","),
            "Expected menu items."
          );
          browser.test.assertEq(
            "all,link",
            sortedContexts,
            "Expected menu contexts"
          );
        } else if (info.contexts.includes("page")) {
          browser.test.assertEq(
            "bg_1,tab_1,tab_2",
            info.menuIds.join(","),
            "Expected menu items."
          );
          browser.test.assertEq(
            "all,page",
            sortedContexts,
            "Expected menu contexts"
          );
        } else {
          browser.test.fail(`Unexpected menu context: ${sortedContexts}`);
        }
        browser.test.sendMessage("onShown");
      });

      browser.tabs.create({ url: "tab.html" });
    },
  });

  let otherExtension = ExtensionTestUtils.loadExtension({
    manifest: {
      permissions: ["menus"],
    },
    background() {
      browser.menus.create(
        { id: "other_extension_item", title: "other_extension_item" },
        () => {
          browser.test.sendMessage("other_extension_item_created");
        }
      );
    },
  });
  await otherExtension.startup();
  await otherExtension.awaitMessage("other_extension_item_created");

  let extensionTabPromise = BrowserTestUtils.waitForNewTab(
    gBrowser,
    null,
    true
  );
  await extension.startup();
  // Must wait for the tab to have loaded completely before calling openContextMenu.
  await extensionTabPromise;
  await extension.awaitMessage("menu-registered");

  const EXPECTED_EXTENSION_MENU_IDS = [
    `${makeWidgetId(extension.id)}-menuitem-_bg_1`,
    `${makeWidgetId(extension.id)}-menuitem-_bg_2`,
    `${makeWidgetId(extension.id)}-menuitem-_tab_1`,
    `${makeWidgetId(extension.id)}-menuitem-_tab_2`,
  ];

  const EXPECTED_EXTENSION_MENU_IDS_NOLINK = [
    `${makeWidgetId(extension.id)}-menuitem-_bg_1`,
    `${makeWidgetId(extension.id)}-menuitem-_tab_1`,
    `${makeWidgetId(extension.id)}-menuitem-_tab_2`,
  ];
  const OTHER_EXTENSION_MENU_ID = `${makeWidgetId(
    otherExtension.id
  )}-menuitem-_other_extension_item`;

  {
    // Tests overrideContext({})
    info("Expecting the menu to be replaced by overrideContext.");
    let menu = await openContextMenu("a");
    await extension.awaitMessage("oncontextmenu_in_dom_part_1");
    await extension.awaitMessage("onShown");

    Assert.deepEqual(
      getVisibleChildrenIds(menu),
      EXPECTED_EXTENSION_MENU_IDS,
      "Expected only extension menu items"
    );

    let menuItems = menu.getElementsByAttribute("label", "tab_1");
    await closeExtensionContextMenu(menuItems[0]);
    await extension.awaitMessage("onClicked_tab_1");
  }

  {
    // Tests overrideContext({showDefaults:true}))
    info(
      "Expecting the menu to be replaced by overrideContext, including default menu items."
    );
    let menu = await openContextMenu("a");
    await extension.awaitMessage("oncontextmenu_in_dom_part_2");
    await extension.awaitMessage("onShown");

    let visibleMenuItemIds = getVisibleChildrenIds(menu);
    Assert.deepEqual(
      visibleMenuItemIds.slice(0, EXPECTED_EXTENSION_MENU_IDS.length),
      EXPECTED_EXTENSION_MENU_IDS,
      "Expected extension menu items at the start."
    );

    checkIsLinkMenuItemVisible(visibleMenuItemIds);

    is(
      visibleMenuItemIds[visibleMenuItemIds.length - 1],
      OTHER_EXTENSION_MENU_ID,
      "Other extension menu item should be at the end."
    );

    let menuItems = menu.getElementsByAttribute("label", "tab_2");
    await closeExtensionContextMenu(menuItems[0]);
    await extension.awaitMessage("onClicked_tab_2");
  }

  {
    // Tests that previous overrideContext call has been forgotten,
    // so the default behavior should occur (=move items into submenu).
    info(
      "Expecting the default menu to be used when overrideContext is not called."
    );
    let menu = await openContextMenu("a");
    await extension.awaitMessage("onShown");

    checkIsLinkMenuItemVisible(getVisibleChildrenIds(menu));

    let menuItems = menu.getElementsByAttribute("ext-type", "top-level-menu");
    is(menuItems.length, 1, "Expected top-level menu element for extension.");
    let topLevelExtensionMenuItem = menuItems[0];
    is(
      topLevelExtensionMenuItem.nextSibling,
      null,
      "Extension menu should be the last element."
    );

    const submenu = await openSubmenu(topLevelExtensionMenuItem);
    is(submenu, topLevelExtensionMenuItem.menupopup, "Correct submenu opened");

    Assert.deepEqual(
      getVisibleChildrenIds(submenu),
      EXPECTED_EXTENSION_MENU_IDS,
      "Extension menu items should be in the submenu by default."
    );

    await closeContextMenu();
  }

  {
    // Tests that overrideContext({}) can be used from a listener inside shadow DOM.
    let menu = await openContextMenu(
      () => this.document.getElementById("shadowHost").shadowRoot.firstChild
    );
    await extension.awaitMessage("oncontextmenu_in_shadow_dom");
    await extension.awaitMessage("onShown");

    Assert.deepEqual(
      getVisibleChildrenIds(menu),
      EXPECTED_EXTENSION_MENU_IDS,
      "Expected only extension menu items after overrideContext({}) in shadow DOM"
    );

    await closeContextMenu();
  }

  {
    // Tests overrideContext({showDefaults:true}) on a non-link
    info(
      "Expecting overrideContext to insert items after the navigation group."
    );
    let menu = await openContextMenu("p");
    await extension.awaitMessage("onShown");

    let visibleMenuItemIds = getVisibleChildrenIds(menu);
    if (AppConstants.platform == "macosx") {
      // On mac, the items should be at the top:
      Assert.deepEqual(
        visibleMenuItemIds.slice(0, EXPECTED_EXTENSION_MENU_IDS_NOLINK.length),
        EXPECTED_EXTENSION_MENU_IDS_NOLINK,
        "Expected extension menu items at the start."
      );
    } else {
      // Elsewhere, they should be immediately after the navigation group:
      Assert.deepEqual(
        visibleMenuItemIds.slice(
          0,
          2 + EXPECTED_EXTENSION_MENU_IDS_NOLINK.length
        ),
        [
          "context-navigation",
          "menuseparator",
          ...EXPECTED_EXTENSION_MENU_IDS_NOLINK,
        ],
        "Expected extension menu items immmediately after navigation items."
      );
    }
    ok(
      visibleMenuItemIds.includes("context-savepage"),
      "Default menu items should be there."
    );

    is(
      visibleMenuItemIds[visibleMenuItemIds.length - 1],
      OTHER_EXTENSION_MENU_ID,
      "Other extension menu item should be at the end."
    );

    await closeContextMenu();
  }

  // Unloading the extension will automatically close the extension's tab.html
  await extension.unload();
  await otherExtension.unload();
});

// Tests some edge cases:
// - overrideContext() is called without any menu registrations,
//   followed by a menu registration + menus.refresh..
// - overrideContext() is called and event.preventDefault() is also
//   called to stop the menu from appearing.
// - Open menu again and verify that the default menu behavior occurs.
add_task(async function overrideContext_sidebar_edge_cases() {
  function sidebarJs() {
    const TIME_BEFORE_MENU_SHOWN = Date.now();
    let count = 0;
    // eslint-disable-next-line mozilla/balanced-listeners
    document.addEventListener("contextmenu", event => {
      ++count;
      if (count === 1) {
        browser.menus.overrideContext({});
      } else if (count === 2) {
        browser.menus.overrideContext({});
        event.preventDefault(); // Prevent menu from being shown.

        // We are not expecting a menu. Wait for the time it took to show and
        // hide the previous menu, to check that no new menu appears.
        // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
        setTimeout(() => {
          browser.test.sendMessage(
            "stop_waiting_for_menu_shown",
            "timer_reached"
          );
        }, Date.now() - TIME_BEFORE_MENU_SHOWN);
      } else if (count === 3) {
        // The overrideContext from the previous call should be forgotten.
        // Use the default behavior, i.e. show the default menu.
      } else {
        browser.test.fail(`Unexpected menu count: ${count}`);
      }

      browser.test.sendMessage("oncontextmenu_in_dom");
    });

    browser.menus.onShown.addListener(info => {
      browser.test.assertEq("sidebar", info.viewType, "Expected viewType");
      if (count === 1) {
        browser.test.assertEq("", info.menuIds.join(","), "Expected no items");
        browser.menus.create({ id: "some_item", title: "some_item" }, () => {
          browser.test.sendMessage("onShown_1_and_menu_item_created");
        });
      } else if (count === 2) {
        browser.test.fail(
          "onShown should not have fired when the menu is not shown."
        );
      } else if (count === 3) {
        browser.test.assertEq(
          "some_item",
          info.menuIds.join(","),
          "Expected menu item"
        );
        browser.test.sendMessage("onShown_3");
      } else {
        browser.test.fail(`Unexpected onShown at count: ${count}`);
      }
    });

    browser.test.onMessage.addListener(async msg => {
      browser.test.assertEq("refresh_menus", msg, "Expected message");
      browser.test.assertEq(1, count, "Expected at first menu test");
      await browser.menus.refresh();
      browser.test.sendMessage("menus_refreshed");
    });

    browser.menus.onHidden.addListener(() => {
      browser.test.sendMessage("onHidden", count);
    });

    browser.test.sendMessage("sidebar_ready");
  }
  let extension = ExtensionTestUtils.loadExtension({
    useAddonManager: "temporary", // To automatically show sidebar on load.
    manifest: {
      permissions: ["menus", "menus.overrideContext"],
      sidebar_action: {
        default_panel: "sidebar.html",
      },
    },
    files: {
      "sidebar.html": `
        <!DOCTYPE html><meta charset="utf-8">
        <a href="http://example.com/">Link</a>
        <script src="sidebar.js"></script>
      `,
      "sidebar.js": sidebarJs,
    },
    background() {
      browser.test.assertThrows(
        () => {
          browser.menus.overrideContext({ someInvalidParameter: true });
        },
        /Unexpected property "someInvalidParameter"/,
        "overrideContext should be available and the parameters be validated."
      );
      browser.test.sendMessage("bg_test_done");
    },
  });

  await extension.startup();
  await extension.awaitMessage("bg_test_done");
  await extension.awaitMessage("sidebar_ready");

  const EXPECTED_EXTENSION_MENU_ID = `${makeWidgetId(
    extension.id
  )}-menuitem-_some_item`;

  {
    // Checks that a menu can initially be empty and be updated.
    info(
      "Expecting menu without items to appear and be updated after menus.refresh()"
    );
    let menu = await openContextMenuInSidebar("a");
    await extension.awaitMessage("oncontextmenu_in_dom");
    await extension.awaitMessage("onShown_1_and_menu_item_created");
    Assert.deepEqual(
      getVisibleChildrenIds(menu),
      [],
      "Expected no items, initially"
    );
    extension.sendMessage("refresh_menus");
    await extension.awaitMessage("menus_refreshed");
    Assert.deepEqual(
      getVisibleChildrenIds(menu),
      [EXPECTED_EXTENSION_MENU_ID],
      "Expected updated menu"
    );
    await closeContextMenu(menu);
    is(await extension.awaitMessage("onHidden"), 1, "Menu hidden");
  }

  {
    // Trigger a context menu. The page has prevented the menu from being
    // shown, so the promise should not resolve.
    info("Expecting menu to not appear because of event.preventDefault()");
    let popupShowingPromise = openContextMenuInSidebar("a");
    await extension.awaitMessage("oncontextmenu_in_dom");
    is(
      await Promise.race([
        extension.awaitMessage("stop_waiting_for_menu_shown"),
        popupShowingPromise.then(() => "popup_shown"),
      ]),
      "timer_reached",
      "The menu should not be shown."
    );
  }

  {
    info(
      "Expecting default menu to be shown when the menu is reopened after event.preventDefault()"
    );
    let menu = await openContextMenuInSidebar("a");
    await extension.awaitMessage("oncontextmenu_in_dom");
    await extension.awaitMessage("onShown_3");
    let visibleMenuItemIds = getVisibleChildrenIds(menu);
    checkIsLinkMenuItemVisible(visibleMenuItemIds);
    ok(
      visibleMenuItemIds.includes(EXPECTED_EXTENSION_MENU_ID),
      "Expected extension menu item"
    );
    await closeContextMenu(menu);
    is(await extension.awaitMessage("onHidden"), 3, "Menu hidden");
  }

  await extension.unload();
});
