/* 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/. */

const { XPCOMUtils } = ChromeUtils.importESModule(
  "resource://gre/modules/XPCOMUtils.sys.mjs"
);
const { AppConstants } = ChromeUtils.importESModule(
  "resource://gre/modules/AppConstants.sys.mjs"
);
const { showStreamSharingMenu, webrtcUI } = ChromeUtils.importESModule(
  "resource:///modules/webrtcUI.sys.mjs"
);

XPCOMUtils.defineLazyServiceGetter(
  this,
  "gScreenManager",
  "@mozilla.org/gfx/screenmanager;1",
  Ci.nsIScreenManager
);

/**
 * Public function called by webrtcUI to update the indicator
 * display when the active streams change.
 */
function updateIndicatorState() {
  WebRTCIndicator.updateIndicatorState();
}

/**
 * Public function called by webrtcUI to indicate that webrtcUI
 * is about to close the indicator. This is so that we can differentiate
 * between closes that are caused by webrtcUI, and closes that are
 * caused by other reasons (like the user closing the window via the
 * OS window controls somehow).
 *
 * If the window is closed without having called this method first, the
 * indicator will ask webrtcUI to shutdown any remaining streams and then
 * select and focus the most recent browser tab that a stream was shared
 * with.
 */
function closingInternally() {
  WebRTCIndicator.closingInternally();
}

/**
 * Main control object for the WebRTC global indicator
 */
const WebRTCIndicator = {
  init() {
    addEventListener("load", this);
    addEventListener("unload", this);

    // If the user customizes the position of the indicator, we will
    // not try to re-center it on the primary display after indicator
    // state updates.
    this.positionCustomized = false;

    this.updatingIndicatorState = false;
    this.loaded = false;
    this.isClosingInternally = false;

    this.statusBar = null;
    this.statusBarMenus = new Set();

    this.showGlobalMuteToggles = Services.prefs.getBoolPref(
      "privacy.webrtc.globalMuteToggles",
      false
    );

    this.hideGlobalIndicator =
      Services.prefs.getBoolPref("privacy.webrtc.hideGlobalIndicator", false) ||
      Services.appinfo.isWayland;

    if (this.hideGlobalIndicator) {
      this.setVisibility(false);
    }
  },

  /**
   * Controls the visibility of the global indicator. Also sets the value of
   * a "visible" attribute on the document element to "true" or "false".
   *
   * @param isVisible (boolean)
   *   Whether or not the global indicator should be visible.
   */
  setVisibility(isVisible) {
    let baseWin = window.docShell.treeOwner.QueryInterface(Ci.nsIBaseWindow);
    baseWin.visibility = isVisible;
    // AppWindow::GetVisibility _always_ returns true (see
    // https://bugzilla.mozilla.org/show_bug.cgi?id=306245), so we'll set an
    // attribute on the document to make it easier for tests to know that the
    // indicator is not visible.
    document.documentElement.setAttribute("visible", isVisible);
  },

  /**
   * Exposed externally so that webrtcUI can alert the indicator to
   * update itself when sharing states have changed.
   */
  updateIndicatorState() {
    // It's possible that we were called externally before the indicator
    // finished loading. If so, then bail out - we're going to call
    // updateIndicatorState ourselves automatically once the load
    // event fires.
    if (!this.loaded) {
      return;
    }

    // We've started to update the indicator state. We set this flag so
    // that the MozUpdateWindowPos event handler doesn't interpret indicator
    // state updates as window movement caused by the user.
    this.updatingIndicatorState = true;

    let showCameraIndicator = webrtcUI.showCameraIndicator;
    let showMicrophoneIndicator = webrtcUI.showMicrophoneIndicator;
    let showScreenSharingIndicator = webrtcUI.showScreenSharingIndicator;
    if (this.statusBar) {
      let statusMenus = new Map([
        ["Camera", showCameraIndicator],
        ["Microphone", showMicrophoneIndicator],
        ["Screen", showScreenSharingIndicator],
      ]);

      for (let [name, shouldShow] of statusMenus) {
        let menu = document.getElementById(`webRTC-sharing${name}-menu`);
        if (shouldShow && !this.statusBarMenus.has(menu)) {
          this.statusBar.addItem(menu);
          this.statusBarMenus.add(menu);
        } else if (!shouldShow && this.statusBarMenus.has(menu)) {
          this.statusBar.removeItem(menu);
          this.statusBarMenus.delete(menu);
        }
      }
    }

    if (!this.showGlobalMuteToggles && !webrtcUI.showScreenSharingIndicator) {
      this.setVisibility(false);
    } else if (!this.hideGlobalIndicator) {
      this.setVisibility(true);
    }

    if (this.showGlobalMuteToggles) {
      this.updateWindowAttr("sharingvideo", showCameraIndicator);
      this.updateWindowAttr("sharingaudio", showMicrophoneIndicator);
    }

    let sharingScreen = showScreenSharingIndicator.startsWith("Screen");
    this.updateWindowAttr("sharingscreen", sharingScreen);

    let sharingTab = showScreenSharingIndicator.startsWith("Browser");

    // We special-case sharing a window, because we want to have a slightly
    // different UI if we're sharing a browser window.
    let sharingWindow = showScreenSharingIndicator.startsWith("Window");
    this.updateWindowAttr("sharingwindow", sharingWindow);

    let sharingBrowserWindow = false;

    if (sharingWindow) {
      // Get the active window streams and see if any of them are "scary".
      // If so, then we're sharing a browser window.
      let activeStreams = webrtcUI.getActiveStreams(
        false /* camera */,
        false /* microphone */,
        false /* screen */,
        false /* tab */,
        true /* window */
      );
      sharingBrowserWindow = activeStreams.some(({ devices }) =>
        devices.some(({ scary }) => scary)
      );
    }
    this.updateWindowAttr("sharingtab", sharingTab || sharingBrowserWindow);

    // The label that's displayed when sharing a display followed a priority.
    // The more "risky" we deem the display is for sharing, the higher priority.
    // This gives us the following priorities, from highest to lowest.
    //
    // 1. Screen
    // 2. Browser window or tab
    // 3. Other application window
    //
    // The CSS for the indicator does the work of showing or hiding these labels
    // for us, but we need to update the aria-labelledby attribute on the container
    // of those labels to make it clearer for screenreaders which one the user cares
    // about.
    let displayShare = document.getElementById("display-share");
    let labelledBy;
    if (sharingScreen) {
      labelledBy = "screen-share-info";
    } else if (sharingBrowserWindow || sharingTab) {
      // TODO: Need to decide if we need different label for tab
      labelledBy = "browser-window-share-info";
    } else if (sharingWindow) {
      labelledBy = "window-share-info";
    }
    displayShare.setAttribute("aria-labelledby", labelledBy);

    if (window.windowState != window.STATE_MINIMIZED) {
      // Resize and ensure the window position is correct
      // (sizeToContent messes with our position).
      let docElStyle = document.documentElement.style;
      docElStyle.minWidth = docElStyle.maxWidth = "unset";
      docElStyle.minHeight = docElStyle.maxHeight = "unset";
      window.sizeToContent();

      // On Linux GTK, the style of window we're using by default is resizable. We
      // workaround this by setting explicit limits on the height and width of the
      // window.
      if (AppConstants.platform == "linux") {
        let { width, height } = window.windowUtils.getBoundsWithoutFlushing(
          document.documentElement
        );

        docElStyle.minWidth = docElStyle.maxWidth = `${width}px`;
        docElStyle.minHeight = docElStyle.maxHeight = `${height}px`;
      }

      this.ensureOnScreen();

      if (!this.positionCustomized) {
        this.centerOnLatestBrowser();
      }
    }

    this.updatingIndicatorState = false;
  },

  /**
   * After the indicator has been updated, checks to see if it has expanded
   * such that part of the indicator is now outside of the screen. If so,
   * it then adjusts the position to put the entire indicator on screen.
   */
  ensureOnScreen() {
    let desiredX = Math.max(window.screenX, screen.availLeft);
    let maxX =
      screen.availLeft +
      screen.availWidth -
      document.documentElement.clientWidth;
    window.moveTo(Math.min(desiredX, maxX), window.screenY);
  },

  /**
   * If the indicator is first being opened, we'll find the browser window
   * associated with the most recent share, and pin the indicator to the
   * very top of the content area.
   */
  centerOnLatestBrowser() {
    let activeStreams = webrtcUI.getActiveStreams(
      true /* camera */,
      true /* microphone */,
      true /* screen */,
      true /* tab */,
      true /* window */
    );

    if (!activeStreams.length) {
      return;
    }

    let browser = activeStreams[activeStreams.length - 1].browser;
    let browserWindow = browser.ownerGlobal;
    let browserRect =
      browserWindow.windowUtils.getBoundsWithoutFlushing(browser);

    // This should be called in initialize right after we've just called
    // updateIndicatorState. Since updateIndicatorState uses
    // window.sizeToContent, the layout information should be up to date,
    // and so the numbers that we get without flushing should be sufficient.
    let { width: windowWidth } = window.windowUtils.getBoundsWithoutFlushing(
      document.documentElement
    );

    window.moveTo(
      browserWindow.mozInnerScreenX +
        browserRect.left +
        (browserRect.width - windowWidth) / 2,
      browserWindow.mozInnerScreenY + browserRect.top
    );
  },

  handleEvent(event) {
    switch (event.type) {
      case "load": {
        this.onLoad();
        break;
      }
      case "unload": {
        this.onUnload();
        break;
      }
      case "click": {
        this.onClick(event);
        break;
      }
      case "change": {
        this.onChange(event);
        break;
      }
      case "MozUpdateWindowPos": {
        if (!this.updatingIndicatorState) {
          // The window moved while not updating the indicator state,
          // so the user probably moved it.
          this.positionCustomized = true;
        }
        break;
      }
      case "sizemodechange": {
        if (window.windowState != window.STATE_MINIMIZED) {
          this.updateIndicatorState();
        }
        break;
      }
      case "popupshowing": {
        this.onPopupShowing(event);
        break;
      }
      case "popuphiding": {
        this.onPopupHiding(event);
        break;
      }
      case "command": {
        this.onCommand(event);
        break;
      }
      case "DOMWindowClose":
      case "close": {
        this.onClose(event);
        break;
      }
    }
  },

  onLoad() {
    this.loaded = true;

    if (AppConstants.platform == "macosx" || AppConstants.platform == "win") {
      this.statusBar = Cc["@mozilla.org/widget/systemstatusbar;1"].getService(
        Ci.nsISystemStatusBar
      );
    }

    this.updateIndicatorState();

    window.addEventListener("click", this);
    window.addEventListener("change", this);
    window.addEventListener("sizemodechange", this);

    // There are two ways that the dialog can close - either via the
    // .close() window method, or via the OS. We handle both of those
    // cases here.
    window.addEventListener("DOMWindowClose", this);
    window.addEventListener("close", this);

    if (this.statusBar) {
      // We only want these events for the system status bar menus.
      window.addEventListener("popupshowing", this);
      window.addEventListener("popuphiding", this);
      window.addEventListener("command", this);
    }

    window.windowRoot.addEventListener("MozUpdateWindowPos", this);

    // Alert accessibility implementations stuff just changed. We only need to do
    // this initially, because changes after this will automatically fire alert
    // events if things change materially.
    let ev = new CustomEvent("AlertActive", {
      bubbles: true,
      cancelable: true,
    });
    document.documentElement.dispatchEvent(ev);

    this.loaded = true;
  },

  onClose(event) {
    // This event is fired from when the indicator window tries to be closed.
    // If we preventDefault() the event, we are able to cancel that close
    // attempt.
    //
    // We want to do that if we're not showing the global mute toggles
    // and we're still sharing a camera or a microphone so that we can
    // keep the status bar indicators present (since those status bar
    // indicators are bound to this window).
    if (
      !this.showGlobalMuteToggles &&
      (webrtcUI.showCameraIndicator || webrtcUI.showMicrophoneIndicator)
    ) {
      event.preventDefault();
      this.setVisibility(false);
    }

    if (!this.isClosingInternally) {
      // Something has tried to close the indicator, but it wasn't webrtcUI.
      // This means we might still have some streams being shared. To protect
      // the user from unknowingly sharing streams, we shut those streams
      // down.
      //
      // This only includes the camera and microphone streams if the user
      // has the global mute toggles enabled, since these toggles visually
      // associate the indicator with those streams.
      let activeStreams = webrtcUI.getActiveStreams(
        this.showGlobalMuteToggles /* camera */,
        this.showGlobalMuteToggles /* microphone */,
        true /* screen */,
        true /* tab */,
        true /* window */
      );
      webrtcUI.stopSharingStreams(
        activeStreams,
        this.showGlobalMuteToggles /* camera */,
        this.showGlobalMuteToggles /* microphone */,
        true /* screen */,
        true /* window */
      );
    }
  },

  onUnload() {
    Services.ppmm.sharedData.set("WebRTC:GlobalCameraMute", false);
    Services.ppmm.sharedData.set("WebRTC:GlobalMicrophoneMute", false);
    Services.ppmm.sharedData.flush();

    if (this.statusBar) {
      for (let menu of this.statusBarMenus) {
        this.statusBar.removeItem(menu);
      }
    }
  },

  onClick(event) {
    switch (event.target.id) {
      case "stop-sharing": {
        let activeStreams = webrtcUI.getActiveStreams(
          false /* camera */,
          false /* microphone */,
          true /* screen */,
          true /* tab */,
          true /* window */
        );

        if (!activeStreams.length) {
          return;
        }

        // getActiveStreams is filtering for streams that have screen
        // sharing, but those streams might _also_ be sharing other
        // devices like camera or microphone. This is why we need to
        // tell stopSharingStreams explicitly which device type we want
        // to stop.
        webrtcUI.stopSharingStreams(
          activeStreams,
          false /* camera */,
          false /* microphone */,
          true /* screen */,
          true /* window */
        );
        break;
      }
      case "minimize": {
        window.minimize();
        break;
      }
    }
  },

  onChange(event) {
    switch (event.target.id) {
      case "microphone-mute-toggle": {
        this.toggleMicrophoneMute(event.target);
        break;
      }
      case "camera-mute-toggle": {
        this.toggleCameraMute(event.target);
        break;
      }
    }
  },

  onPopupShowing(event) {
    if (this.eventIsForDeviceMenuPopup(event)) {
      // When the indicator is hidden by default, opening the menu from the
      // system tray _might_ cause the indicator to try to become visible again.
      // We work around this by re-hiding it if it wasn't already visible.
      if (document.documentElement.getAttribute("visible") != "true") {
        let baseWin = window.docShell.treeOwner.QueryInterface(
          Ci.nsIBaseWindow
        );
        baseWin.visibility = false;
      }

      showStreamSharingMenu(window, event, true);
    }
  },

  onPopupHiding(event) {
    if (!this.eventIsForDeviceMenuPopup(event)) {
      return;
    }

    let menu = event.target;
    while (menu.firstChild) {
      menu.firstChild.remove();
    }
  },

  onCommand(event) {
    webrtcUI.showSharingDoorhanger(event.target.stream, event);
  },

  /**
   * Returns true if an event was fired for one of the shared device
   * menupopups.
   *
   * @param event (Event)
   *   The event to check.
   * @returns True if the event was for one of the shared device
   *   menupopups.
   */
  eventIsForDeviceMenuPopup(event) {
    let menupopup = event.target;
    let type = menupopup.getAttribute("type");

    return ["Camera", "Microphone", "Screen"].includes(type);
  },

  /**
   * Mutes or unmutes the microphone globally based on the checked
   * state of toggleEl. Also updates the tooltip of toggleEl once
   * the state change is done.
   *
   * @param toggleEl (Element)
   *   The input[type="checkbox"] for toggling the microphone mute
   *   state.
   */
  toggleMicrophoneMute(toggleEl) {
    Services.ppmm.sharedData.set(
      "WebRTC:GlobalMicrophoneMute",
      toggleEl.checked
    );
    Services.ppmm.sharedData.flush();
    let l10nId = toggleEl.checked
      ? "webrtc-microphone-muted"
      : "webrtc-microphone-unmuted";
    document.l10n.setAttributes(toggleEl, l10nId);
  },

  /**
   * Mutes or unmutes the camera globally based on the checked
   * state of toggleEl. Also updates the tooltip of toggleEl once
   * the state change is done.
   *
   * @param toggleEl (Element)
   *   The input[type="checkbox"] for toggling the camera mute
   *   state.
   */
  toggleCameraMute(toggleEl) {
    Services.ppmm.sharedData.set("WebRTC:GlobalCameraMute", toggleEl.checked);
    Services.ppmm.sharedData.flush();
    let l10nId = toggleEl.checked
      ? "webrtc-camera-muted"
      : "webrtc-camera-unmuted";
    document.l10n.setAttributes(toggleEl, l10nId);
  },

  /**
   * Updates an attribute on the <window> element.
   *
   * @param attr (String)
   *   The name of the attribute to update.
   * @param value (String?)
   *   A string to set the attribute to. If the value is false-y,
   *   the attribute is removed.
   */
  updateWindowAttr(attr, value) {
    let docEl = document.documentElement;
    if (value) {
      docEl.setAttribute(attr, "true");
    } else {
      docEl.removeAttribute(attr);
    }
  },

  /**
   * See the documentation on the script global closingInternally() function.
   */
  closingInternally() {
    this.isClosingInternally = true;
  },
};

WebRTCIndicator.init();
