var { PermissionTestUtils } = ChromeUtils.importESModule(
  "resource://testing-common/PermissionTestUtils.sys.mjs"
);

const PREF_PERMISSION_FAKE = "media.navigator.permission.fake";
const PREF_AUDIO_LOOPBACK = "media.audio_loopback_dev";
const PREF_VIDEO_LOOPBACK = "media.video_loopback_dev";
const PREF_FAKE_STREAMS = "media.navigator.streams.fake";
const PREF_FOCUS_SOURCE = "media.getusermedia.window.focus_source.enabled";

const STATE_CAPTURE_ENABLED = Ci.nsIMediaManagerService.STATE_CAPTURE_ENABLED;
const STATE_CAPTURE_DISABLED = Ci.nsIMediaManagerService.STATE_CAPTURE_DISABLED;

const ALLOW_SILENCING_NOTIFICATIONS = Services.prefs.getBoolPref(
  "privacy.webrtc.allowSilencingNotifications",
  false
);

const SHOW_GLOBAL_MUTE_TOGGLES = Services.prefs.getBoolPref(
  "privacy.webrtc.globalMuteToggles",
  false
);

const SHOW_ALWAYS_ASK = Services.prefs.getBoolPref(
  "permissions.media.show_always_ask.enabled",
  false
);

let IsIndicatorDisabled =
  AppConstants.isPlatformAndVersionAtLeast("macosx", 14.0) &&
  !Services.prefs.getBoolPref(
    "privacy.webrtc.showIndicatorsOnMacos14AndAbove",
    false
  );

const INDICATOR_PATH = "chrome://browser/content/webrtcIndicator.xhtml";

const IS_MAC = AppConstants.platform == "macosx";

const SHARE_SCREEN = 1;
const SHARE_WINDOW = 2;

let observerTopics = [
  "getUserMedia:response:allow",
  "getUserMedia:revoke",
  "getUserMedia:response:deny",
  "getUserMedia:request",
  "recording-device-events",
  "recording-window-ended",
];

// Structured hierarchy of subframes. Keys are frame id:s, The children member
// contains nested sub frames if any. The noTest member make a frame be ignored
// for testing if true.
let gObserveSubFrames = {};
// Object of subframes to test. Each element contains the members bc and id, for
// the frames BrowsingContext and id, respectively.
let gSubFramesToTest = [];
let gBrowserContextsToObserve = [];

function whenDelayedStartupFinished(aWindow) {
  return TestUtils.topicObserved(
    "browser-delayed-startup-finished",
    subject => subject == aWindow
  );
}

function promiseIndicatorWindow() {
  let startTime = ChromeUtils.now();

  return new Promise(resolve => {
    Services.obs.addObserver(function obs(win) {
      win.addEventListener(
        "load",
        function () {
          if (win.location.href !== INDICATOR_PATH) {
            info("ignoring a window with this url: " + win.location.href);
            return;
          }

          Services.obs.removeObserver(obs, "domwindowopened");
          executeSoon(() => {
            ChromeUtils.addProfilerMarker("promiseIndicatorWindow", {
              startTime,
              category: "Test",
            });
            resolve(win);
          });
        },
        { once: true }
      );
    }, "domwindowopened");
  });
}

async function assertWebRTCIndicatorStatus(expected) {
  let ui = ChromeUtils.importESModule(
    "resource:///modules/webrtcUI.sys.mjs"
  ).webrtcUI;
  let expectedState = expected ? "visible" : "hidden";
  let msg = "WebRTC indicator " + expectedState;
  if (!expected && ui.showGlobalIndicator) {
    // It seems the global indicator is not always removed synchronously
    // in some cases.
    await TestUtils.waitForCondition(
      () => !ui.showGlobalIndicator,
      "waiting for the global indicator to be hidden"
    );
  }
  is(ui.showGlobalIndicator, !!expected, msg);

  let expectVideo = false,
    expectAudio = false,
    expectScreen = "";
  if (expected && !IsIndicatorDisabled) {
    if (expected.video) {
      expectVideo = true;
    }
    if (expected.audio) {
      expectAudio = true;
    }
    if (expected.screen) {
      expectScreen = expected.screen;
    }
  }
  is(
    Boolean(ui.showCameraIndicator),
    expectVideo,
    "camera global indicator as expected"
  );
  is(
    Boolean(ui.showMicrophoneIndicator),
    expectAudio,
    "microphone global indicator as expected"
  );
  is(
    ui.showScreenSharingIndicator,
    expectScreen,
    "screen global indicator as expected"
  );

  for (let win of Services.wm.getEnumerator("navigator:browser")) {
    let menu = win.document.getElementById("tabSharingMenu");
    is(
      !!menu && !menu.hidden,
      !!expected,
      "WebRTC menu should be " + expectedState
    );
  }

  if (!expected) {
    let win = Services.wm.getMostRecentWindow("Browser:WebRTCGlobalIndicator");
    if (win) {
      await new Promise(resolve => {
        win.addEventListener("unload", function listener(e) {
          if (e.target == win.document) {
            win.removeEventListener("unload", listener);
            executeSoon(resolve);
          }
        });
      });
    }
  }

  let indicator = Services.wm.getEnumerator("Browser:WebRTCGlobalIndicator");
  let hasWindow = indicator.hasMoreElements();
  is(hasWindow, !!expected, "popup " + msg);
  if (hasWindow) {
    let document = indicator.getNext().document;
    let docElt = document.documentElement;

    if (document.readyState != "complete") {
      info("Waiting for the sharing indicator's document to load");
      await new Promise(resolve => {
        document.addEventListener(
          "readystatechange",
          function onReadyStateChange() {
            if (document.readyState != "complete") {
              return;
            }
            document.removeEventListener(
              "readystatechange",
              onReadyStateChange
            );
            executeSoon(resolve);
          }
        );
      });
    }

    if (expected.screen && expected.screen.startsWith("Window")) {
      // These tests were originally written to express window sharing by
      // having expected.screen start with "Window". This meant that the
      // legacy indicator is expected to have the "sharingscreen" attribute
      // set to true when sharing a window.
      //
      // The new indicator, however, differentiates between screen, window
      // and browser window sharing. If we're using the new indicator, we
      // update the expectations accordingly. This can be removed once we
      // are able to remove the tests for the legacy indicator.
      expected.screen = null;
      expected.window = true;
    }

    if (!SHOW_GLOBAL_MUTE_TOGGLES) {
      expected.video = false;
      expected.audio = false;

      let visible = docElt.getAttribute("visible") == "true";

      if (!expected.screen && !expected.window && !expected.browserwindow) {
        ok(!visible, "Indicator should not be visible in this configuation.");
      } else {
        ok(visible, "Indicator should be visible.");
      }
    }

    for (let item of ["video", "audio", "screen", "window", "browserwindow"]) {
      let expectedValue;

      expectedValue = expected && expected[item] ? "true" : null;

      is(
        docElt.getAttribute("sharing" + item),
        expectedValue,
        item + " global indicator attribute as expected"
      );
    }

    ok(!indicator.hasMoreElements(), "only one global indicator window");
  }
}

function promiseNotificationShown(notification) {
  let win = notification.browser.ownerGlobal;
  if (win.PopupNotifications.panel.state == "open") {
    return Promise.resolve();
  }
  let panelPromise = BrowserTestUtils.waitForPopupEvent(
    win.PopupNotifications.panel,
    "shown"
  );
  notification.reshow();
  return panelPromise;
}

function ignoreEvent(aSubject, aTopic, aData) {
  // With e10s disabled, our content script receives notifications for the
  // preview displayed in our screen sharing permission prompt; ignore them.
  const kBrowserURL = AppConstants.BROWSER_CHROME_URL;
  const nsIPropertyBag = Ci.nsIPropertyBag;
  if (
    aTopic == "recording-device-events" &&
    aSubject.QueryInterface(nsIPropertyBag).getProperty("requestURL") ==
      kBrowserURL
  ) {
    return true;
  }
  if (aTopic == "recording-window-ended") {
    let win = Services.wm.getOuterWindowWithId(aData).top;
    if (win.document.documentURI == kBrowserURL) {
      return true;
    }
  }
  return false;
}

function expectObserverCalledInProcess(aTopic, aCount = 1) {
  let promises = [];
  for (let count = aCount; count > 0; count--) {
    promises.push(TestUtils.topicObserved(aTopic, ignoreEvent));
  }
  return promises;
}

function expectObserverCalled(
  aTopic,
  aCount = 1,
  browser = gBrowser.selectedBrowser
) {
  if (!gMultiProcessBrowser) {
    return expectObserverCalledInProcess(aTopic, aCount);
  }

  let browsingContext = Element.isInstance(browser)
    ? browser.browsingContext
    : browser;

  return BrowserTestUtils.contentTopicObserved(browsingContext, aTopic, aCount);
}

// This is a special version of expectObserverCalled that should only
// be used when expecting a notification upon closing a window. It uses
// the per-process message manager instead of actors to send the
// notifications.
function expectObserverCalledOnClose(
  aTopic,
  aCount = 1,
  browser = gBrowser.selectedBrowser
) {
  if (!gMultiProcessBrowser) {
    return expectObserverCalledInProcess(aTopic, aCount);
  }

  let browsingContext = Element.isInstance(browser)
    ? browser.browsingContext
    : browser;

  return new Promise(resolve => {
    BrowserTestUtils.sendAsyncMessage(
      browsingContext,
      "BrowserTestUtils:ObserveTopic",
      {
        topic: aTopic,
        count: 1,
        filterFunctionSource: ((subject, topic) => {
          Services.cpmm.sendAsyncMessage("WebRTCTest:ObserverCalled", {
            topic,
          });
          return true;
        }).toSource(),
      }
    );

    function observerCalled(message) {
      if (message.data.topic == aTopic) {
        Services.ppmm.removeMessageListener(
          "WebRTCTest:ObserverCalled",
          observerCalled
        );
        resolve();
      }
    }
    Services.ppmm.addMessageListener(
      "WebRTCTest:ObserverCalled",
      observerCalled
    );
  });
}

function promiseMessage(
  aMessage,
  aAction,
  aCount = 1,
  browser = gBrowser.selectedBrowser
) {
  let startTime = ChromeUtils.now();
  let promise = ContentTask.spawn(
    browser,
    [aMessage, aCount],
    async function ([expectedMessage, expectedCount]) {
      return new Promise(resolve => {
        function listenForMessage({ data }) {
          if (
            (!expectedMessage || data == expectedMessage) &&
            --expectedCount == 0
          ) {
            content.removeEventListener("message", listenForMessage);
            resolve(data);
          }
        }
        content.addEventListener("message", listenForMessage);
      });
    }
  );
  if (aAction) {
    aAction();
  }
  return promise.then(data => {
    ChromeUtils.addProfilerMarker(
      "promiseMessage",
      { startTime, category: "Test" },
      data
    );
    return data;
  });
}

function promisePopupNotificationShown(aName, aAction, aWindow = window) {
  let startTime = ChromeUtils.now();
  return new Promise(resolve => {
    aWindow.PopupNotifications.panel.addEventListener(
      "popupshown",
      function () {
        ok(
          !!aWindow.PopupNotifications.getNotification(aName),
          aName + " notification shown"
        );
        ok(aWindow.PopupNotifications.isPanelOpen, "notification panel open");
        ok(
          !!aWindow.PopupNotifications.panel.firstElementChild,
          "notification panel populated"
        );

        executeSoon(() => {
          ChromeUtils.addProfilerMarker(
            "promisePopupNotificationShown",
            { startTime, category: "Test" },
            aName
          );
          resolve();
        });
      },
      { once: true }
    );

    if (aAction) {
      aAction();
    }
  });
}

async function promisePopupNotification(aName) {
  return TestUtils.waitForCondition(
    () => PopupNotifications.getNotification(aName),
    aName + " notification appeared"
  );
}

async function promiseNoPopupNotification(aName) {
  return TestUtils.waitForCondition(
    () => !PopupNotifications.getNotification(aName),
    aName + " notification removed"
  );
}

const kActionAlways = 1;
const kActionDeny = 2;
const kActionNever = 3;

async function activateSecondaryAction(aAction) {
  let notification = PopupNotifications.panel.firstElementChild;
  switch (aAction) {
    case kActionNever:
      if (notification.notification.secondaryActions.length > 1) {
        // "Always Block" is the first (and only) item in the menupopup.
        await Promise.all([
          BrowserTestUtils.waitForEvent(notification.menupopup, "popupshown"),
          notification.menubutton.click(),
        ]);
        notification.menupopup.querySelector("menuitem").click();
        return;
      }
      if (!notification.checkbox.checked) {
        notification.checkbox.click();
      }
    // fallthrough
    case kActionDeny:
      notification.secondaryButton.click();
      break;
    case kActionAlways:
      if (!notification.checkbox.checked) {
        notification.checkbox.click();
      }
      notification.button.click();
      break;
  }
}

async function getMediaCaptureState() {
  let startTime = ChromeUtils.now();

  function gatherBrowsingContexts(aBrowsingContext) {
    let list = [aBrowsingContext];

    let children = aBrowsingContext.children;
    for (let child of children) {
      list.push(...gatherBrowsingContexts(child));
    }

    return list;
  }

  function combine(x, y) {
    if (
      x == Ci.nsIMediaManagerService.STATE_CAPTURE_ENABLED ||
      y == Ci.nsIMediaManagerService.STATE_CAPTURE_ENABLED
    ) {
      return Ci.nsIMediaManagerService.STATE_CAPTURE_ENABLED;
    }
    if (
      x == Ci.nsIMediaManagerService.STATE_CAPTURE_DISABLED ||
      y == Ci.nsIMediaManagerService.STATE_CAPTURE_DISABLED
    ) {
      return Ci.nsIMediaManagerService.STATE_CAPTURE_DISABLED;
    }
    return Ci.nsIMediaManagerService.STATE_NOCAPTURE;
  }

  let video = Ci.nsIMediaManagerService.STATE_NOCAPTURE;
  let audio = Ci.nsIMediaManagerService.STATE_NOCAPTURE;
  let screen = Ci.nsIMediaManagerService.STATE_NOCAPTURE;
  let window = Ci.nsIMediaManagerService.STATE_NOCAPTURE;
  let browser = Ci.nsIMediaManagerService.STATE_NOCAPTURE;

  for (let bc of gatherBrowsingContexts(
    gBrowser.selectedBrowser.browsingContext
  )) {
    let state = await SpecialPowers.spawn(bc, [], async function () {
      let mediaManagerService = Cc[
        "@mozilla.org/mediaManagerService;1"
      ].getService(Ci.nsIMediaManagerService);

      let hasCamera = {};
      let hasMicrophone = {};
      let hasScreenShare = {};
      let hasWindowShare = {};
      let hasBrowserShare = {};
      let devices = {};
      mediaManagerService.mediaCaptureWindowState(
        content,
        hasCamera,
        hasMicrophone,
        hasScreenShare,
        hasWindowShare,
        hasBrowserShare,
        devices,
        false
      );

      return {
        video: hasCamera.value,
        audio: hasMicrophone.value,
        screen: hasScreenShare.value,
        window: hasWindowShare.value,
        browser: hasBrowserShare.value,
      };
    });

    video = combine(state.video, video);
    audio = combine(state.audio, audio);
    screen = combine(state.screen, screen);
    window = combine(state.window, window);
    browser = combine(state.browser, browser);
  }

  let result = {};

  if (video != Ci.nsIMediaManagerService.STATE_NOCAPTURE) {
    result.video = true;
  }
  if (audio != Ci.nsIMediaManagerService.STATE_NOCAPTURE) {
    result.audio = true;
  }

  if (screen != Ci.nsIMediaManagerService.STATE_NOCAPTURE) {
    result.screen = "Screen";
  } else if (window != Ci.nsIMediaManagerService.STATE_NOCAPTURE) {
    result.window = true;
  } else if (browser != Ci.nsIMediaManagerService.STATE_NOCAPTURE) {
    result.browserwindow = true;
  }

  ChromeUtils.addProfilerMarker("getMediaCaptureState", {
    startTime,
    category: "Test",
  });
  return result;
}

async function stopSharing(
  aType = "camera",
  aShouldKeepSharing = false,
  aFrameBC,
  aWindow = window
) {
  let promiseRecordingEvent = expectObserverCalled(
    "recording-device-events",
    1,
    aFrameBC
  );
  let observerPromise1 = expectObserverCalled(
    "getUserMedia:revoke",
    1,
    aFrameBC
  );

  // If we are stopping screen sharing and expect to still have another stream,
  // "recording-window-ended" won't be fired.
  let observerPromise2 = null;
  if (!aShouldKeepSharing) {
    observerPromise2 = expectObserverCalled(
      "recording-window-ended",
      1,
      aFrameBC
    );
  }

  await revokePermission(aType, aShouldKeepSharing, aFrameBC, aWindow);
  await promiseRecordingEvent;
  await observerPromise1;
  await observerPromise2;

  if (!aShouldKeepSharing) {
    await checkNotSharing();
  }
}

async function revokePermission(
  aType = "camera",
  aShouldKeepSharing = false,
  aFrameBC,
  aWindow = window
) {
  aWindow.gPermissionPanel._identityPermissionBox.click();
  let popup = aWindow.gPermissionPanel._permissionPopup;
  // If the popup gets hidden before being shown, by stray focus/activate
  // events, don't bother failing the test. It's enough to know that we
  // started showing the popup.
  let hiddenEvent = BrowserTestUtils.waitForEvent(popup, "popuphidden");
  let shownEvent = BrowserTestUtils.waitForEvent(popup, "popupshown");
  await Promise.race([hiddenEvent, shownEvent]);
  let doc = aWindow.document;
  let permissions = doc.getElementById("permission-popup-permission-list");
  let cancelButton = permissions.querySelector(
    ".permission-popup-permission-icon." +
      aType +
      "-icon ~ " +
      ".permission-popup-permission-remove-button"
  );

  cancelButton.click();
  popup.hidePopup();

  if (!aShouldKeepSharing) {
    await checkNotSharing();
  }
}

function getBrowsingContextForFrame(aBrowsingContext, aFrameId) {
  if (!aFrameId) {
    return aBrowsingContext;
  }

  return SpecialPowers.spawn(aBrowsingContext, [aFrameId], frameId => {
    return content.document.getElementById(frameId).browsingContext;
  });
}

async function getBrowsingContextsAndFrameIdsForSubFrames(
  aBrowsingContext,
  aSubFrames
) {
  let pendingBrowserSubFrames = [
    { bc: aBrowsingContext, subFrames: aSubFrames },
  ];
  let browsingContextsAndFrames = [];
  while (pendingBrowserSubFrames.length) {
    let { bc, subFrames } = pendingBrowserSubFrames.shift();
    for (let id of Object.keys(subFrames)) {
      let subBc = await getBrowsingContextForFrame(bc, id);
      if (subFrames[id].children) {
        pendingBrowserSubFrames.push({
          bc: subBc,
          subFrames: subFrames[id].children,
        });
      }
      if (subFrames[id].noTest) {
        continue;
      }
      let observeBC = subFrames[id].observe ? subBc : undefined;
      browsingContextsAndFrames.push({ bc: subBc, id, observeBC });
    }
  }
  return browsingContextsAndFrames;
}

/**
 * Test helper for getUserMedia calls.
 *
 * @param {boolean} aRequestAudio - Whether to request audio
 * @param {boolean} aRequestVideo - Whether to request video
 * @param {string} aFrameId - The ID of the frame
 * @param {string} aType - The type of screen sharing.
 * @param {BrowsingContext} aBrowsingContext - The browsing context
 * @param {boolean} [aBadDevice=false] - Whether to use a bad device
 * @param {boolean} [viaButtonClick=false] - Whether to call gUM directly or to
 *   request via simulated button click.
 * @returns {Promise} - Resolves when the gUM request has been made.
 */
async function promiseRequestDevice(
  aRequestAudio,
  aRequestVideo,
  aFrameId,
  aType,
  aBrowsingContext,
  aBadDevice = false,
  viaButtonClick = false
) {
  info("requesting devices");
  let bc =
    aBrowsingContext ??
    (await getBrowsingContextForFrame(gBrowser.selectedBrowser, aFrameId));

  if (viaButtonClick) {
    return SpecialPowers.spawn(
      bc,
      [{ aRequestAudio, aRequestVideo, aType, aBadDevice }],
      async function (args) {
        let global = content.wrappedJSObject;
        global.queueRequestDeviceViaBtn(
          args.aRequestAudio,
          args.aRequestVideo,
          args.aType,
          args.aBadDevice
        );
        await EventUtils.synthesizeMouseAtCenter(
          global.document.getElementById("gum"),
          {},
          content
        );
      }
    );
  }

  return SpecialPowers.spawn(
    bc,
    [{ aRequestAudio, aRequestVideo, aType, aBadDevice }],
    async function (args) {
      let global = content.wrappedJSObject;
      global.requestDevice(
        args.aRequestAudio,
        args.aRequestVideo,
        args.aType,
        args.aBadDevice,
        args.withUserActivation
      );
    }
  );
}

async function promiseRequestAudioOutput(options) {
  info("requesting audio output");
  const bc = gBrowser.selectedBrowser;
  return SpecialPowers.spawn(bc, [options], async function (opts) {
    const global = content.wrappedJSObject;
    global.requestAudioOutput(Cu.cloneInto(opts, content));
  });
}

async function stopTracks(
  aKind,
  aAlreadyStopped,
  aLastTracks,
  aFrameId,
  aBrowsingContext,
  aBrowsingContextToObserve
) {
  // If the observers are listening to other frames, listen for a notification
  // on the right subframe.
  let frameBC =
    aBrowsingContext ??
    (await getBrowsingContextForFrame(
      gBrowser.selectedBrowser.browsingContext,
      aFrameId
    ));

  let observerPromises = [];
  if (!aAlreadyStopped) {
    observerPromises.push(
      expectObserverCalled(
        "recording-device-events",
        1,
        aBrowsingContextToObserve
      )
    );
  }
  if (aLastTracks) {
    observerPromises.push(
      expectObserverCalled(
        "recording-window-ended",
        1,
        aBrowsingContextToObserve
      )
    );
  }

  info(`Stopping all ${aKind} tracks`);
  await SpecialPowers.spawn(frameBC, [aKind], async function (kind) {
    content.wrappedJSObject.stopTracks(kind);
  });

  await Promise.all(observerPromises);
}

async function closeStream(
  aAlreadyClosed,
  aFrameId,
  aDontFlushObserverVerification,
  aBrowsingContext,
  aBrowsingContextToObserve
) {
  // Check that spurious notifications that occur while closing the
  // stream are handled separately. Tests that use skipObserverVerification
  // should pass true for aDontFlushObserverVerification.
  if (!aDontFlushObserverVerification) {
    await disableObserverVerification();
    await enableObserverVerification();
  }

  // If the observers are listening to other frames, listen for a notification
  // on the right subframe.
  let frameBC =
    aBrowsingContext ??
    (await getBrowsingContextForFrame(
      gBrowser.selectedBrowser.browsingContext,
      aFrameId
    ));

  let observerPromises = [];
  if (!aAlreadyClosed) {
    observerPromises.push(
      expectObserverCalled(
        "recording-device-events",
        1,
        aBrowsingContextToObserve
      )
    );
    observerPromises.push(
      expectObserverCalled(
        "recording-window-ended",
        1,
        aBrowsingContextToObserve
      )
    );
  }

  info("closing the stream");
  await SpecialPowers.spawn(frameBC, [], async function () {
    content.wrappedJSObject.closeStream();
  });

  await Promise.all(observerPromises);

  await assertWebRTCIndicatorStatus(null);
}

async function reloadAsUser() {
  info("reloading as a user");

  const reloadButton = document.getElementById("reload-button");
  await TestUtils.waitForCondition(() => !reloadButton.disabled);
  // Disable observers as the page is being reloaded which can destroy
  // the actors listening to the notifications.
  await disableObserverVerification();

  let loadedPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
  reloadButton.click();
  await loadedPromise;

  await enableObserverVerification();
}

async function reloadFromContent() {
  info("reloading from content");

  // Disable observers as the page is being reloaded which can destroy
  // the actors listening to the notifications.
  await disableObserverVerification();

  let loadedPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
  await ContentTask.spawn(gBrowser.selectedBrowser, null, () =>
    content.location.reload()
  );

  await loadedPromise;

  await enableObserverVerification();
}

async function reloadAndAssertClosedStreams() {
  await reloadFromContent();
  await checkNotSharing();
}

/**
 * @param {("microphone"|"camera"|"screen")[]} aExpectedTypes
 * @param {Window} [aWindow]
 */
function checkDeviceSelectors(aExpectedTypes, aWindow = window) {
  for (const type of aExpectedTypes) {
    if (!["microphone", "camera", "screen", "speaker"].includes(type)) {
      throw new Error(`Bad device type name ${type}`);
    }
  }
  let document = aWindow.document;

  let expectedDescribedBy = "webRTC-shareDevices-notification-description";
  for (let type of ["Camera", "Microphone", "Speaker"]) {
    let selector = document.getElementById(`webRTC-select${type}`);
    if (!aExpectedTypes.includes(type.toLowerCase())) {
      ok(selector.hidden, `${type} selector hidden`);
      continue;
    }
    ok(!selector.hidden, `${type} selector visible`);
    let tagName = type == "Speaker" ? "richlistbox" : "menulist";
    let selectorList = document.getElementById(
      `webRTC-select${type}-${tagName}`
    );
    let label = document.getElementById(
      `webRTC-select${type}-single-device-label`
    );
    // If there's only 1 device listed, then we should show the label
    // instead of the menulist.
    if (selectorList.itemCount == 1) {
      ok(selectorList.hidden, `${type} selector list should be hidden.`);
      ok(!label.hidden, `${type} selector label should not be hidden.`);
      let itemLabel =
        tagName == "richlistbox"
          ? selectorList.selectedItem.firstElementChild.getAttribute("value")
          : selectorList.selectedItem.getAttribute("label");
      is(
        label.value,
        itemLabel,
        `${type} label should be showing the lone device label.`
      );
      expectedDescribedBy += ` webRTC-select${type}-icon webRTC-select${type}-single-device-label`;
    } else {
      ok(!selectorList.hidden, `${type} selector list should not be hidden.`);
      ok(label.hidden, `${type} selector label should be hidden.`);
    }
  }
  let ariaDescribedby =
    aWindow.PopupNotifications.panel.getAttribute("aria-describedby");
  is(ariaDescribedby, expectedDescribedBy, "aria-describedby");

  let screenSelector = document.getElementById("webRTC-selectWindowOrScreen");
  if (aExpectedTypes.includes("screen")) {
    ok(!screenSelector.hidden, "screen selector visible");
  } else {
    ok(screenSelector.hidden, "screen selector hidden");
  }
}

/**
 * Tests the siteIdentity icons, the permission panel and the global indicator
 * UI state.
 *
 * @param {object} aExpected - Expected state for the current tab.
 * @param {window} [aWin] - Top level chrome window to test state of.
 * @param {object} [aExpectedGlobal] - Expected state for all tabs.
 * @param {object} [aExpectedPerm] - Expected permission states keyed by device
 * type.
 */
async function checkSharingUI(
  aExpected,
  aWin = window,
  aExpectedGlobal = null,
  aExpectedPerm = null
) {
  function isPaused(streamState) {
    if (typeof streamState == "string") {
      return streamState.includes("Paused");
    }
    return streamState == STATE_CAPTURE_DISABLED;
  }

  let doc = aWin.document;
  // First check the icon above the control center (i) icon.
  let permissionBox = doc.getElementById("identity-permission-box");
  let webrtcSharingIcon = doc.getElementById("webrtc-sharing-icon");
  let expectOn = aExpected.audio || aExpected.video || aExpected.screen;
  if (expectOn) {
    ok(webrtcSharingIcon.hasAttribute("sharing"), "sharing attribute is set");
  } else {
    ok(
      !webrtcSharingIcon.hasAttribute("sharing"),
      "sharing attribute is not set"
    );
  }
  let sharing = webrtcSharingIcon.getAttribute("sharing");
  if (!IsIndicatorDisabled) {
    if (aExpected.screen) {
      is(sharing, "screen", "showing screen icon in the identity block");
    } else if (aExpected.video == STATE_CAPTURE_ENABLED) {
      is(sharing, "camera", "showing camera icon in the identity block");
    } else if (aExpected.audio == STATE_CAPTURE_ENABLED) {
      is(sharing, "microphone", "showing mic icon in the identity block");
    } else if (aExpected.video) {
      is(sharing, "camera", "showing camera icon in the identity block");
    } else if (aExpected.audio) {
      is(sharing, "microphone", "showing mic icon in the identity block");
    }
  }

  let allStreamsPaused = Object.values(aExpected).every(isPaused);
  is(
    webrtcSharingIcon.hasAttribute("paused"),
    allStreamsPaused,
    "sharing icon(s) should be in paused state when paused"
  );

  // Then check the sharing indicators inside the permission popup.
  permissionBox.click();
  let popup = aWin.gPermissionPanel._permissionPopup;
  // If the popup gets hidden before being shown, by stray focus/activate
  // events, don't bother failing the test. It's enough to know that we
  // started showing the popup.
  let hiddenEvent = BrowserTestUtils.waitForEvent(popup, "popuphidden");
  let shownEvent = BrowserTestUtils.waitForEvent(popup, "popupshown");
  await Promise.race([hiddenEvent, shownEvent]);
  let permissions = doc.getElementById("permission-popup-permission-list");
  for (let id of ["microphone", "camera", "screen"]) {
    let convertId = idToConvert => {
      if (idToConvert == "camera") {
        return "video";
      }
      if (idToConvert == "microphone") {
        return "audio";
      }
      return idToConvert;
    };
    let expected = aExpected[convertId(id)];

    // Extract the expected permission for the device type.
    // Defaults to temporary allow.
    let { state, scope } = aExpectedPerm?.[convertId(id)] || {};
    if (state == null) {
      state = SitePermissions.ALLOW;
    }
    if (scope == null) {
      scope = SitePermissions.SCOPE_TEMPORARY;
    }

    is(
      !!aWin.gPermissionPanel._sharingState.webRTC[id],
      !!expected,
      "sharing state for " + id + " as expected"
    );
    let item = permissions.querySelectorAll(
      ".permission-popup-permission-item-" + id
    );
    let stateLabel = item?.[0]?.querySelector(
      ".permission-popup-permission-state-label"
    );
    let icon = permissions.querySelectorAll(
      ".permission-popup-permission-icon." + id + "-icon"
    );
    if (expected) {
      is(item.length, 1, "should show " + id + " item in permission panel");
      is(
        stateLabel?.textContent,
        SitePermissions.getCurrentStateLabel(state, id, scope),
        "should show correct item label for " + id
      );
      is(icon.length, 1, "should show " + id + " icon in permission panel");
      is(
        icon[0].classList.contains("in-use"),
        expected && !isPaused(expected),
        "icon should have the in-use class, unless paused"
      );
    } else if (!icon.length && !item.length && !stateLabel) {
      ok(true, "should not show " + id + " item in the permission panel");
      ok(true, "should not show " + id + " icon in the permission panel");
      ok(
        true,
        "should not show " + id + " state label in the permission panel"
      );
      if (state != SitePermissions.PROMPT || SHOW_ALWAYS_ASK) {
        isnot(
          scope,
          SitePermissions.SCOPE_PERSISTENT,
          "persistent permission not shown"
        );
      }
    } else {
      // This will happen if there are persistent permissions set.
      ok(
        !icon[0].classList.contains("in-use"),
        "if shown, the " + id + " icon should not have the in-use class"
      );
      is(item.length, 1, "should not show more than 1 " + id + " item");
      is(icon.length, 1, "should not show more than 1 " + id + " icon");

      // Note: To pass, this one needs state and/or scope passed into
      // checkSharingUI() for values other than ALLOW and SCOPE_TEMPORARY
      is(
        stateLabel?.textContent,
        SitePermissions.getCurrentStateLabel(state, id, scope),
        "should show correct item label for " + id
      );
      if (!SHOW_ALWAYS_ASK) {
        isnot(
          state,
          state == SitePermissions.PROMPT,
          "always ask permission should be hidden"
        );
      }
    }
  }
  aWin.gPermissionPanel._permissionPopup.hidePopup();
  await TestUtils.waitForCondition(
    () => permissionPopupHidden(aWin),
    "identity popup should be hidden"
  );

  // Check the global indicators.
  if (expectOn) {
    await assertWebRTCIndicatorStatus(aExpectedGlobal || aExpected);
  }
}

async function checkNotSharing() {
  Assert.deepEqual(
    await getMediaCaptureState(),
    {},
    "expected nothing to be shared"
  );

  ok(
    !document.getElementById("webrtc-sharing-icon").hasAttribute("sharing"),
    "no sharing indicator on the control center icon"
  );

  await assertWebRTCIndicatorStatus(null);
}

async function checkNotSharingWithinGracePeriod() {
  Assert.deepEqual(
    await getMediaCaptureState(),
    {},
    "expected nothing to be shared"
  );

  ok(
    document.getElementById("webrtc-sharing-icon").hasAttribute("sharing"),
    "has sharing indicator on the control center icon"
  );
  ok(
    document.getElementById("webrtc-sharing-icon").hasAttribute("paused"),
    "sharing indicator is paused"
  );

  await assertWebRTCIndicatorStatus(null);
}

async function promiseReloadFrame(aFrameId, aBrowsingContext) {
  let loadedPromise = BrowserTestUtils.browserLoaded(
    gBrowser.selectedBrowser,
    true,
    () => {
      return true;
    }
  );
  let bc =
    aBrowsingContext ??
    (await getBrowsingContextForFrame(
      gBrowser.selectedBrowser.browsingContext,
      aFrameId
    ));
  await SpecialPowers.spawn(bc, [], async function () {
    content.location.reload();
  });
  return loadedPromise;
}

function promiseChangeLocationFrame(aFrameId, aNewLocation) {
  return SpecialPowers.spawn(
    gBrowser.selectedBrowser.browsingContext,
    [{ aFrameId, aNewLocation }],
    async function (args) {
      let frame = content.wrappedJSObject.document.getElementById(
        args.aFrameId
      );
      return new Promise(resolve => {
        function listener() {
          frame.removeEventListener("load", listener, true);
          resolve();
        }
        frame.addEventListener("load", listener, true);

        content.wrappedJSObject.document.getElementById(
          args.aFrameId
        ).contentWindow.location = args.aNewLocation;
      });
    }
  );
}

async function openNewTestTab(leaf = "get_user_media.html") {
  let rootDir = getRootDirectory(gTestPath);
  rootDir = rootDir.replace(
    "chrome://mochitests/content/",
    "https://example.com/"
  );
  let absoluteURI = rootDir + leaf;

  let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, absoluteURI);
  return tab.linkedBrowser;
}

// Enabling observer verification adds listeners for all of the webrtc
// observer topics. If any notifications occur for those topics that
// were not explicitly requested, a failure will occur.
async function enableObserverVerification(browser = gBrowser.selectedBrowser) {
  // Skip these checks in single process mode as it isn't worth implementing it.
  if (!gMultiProcessBrowser) {
    return;
  }

  gBrowserContextsToObserve = [browser.browsingContext];

  // A list of subframe indicies to also add observers to. This only
  // supports one nested level.
  if (gObserveSubFrames) {
    let bcsAndFrameIds = await getBrowsingContextsAndFrameIdsForSubFrames(
      browser,
      gObserveSubFrames
    );
    for (let { observeBC } of bcsAndFrameIds) {
      if (observeBC) {
        gBrowserContextsToObserve.push(observeBC);
      }
    }
  }

  for (let bc of gBrowserContextsToObserve) {
    await BrowserTestUtils.startObservingTopics(bc, observerTopics);
  }
}

async function disableObserverVerification() {
  if (!gMultiProcessBrowser) {
    return;
  }

  for (let bc of gBrowserContextsToObserve) {
    await BrowserTestUtils.stopObservingTopics(bc, observerTopics).catch(
      reason => {
        ok(false, "Failed " + reason);
      }
    );
  }
}

function permissionPopupHidden(win = window) {
  let popup = win.gPermissionPanel._permissionPopup;
  return !popup || popup.state == "closed";
}

async function runTests(tests, options = {}) {
  let browser = await openNewTestTab(options.relativeURI);

  is(
    PopupNotifications._currentNotifications.length,
    0,
    "should start the test without any prior popup notification"
  );
  ok(
    permissionPopupHidden(),
    "should start the test with the permission panel hidden"
  );

  // Set prefs so that permissions prompts are shown and loopback devices
  // are not used. To test the chrome we want prompts to be shown, and
  // these tests are flakey when using loopback devices (though it would
  // be desirable to make them work with loopback in future). See bug 1643711.
  let prefs = [
    [PREF_PERMISSION_FAKE, true],
    [PREF_AUDIO_LOOPBACK, ""],
    [PREF_VIDEO_LOOPBACK, ""],
    [PREF_FAKE_STREAMS, true],
    [PREF_FOCUS_SOURCE, false],
  ];
  await SpecialPowers.pushPrefEnv({ set: prefs });

  // When the frames are in different processes, add observers to each frame,
  // to ensure that the notifications don't get sent in the wrong process.
  gObserveSubFrames = SpecialPowers.useRemoteSubframes ? options.subFrames : {};

  for (let testCase of tests) {
    let startTime = ChromeUtils.now();
    info(testCase.desc);
    if (
      !testCase.skipObserverVerification &&
      !options.skipObserverVerification
    ) {
      await enableObserverVerification();
    }
    await testCase.run(browser, options.subFrames);
    if (
      !testCase.skipObserverVerification &&
      !options.skipObserverVerification
    ) {
      await disableObserverVerification();
    }
    if (options.cleanup) {
      await options.cleanup();
    }
    ChromeUtils.addProfilerMarker(
      "browser-test",
      { startTime, category: "Test" },
      testCase.desc
    );
  }

  // Some tests destroy the original tab and leave a new one in its place.
  BrowserTestUtils.removeTab(gBrowser.selectedTab);
}

/**
 * Given a browser from a tab in this window, chooses to share
 * some combination of camera, mic or screen.
 *
 * @param {<xul:browser} browser - The browser to share devices with.
 * @param {boolean} camera - True to share a camera device.
 * @param {boolean} mic - True to share a microphone device.
 * @param {number} [screenOrWin] - One of either SHARE_WINDOW or SHARE_SCREEN
 *   to share a window or screen. Defaults to neither.
 * @param {boolean} remember - True to persist the permission to the
 *   SitePermissions database as SitePermissions.SCOPE_PERSISTENT. Note that
 *   callers are responsible for clearing this persistent permission.
 * @return {Promise}
 * @resolves {undefined} - Once the sharing is complete.
 */
async function shareDevices(
  browser,
  camera,
  mic,
  screenOrWin = 0,
  remember = false
) {
  if (camera || mic) {
    let promise = promisePopupNotificationShown(
      "webRTC-shareDevices",
      null,
      window
    );

    await promiseRequestDevice(mic, camera, null, null, browser);
    await promise;

    const expectedDeviceSelectorTypes = [
      camera && "camera",
      mic && "microphone",
    ].filter(x => x);
    checkDeviceSelectors(expectedDeviceSelectorTypes);
    let observerPromise1 = expectObserverCalled("getUserMedia:response:allow");
    let observerPromise2 = expectObserverCalled("recording-device-events");

    let rememberCheck = PopupNotifications.panel.querySelector(
      ".popup-notification-checkbox"
    );
    rememberCheck.checked = remember;

    promise = promiseMessage("ok", () => {
      PopupNotifications.panel.firstElementChild.button.click();
    });

    await observerPromise1;
    await observerPromise2;
    await promise;
  }

  if (screenOrWin) {
    let promise = promisePopupNotificationShown(
      "webRTC-shareDevices",
      null,
      window
    );

    await promiseRequestDevice(false, true, null, "screen", browser);
    await promise;

    checkDeviceSelectors(["screen"], window);

    let document = window.document;

    let menulist = document.getElementById("webRTC-selectWindow-menulist");
    let displayMediaSource;

    if (screenOrWin == SHARE_SCREEN) {
      displayMediaSource = "screen";
    } else if (screenOrWin == SHARE_WINDOW) {
      displayMediaSource = "window";
    } else {
      throw new Error("Got an invalid argument to shareDevices.");
    }

    let menuitem = null;
    for (let i = 0; i < menulist.itemCount; ++i) {
      let current = menulist.getItemAtIndex(i);
      if (current.mediaSource == displayMediaSource) {
        menuitem = current;
        break;
      }
    }

    Assert.ok(menuitem, "Should have found an appropriate display menuitem");
    menuitem.doCommand();

    let notification = window.PopupNotifications.panel.firstElementChild;

    let observerPromise1 = expectObserverCalled("getUserMedia:response:allow");
    let observerPromise2 = expectObserverCalled("recording-device-events");
    await promiseMessage(
      "ok",
      () => {
        notification.button.click();
      },
      1,
      browser
    );
    await observerPromise1;
    await observerPromise2;
  }
}
