/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set ts=2 et sw=2 tw=80: */
/* 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/. */
// @ts-check

/**
 * Contains elements of the Content Analysis UI, which are integrated into
 * various browser behaviors (uploading, downloading, printing, etc) that
 * require content analysis to be done.
 * The content analysis itself is done by the clients of this script, who
 * use nsIContentAnalysis to talk to the external CA system.
 */

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

const lazy = {};
let internalContentAnalysisService = undefined;

ChromeUtils.defineESModuleGetters(lazy, {
  BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
  clearTimeout: "resource://gre/modules/Timer.sys.mjs",
  PanelMultiView: "resource:///modules/PanelMultiView.sys.mjs",
  setTimeout: "resource://gre/modules/Timer.sys.mjs",
});

XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "silentNotifications",
  "browser.contentanalysis.silent_notifications",
  false
);

XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "agentName",
  "browser.contentanalysis.agent_name",
  "A DLP agent"
);

XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "showBlockedResult",
  "browser.contentanalysis.show_blocked_result",
  true
);

export const ContentAnalysis = {
  _SHOW_NOTIFICATIONS: true,

  _SHOW_DIALOGS: false,

  _SLOW_DLP_NOTIFICATION_BLOCKING_TIMEOUT_MS: 250,

  _SLOW_DLP_NOTIFICATION_NONBLOCKING_TIMEOUT_MS: 3 * 1000,

  _RESULT_NOTIFICATION_TIMEOUT_MS: 5 * 60 * 1000, // 5 min

  _RESULT_NOTIFICATION_FAST_TIMEOUT_MS: 60 * 1000, // 1 min

  PROMPTID_PREFIX: "ContentAnalysisSlowDialog-",

  isInitialized: false,

  /**
   * @typedef {object} NotificationInfo - information about the busy dialog itself that is showing
   * @property {*} [close] - Method to close the native notification
   * @property {BrowsingContext} [dialogBrowsingContext] - browsing context where the
   *                                                       confirm() dialog is shown
   */

  /**
   * @typedef {object} BusyDialogInfo - information about a busy dialog that is either showing or will
   *                                    will be shown after a delay.
   * @property {string} userActionId - The userActionId of the request
   * @property {Set<string>} requestTokenSet - The set of requestTokens associated with the userActionId
   * @property {*} [timer] - Result of a setTimeout() call that can be used to cancel the showing of the busy
   *                         dialog if it has not been displayed yet.
   * @property {NotificationInfo} [notification] - Information about the busy dialog that is being shown.
   */

  /**
   * @type {Map<string, BusyDialogInfo>}
   *
   * Maps string UserActionId to info about the busy dialog.
   */
  userActionToBusyDialogMap: new Map(),

  /**
   * @typedef {object} ResourceNameOrOperationType
   * @property {string} [name] - the name of the resource
   * @property {number} [operationType] - the type of operation
   */

  /**
   * @typedef {object} RequestInfo
   * @property {CanonicalBrowsingContext?} browsingContext - browsing context where the request was sent from
   * @property {ResourceNameOrOperationType} resourceNameOrOperationType - name of the operation
   */

  /**
   * @type {Map<string, RequestInfo>}
   */
  requestTokenToRequestInfo: new Map(),

  /**
   * @type {Set<string>}
   */
  warnDialogRequestTokens: new Set(),

  /**
   * The nsIContentAnalysis to use instead of lazy.gContentAnalysis. Should
   * only be used for tests.
   *
   * @type {nsIContentAnalysis?}
   */
  mockContentAnalysisForTest: undefined,

  /**
   * The nsIContentAnalysis to use. Nothing else in this file should
   * use lazy.gContentAnalysis.
   *
   * @returns {nsIContentAnalysis}
   */
  get contentAnalysis() {
    if (this.mockContentAnalysisForTest) {
      return this.mockContentAnalysisForTest;
    }
    if (!internalContentAnalysisService) {
      internalContentAnalysisService = Cc[
        "@mozilla.org/contentanalysis;1"
      ].getService(Ci.nsIContentAnalysis);
    }
    return internalContentAnalysisService;
  },

  /**
   * Sets the nsIContentAnalysis to use. Should only be used for tests.
   *
   * @param {nsIContentAnalysis?} contentAnalysis
   */
  setMockContentAnalysisForTest(contentAnalysis) {
    this.mockContentAnalysisForTest = contentAnalysis;
  },

  /**
   * Registers for various messages/events that will indicate the
   * need for communicating something to the user.
   *
   * @param {Window} window - The window to monitor
   */
  initialize(window) {
    if (!this.contentAnalysis.isActive) {
      this.uninitialize();
      return;
    }
    let doc = window.document;
    if (!this.isInitialized) {
      this.isInitialized = true;
      this.initializeObservers();

      ChromeUtils.defineLazyGetter(this, "l10n", function () {
        return new Localization(
          ["branding/brand.ftl", "toolkit/contentanalysis/contentanalysis.ftl"],
          true
        );
      });
    }

    // Do this even if initialized so the icon shows up on new windows, not just the
    // first one.
    for (let indicator of doc.getElementsByClassName(
      "content-analysis-indicator"
    )) {
      doc.l10n.setAttributes(indicator, "content-analysis-indicator-tooltip", {
        agentName: lazy.agentName,
      });
    }
    doc.documentElement.setAttribute("contentanalysisactive", "true");
  },

  async uninitialize() {
    if (this.isInitialized) {
      this.isInitialized = false;
      this.requestTokenToRequestInfo.clear();
      this.userActionToBusyDialogMap.clear();
      this.uninitializeObservers();
    }
  },

  /**
   * Register UI for CA events.
   */
  initializeObservers() {
    Services.obs.addObserver(this, "dlp-request-made");
    Services.obs.addObserver(this, "dlp-response");
    Services.obs.addObserver(this, "quit-application");
    Services.obs.addObserver(this, "quit-application-granted");
    Services.obs.addObserver(this, "quit-application-requested");
  },

  /**
   * Unregister UI for CA events.
   */
  uninitializeObservers() {
    Services.obs.removeObserver(this, "dlp-request-made");
    Services.obs.removeObserver(this, "dlp-response");
    Services.obs.removeObserver(this, "quit-application");
    Services.obs.removeObserver(this, "quit-application-granted");
    Services.obs.removeObserver(this, "quit-application-requested");
  },

  // nsIObserver
  async observe(aSubj, aTopic, _aData) {
    switch (aTopic) {
      case "quit-application-requested": {
        if (aSubj.data) {
          // something has already cancelled the quit operation,
          // so we don't need to do anything.
          return;
        }
        let pendingRequestInfos = this._getAllSlowCARequestInfos();
        let requestDescriptions = Array.from(
          pendingRequestInfos.flatMap(info =>
            info
              ? [
                  this._getResourceNameFromNameOrOperationType(
                    info.resourceNameOrOperationType
                  ),
                ]
              : []
          )
        );
        if (!requestDescriptions.length) {
          return;
        }
        let messageBody = this.l10n.formatValueSync(
          "contentanalysis-inprogress-quit-message"
        );
        messageBody = messageBody + "\n\n";
        messageBody += requestDescriptions.join("\n");
        let buttonSelected = Services.prompt.confirmEx(
          null,
          this.l10n.formatValueSync("contentanalysis-inprogress-quit-title"),
          messageBody,
          Ci.nsIPromptService.BUTTON_POS_0 *
            Ci.nsIPromptService.BUTTON_TITLE_IS_STRING +
            Ci.nsIPromptService.BUTTON_POS_1 *
              Ci.nsIPromptService.BUTTON_TITLE_CANCEL +
            Ci.nsIPromptService.BUTTON_POS_0_DEFAULT,
          this.l10n.formatValueSync(
            "contentanalysis-inprogress-quit-yesbutton"
          ),
          null,
          null,
          null,
          { value: false }
        );
        if (buttonSelected === 1) {
          // Cancel the quit operation
          aSubj.data = true;
        } else {
          // Ideally we would wait until "quit-application" to cancel outstanding
          // DLP requests, but the "DLP busy" or "DLP blocked" dialog can block the
          // main thread, thus preventing the "quit-application" from being sent,
          // which causes a shutdownhang. (bug 1899703)
          this.contentAnalysis.cancelAllRequests(true);
        }
        break;
      }
      // Note that we do this in quit-application-granted instead of quit-application
      // because otherwise we can get a shutdownhang if WARN dialogs are showing and
      // the user quits via keyboard or the hamburger menu (bug 1959966)
      case "quit-application-granted": {
        // We're quitting, so respond false to all WARN dialogs.
        let requestTokensToCancel = this.warnDialogRequestTokens;
        // Clear this first so the handler showing the dialog will know not
        // to call respondToWarnDialog() again.
        this.warnDialogRequestTokens = new Set();
        for (let warnDialogRequestToken of requestTokensToCancel) {
          this.contentAnalysis.respondToWarnDialog(
            warnDialogRequestToken,
            false
          );
        }
        break;
      }
      case "quit-application": {
        this.uninitialize();
        break;
      }
      case "dlp-request-made":
        {
          const request = aSubj.QueryInterface(Ci.nsIContentAnalysisRequest);
          if (!request) {
            console.error(
              "Showing in-browser Content Analysis notification but no request was passed"
            );
            return;
          }
          let browsingContext = request.windowGlobalParent?.browsingContext;
          if (
            !browsingContext &&
            request.operationTypeForDisplay !==
              Ci.nsIContentAnalysisRequest.eDownload
          ) {
            throw new Error(
              "Got dlp-request-made message but couldn't find a browsingContext!"
            );
          }

          // Start timer that, when it expires,
          // presents a "slow CA check" message.
          let resourceNameOrOperationType =
            this._getResourceNameOrOperationTypeFromRequest(request, false);
          this.requestTokenToRequestInfo.set(request.requestToken, {
            browsingContext,
            resourceNameOrOperationType,
          });
          this._queueSlowCAMessage(
            request,
            resourceNameOrOperationType,
            browsingContext
          );
        }
        break;
      case "dlp-response": {
        const response = aSubj.QueryInterface(Ci.nsIContentAnalysisResponse);
        // Cancels timer or slow message UI,
        // if present, and possibly presents the CA verdict.
        if (!response) {
          throw new Error(
            "Got dlp-response message but no response object was passed"
          );
        }

        let windowAndResourceNameOrOperationType =
          this.requestTokenToRequestInfo.get(response.requestToken);
        if (!windowAndResourceNameOrOperationType) {
          // We may get multiple responses, for example, if we are blocked or
          // canceled after receiving our verdict because we were part of a
          // multipart transaction.  Just ignore that.
          console.warn(
            `Got dlp-response message with unknown token ${response.requestToken} | action: ${response.action}`
          );
          return;
        }
        this.requestTokenToRequestInfo.delete(response.requestToken);
        this._removeSlowCAMessage(response.userActionId, response.requestToken);
        if (
          windowAndResourceNameOrOperationType.resourceNameOrOperationType
            ?.operationType === Ci.nsIContentAnalysisRequest.eDownload
        ) {
          // Don't show warn/block dialogs for downloads; they're shown inside
          // the downloads panel.
          return;
        }
        const responseResult =
          response?.action ?? Ci.nsIContentAnalysisResponse.eUnspecified;
        // Don't show dialog if this is a cached response
        if (!response?.isCachedResponse) {
          await this._showCAResult(
            windowAndResourceNameOrOperationType.resourceNameOrOperationType,
            windowAndResourceNameOrOperationType.browsingContext,
            response.requestToken,
            response.userActionId,
            responseResult,
            response.isSyntheticResponse,
            response.cancelError
          );
        }
        break;
      }
    }
  },

  /**
   * Shows the panel that indicates that DLP is active.
   *
   * @param {Element} element The toolbarbutton the user has clicked on
   * @param {*} panelUI Maintains state for the main menu panel
   */
  async showPanel(element, panelUI) {
    element.ownerDocument.l10n.setAttributes(
      lazy.PanelMultiView.getViewNode(
        element.ownerDocument,
        "content-analysis-panel-description"
      ),
      "content-analysis-panel-text-styled",
      { agentName: lazy.agentName }
    );
    panelUI.showSubView("content-analysis-panel", element);
  },

  /**
   * Closes a busy dialog
   *
   * @param {BusyDialogInfo?} caView - the busy dialog to close
   */
  _disconnectFromView(caView) {
    if (!caView) {
      return;
    }
    if (caView.timer) {
      lazy.clearTimeout(caView.timer);
    } else if (caView.notification) {
      if (caView.notification.close) {
        // native notification
        caView.notification.close();
      } else if (caView.notification.dialogBrowsingContext) {
        // in-browser notification
        let browser =
          caView.notification.dialogBrowsingContext.top.embedderElement;
        // If we're showing a dialog in the sidebar, the dialog is managed
        // by the embedderElement.
        let isSidebar =
          browser?.ownerGlobal?.browsingContext?.embedderElement?.id ==
          "sidebar";
        if (isSidebar) {
          browser = browser.ownerGlobal.browsingContext.embedderElement;
        }
        // browser will be null if the tab was closed
        let win = browser?.ownerGlobal;
        if (win) {
          let dialogBox = win.gBrowser.getTabDialogBox(browser);
          // Just close the dialog associated with this CA request.
          dialogBox.getTabDialogManager().abortDialogs(dialog => {
            return (
              dialog.promptID == this.PROMPTID_PREFIX + caView.userActionId
            );
          });
        }
      } else {
        console.error(
          "Unexpected content analysis notification - can't close it!"
        );
      }
    }
  },

  /**
   * Shows either a dialog or native notification or both, depending on the values of
   * _SHOW_DIALOGS and _SHOW_NOTIFICATIONS.
   *
   * @param {string} aMessage - Message to show
   * @param {CanonicalBrowsingContext?} aBrowsingContext - BrowsingContext to show the dialog in. If
   *                            null, the top browsing context will be used for native notifications.
   * @param {number} aTimeout - timeout for closing the native notification. 0 indicates it is
   *                            not automatically closed.
   * @returns {NotificationInfo?} - information about the native notification, if it has been shown.
   */
  _showMessage(aMessage, aBrowsingContext, aTimeout = 0) {
    if (this._SHOW_DIALOGS) {
      Services.prompt.asyncAlert(
        aBrowsingContext,
        Ci.nsIPrompt.MODAL_TYPE_WINDOW,
        this.l10n.formatValueSync("contentanalysis-alert-title"),
        aMessage
      );
    }

    if (this._SHOW_NOTIFICATIONS) {
      // Downloading as a "save as" operation does not provide a browsing context,
      // so use the the top window in that case.
      let topWindow =
        aBrowsingContext?.topChromeWindow ??
        aBrowsingContext?.embedderWindowGlobal.browsingContext
          .topChromeWindow ??
        lazy.BrowserWindowTracker.getTopWindow();
      if (!topWindow) {
        console.error(
          "Unable to get window to show Content Analysis notification for."
        );
        return null;
      }
      const notification = new topWindow.Notification(
        this.l10n.formatValueSync("contentanalysis-notification-title"),
        { body: aMessage, silent: lazy.silentNotifications }
      );

      if (aTimeout != 0) {
        lazy.setTimeout(() => {
          notification.close();
        }, aTimeout);
      }
      return notification;
    }

    return null;
  },

  /**
   * Whether the notification should block browser interaction.
   *
   * @param {nsIContentAnalysisRequest.AnalysisType} aAnalysisType The type of DLP analysis being done.
   * @returns {boolean}
   */
  _shouldShowBlockingNotification(aAnalysisType) {
    return !(
      aAnalysisType == Ci.nsIContentAnalysisRequest.eFileDownloaded ||
      aAnalysisType == Ci.nsIContentAnalysisRequest.ePrint
    );
  },

  /**
   * This function also transforms the nameOrOperationType so we won't have to
   * look it up again.
   *
   * @param {ResourceNameOrOperationType} nameOrOperationType
   * @returns {string}
   */
  _getResourceNameFromNameOrOperationType(nameOrOperationType) {
    if (!nameOrOperationType.name) {
      let l10nId = undefined;
      switch (nameOrOperationType.operationType) {
        case Ci.nsIContentAnalysisRequest.eClipboard:
          l10nId = "contentanalysis-operationtype-clipboard";
          break;
        case Ci.nsIContentAnalysisRequest.eDroppedText:
          l10nId = "contentanalysis-operationtype-dropped-text";
          break;
        case Ci.nsIContentAnalysisRequest.eOperationPrint:
          l10nId = "contentanalysis-operationtype-print";
          break;
      }
      if (!l10nId) {
        console.error(
          "Unknown operationTypeForDisplay: " +
            nameOrOperationType.operationType
        );
        return "";
      }
      nameOrOperationType.name = this.l10n.formatValueSync(l10nId);
    }
    return nameOrOperationType.name;
  },

  /**
   * Gets a name or operation type from a request
   *
   * @param {nsIContentAnalysisRequest} aRequest The nsIContentAnalysisRequest
   * @param {boolean} aStandalone Whether the message is going to be used on its own
   *                              line. This is used to add more context to the message
   *                              if a file is being uploaded or downloaded rather than
   *                              just the name of the file.
   * @returns {ResourceNameOrOperationType}
   */
  _getResourceNameOrOperationTypeFromRequest(aRequest, aStandalone) {
    /**
     * @type {ResourceNameOrOperationType}
     */
    let nameOrOperationType = {
      operationType: aRequest.operationTypeForDisplay,
    };
    if (
      aRequest.operationTypeForDisplay == Ci.nsIContentAnalysisRequest.eUpload
    ) {
      if (aStandalone) {
        nameOrOperationType.name = this.l10n.formatValueSync(
          "contentanalysis-upload-description",
          { filename: aRequest.fileNameForDisplay }
        );
      } else {
        nameOrOperationType.name = aRequest.fileNameForDisplay;
      }
    } else if (
      aRequest.operationTypeForDisplay == Ci.nsIContentAnalysisRequest.eDownload
    ) {
      if (aStandalone) {
        nameOrOperationType.name = this.l10n.formatValueSync(
          "contentanalysis-download-description",
          { filename: aRequest.fileNameForDisplay }
        );
      } else {
        nameOrOperationType.name = aRequest.fileNameForDisplay;
      }
    }
    return nameOrOperationType;
  },

  /**
   * Sets up an "operation is in progress" dialog to be shown after a delay,
   * unless one is already showing for this userActionId.
   *
   * @param {nsIContentAnalysisRequest} aRequest
   * @param {ResourceNameOrOperationType} aResourceNameOrOperationType
   * @param {CanonicalBrowsingContext?} aBrowsingContext
   */
  _queueSlowCAMessage(
    aRequest,
    aResourceNameOrOperationType,
    aBrowsingContext
  ) {
    let entry = this.userActionToBusyDialogMap.get(aRequest.userActionId);
    if (entry) {
      // Don't show busy dialog if another request is already doing so.
      entry.requestTokenSet.add(aRequest.requestToken);
      return;
    }

    const analysisType = aRequest.analysisType;
    // For operations that block browser interaction, show the "slow content analysis"
    // dialog faster
    let slowTimeoutMs = this._shouldShowBlockingNotification(analysisType)
      ? this._SLOW_DLP_NOTIFICATION_BLOCKING_TIMEOUT_MS
      : this._SLOW_DLP_NOTIFICATION_NONBLOCKING_TIMEOUT_MS;

    entry = {
      requestTokenSet: new Set([aRequest.requestToken]),
      userActionId: aRequest.userActionId,
    };
    this.userActionToBusyDialogMap.set(aRequest.userActionId, entry);
    entry.timer = lazy.setTimeout(() => {
      entry.timer = null;
      entry.notification = this._showSlowCAMessage(
        analysisType,
        aRequest,
        this._getSlowDialogMessage(
          aResourceNameOrOperationType,
          aRequest.userActionRequestsCount
        ),
        aBrowsingContext
      );
    }, slowTimeoutMs);
  },

  /**
   * Removes the Slow CA message, if it is showing
   *
   * @param {string} aUserActionId The user action ID to remove
   * @param {string} aRequestToken The request token to remove
   */
  _removeSlowCAMessage(aUserActionId, aRequestToken) {
    let entry = this.userActionToBusyDialogMap.get(aUserActionId);
    if (!entry) {
      console.error(
        `Couldn't find slow dialog for user action ${aUserActionId}`
      );
      return;
    }
    if (!entry.requestTokenSet.delete(aRequestToken)) {
      console.warn(
        `Couldn't find request ${aRequestToken} in slow dialog object for user action ${aUserActionId}.  Shutting down?`
      );
      return;
    }
    if (entry.requestTokenSet.size) {
      // Continue showing the busy dialog since other requests are still pending.
      return;
    }
    this.userActionToBusyDialogMap.delete(aUserActionId);
    this._disconnectFromView(entry);
  },

  /**
   * Gets all the requests that are still in progress.
   *
   * @returns {IteratorObject<RequestInfo>} Information about the requests that are still in progress
   */
  _getAllSlowCARequestInfos() {
    return this.userActionToBusyDialogMap
      .values()
      .flatMap(val => val.requestTokenSet)
      .map(requestToken => this.requestTokenToRequestInfo.get(requestToken));
  },

  /**
   * Show a message to the user to indicate that a CA request is taking
   * a long time.
   *
   * @param {nsIContentAnalysisRequest.AnalysisType} aOperation The operation
   * @param {nsIContentAnalysisRequest} aRequest The request that is taking a long time
   * @param {string} aBodyMessage Message to show in the body of the alert
   * @param {CanonicalBrowsingContext?} aBrowsingContext BrowsingContext to show the alert in
   */
  _showSlowCAMessage(aOperation, aRequest, aBodyMessage, aBrowsingContext) {
    if (!this._shouldShowBlockingNotification(aOperation)) {
      return this._showMessage(aBodyMessage, aBrowsingContext);
    }

    if (!aRequest) {
      throw new Error(
        "Showing in-browser Content Analysis notification but no request was passed"
      );
    }

    return this._showSlowCABlockingMessage(
      aBrowsingContext,
      aRequest.userActionId,
      aRequest.requestToken,
      aBodyMessage
    );
  },

  /**
   * Gets the dialog message to show for the Slow CA dialog.
   *
   * @param {ResourceNameOrOperationType} aResourceNameOrOperationType
   * @param {number} aNumRequests
   * @returns {string}
   */
  _getSlowDialogMessage(aResourceNameOrOperationType, aNumRequests) {
    if (aResourceNameOrOperationType.name) {
      let label =
        aNumRequests > 1
          ? "contentanalysis-slow-agent-dialog-body-file-and-more"
          : "contentanalysis-slow-agent-dialog-body-file";

      return this.l10n.formatValueSync(label, {
        agent: lazy.agentName,
        filename: aResourceNameOrOperationType.name,
        count: aNumRequests - 1,
      });
    }
    let l10nId = undefined;
    switch (aResourceNameOrOperationType.operationType) {
      case Ci.nsIContentAnalysisRequest.eClipboard:
        l10nId = "contentanalysis-slow-agent-dialog-body-clipboard";
        break;
      case Ci.nsIContentAnalysisRequest.eDroppedText:
        l10nId = "contentanalysis-slow-agent-dialog-body-dropped-text";
        break;
      case Ci.nsIContentAnalysisRequest.eOperationPrint:
        l10nId = "contentanalysis-slow-agent-dialog-body-print";
        break;
    }
    if (!l10nId) {
      console.error(
        "Unknown operationTypeForDisplay: ",
        aResourceNameOrOperationType
      );
      return "";
    }
    return this.l10n.formatValueSync(l10nId, { agent: lazy.agentName });
  },

  /**
   * Gets the dialog message to show when the request has an error.
   *
   * @param {ResourceNameOrOperationType} aResourceNameOrOperationType
   * @returns {string}
   */
  _getErrorDialogMessage(aResourceNameOrOperationType) {
    if (aResourceNameOrOperationType.name) {
      return this.l10n.formatValueSync(
        aResourceNameOrOperationType.operationType ==
          Ci.nsIContentAnalysisRequest.eUpload
          ? "contentanalysis-error-message-upload-file"
          : "contentanalysis-error-message-download-file",
        { filename: aResourceNameOrOperationType.name }
      );
    }
    let l10nId = undefined;
    switch (aResourceNameOrOperationType.operationType) {
      case Ci.nsIContentAnalysisRequest.eClipboard:
        l10nId = "contentanalysis-error-message-clipboard";
        break;
      case Ci.nsIContentAnalysisRequest.eDroppedText:
        l10nId = "contentanalysis-error-message-dropped-text";
        break;
      case Ci.nsIContentAnalysisRequest.eOperationPrint:
        l10nId = "contentanalysis-error-message-print";
        break;
    }
    if (!l10nId) {
      console.error(
        "Unknown operationTypeForDisplay: ",
        aResourceNameOrOperationType
      );
      return "";
    }
    return this.l10n.formatValueSync(l10nId);
  },

  /**
   * Show the Slow CA blocking dialog.
   *
   * @param {BrowsingContext} aBrowsingContext
   * @param {string} aUserActionId
   * @param {string} aRequestToken
   * @param {string} aBodyMessage
   * @returns {NotificationInfo}
   */
  _showSlowCABlockingMessage(
    aBrowsingContext,
    aUserActionId,
    aRequestToken,
    aBodyMessage
  ) {
    // Note that TabDialogManager maintains a list of displaying dialogs, and so
    // we can pop up multiple of these and the first one will keep displaying until
    // it is closed, at which point the next one will display, etc.
    let promise = Services.prompt.asyncConfirmEx(
      aBrowsingContext,
      Ci.nsIPromptService.MODAL_TYPE_TAB,
      this.l10n.formatValueSync("contentanalysis-slow-agent-dialog-header"),
      aBodyMessage,
      Ci.nsIPromptService.BUTTON_POS_0 *
        Ci.nsIPromptService.BUTTON_TITLE_CANCEL +
        Ci.nsIPromptService.BUTTON_POS_1_DEFAULT +
        Ci.nsIPromptService.SHOW_SPINNER,
      null,
      null,
      null,
      null,
      false,
      { promptID: this.PROMPTID_PREFIX + aUserActionId }
    );
    promise
      .catch(() => {
        // need a catch clause to avoid an unhandled JS exception
        // when we programmatically close the dialog or close the tab.
      })
      .finally(() => {
        // This is also called if the tab/window is closed while a request is
        // in progress, in which case we need to cancel all related requests.
        //
        // If aUserActionId is still in userActionToBusyDialogMap,
        // this means the dialog wasn't closed by _disconnectFromView(),
        // so cancel the operation.
        if (this.userActionToBusyDialogMap.has(aUserActionId)) {
          this.contentAnalysis.cancelAllRequestsAssociatedWithUserAction(
            aUserActionId
          );
        }
        // Do this after checking userActionToBusyDialogMap, since
        // _removeSlowCAMessage() will remove the entry from
        // userActionToBusyDialogMap.
        if (this.requestTokenToRequestInfo.delete(aRequestToken)) {
          // I think this is needed to clean up when the tab/window
          // is closed.
          this._removeSlowCAMessage(aUserActionId, aRequestToken);
        }
      });
    return {
      dialogBrowsingContext: aBrowsingContext,
    };
  },

  /**
   * Show a message to the user to indicate the result of a CA request.
   *
   * @param {ResourceNameOrOperationType} aResourceNameOrOperationType
   * @param {CanonicalBrowsingContext} aBrowsingContext
   * @param {string} aRequestToken
   * @param {string} aUserActionId
   * @param {number} aCAResult
   * @param {boolean} aIsSyntheticResponse
   * @param {number} aRequestCancelError
   * @returns {Promise<NotificationInfo?>} a notification object (if shown)
   */
  async _showCAResult(
    aResourceNameOrOperationType,
    aBrowsingContext,
    aRequestToken,
    aUserActionId,
    aCAResult,
    aIsSyntheticResponse,
    aRequestCancelError
  ) {
    let message = null;
    let timeoutMs = 0;

    switch (aCAResult) {
      case Ci.nsIContentAnalysisResponse.eAllow:
        // We don't need to show anything
        return null;
      case Ci.nsIContentAnalysisResponse.eReportOnly:
        message = await this.l10n.formatValue(
          "contentanalysis-genericresponse-message",
          {
            content: this._getResourceNameFromNameOrOperationType(
              aResourceNameOrOperationType
            ),
            response: "REPORT_ONLY",
          }
        );
        timeoutMs = this._RESULT_NOTIFICATION_FAST_TIMEOUT_MS;
        break;
      case Ci.nsIContentAnalysisResponse.eWarn: {
        let allow = false;
        try {
          this.warnDialogRequestTokens.add(aRequestToken);
          const result = await Services.prompt.asyncConfirmEx(
            aBrowsingContext,
            Ci.nsIPromptService.MODAL_TYPE_TAB,
            await this.l10n.formatValue("contentanalysis-warndialogtitle"),
            await this._warnDialogText(aResourceNameOrOperationType),
            Ci.nsIPromptService.BUTTON_POS_0 *
              Ci.nsIPromptService.BUTTON_TITLE_IS_STRING +
              Ci.nsIPromptService.BUTTON_POS_1 *
                Ci.nsIPromptService.BUTTON_TITLE_IS_STRING +
              Ci.nsIPromptService.BUTTON_POS_2_DEFAULT,
            await this.l10n.formatValue(
              "contentanalysis-warndialog-response-allow"
            ),
            await this.l10n.formatValue(
              "contentanalysis-warndialog-response-deny"
            ),
            null,
            null,
            false
          );
          allow = result.get("buttonNumClicked") === 0;
        } catch {
          // This can happen if the dialog is closed programmatically, for
          // example if the tab is moved to a new window.
          // In this case just pretend the user clicked deny, as this
          // emulates the behavior of cancelling when
          // the request is still active.
          allow = false;
        }
        // Note that the shutdown code in the "quit-application" handler
        // may have cleared out warnDialogRequestTokens and responded
        // to the request already, so don't call respondToWarnDialog()
        // if aRequestToken is not in warnDialogRequestTokens.
        if (this.warnDialogRequestTokens.delete(aRequestToken)) {
          this.contentAnalysis.respondToWarnDialog(aRequestToken, allow);
        }
        return null;
      }
      case Ci.nsIContentAnalysisResponse.eBlock: {
        if (!aIsSyntheticResponse && !lazy.showBlockedResult) {
          // Don't show anything
          return null;
        }
        let titleId = undefined;
        let body = undefined;
        if (aResourceNameOrOperationType.name) {
          titleId =
            aResourceNameOrOperationType.operationType ==
            Ci.nsIContentAnalysisRequest.eUpload
              ? "contentanalysis-block-dialog-title-upload-file"
              : "contentanalysis-block-dialog-title-download-file";
          body = this.l10n.formatValueSync(
            aResourceNameOrOperationType.operationType ==
              Ci.nsIContentAnalysisRequest.eUpload
              ? "contentanalysis-block-dialog-body-upload-file"
              : "contentanalysis-block-dialog-body-download-file",
            { filename: aResourceNameOrOperationType.name }
          );
        } else {
          let bodyId = undefined;
          let bodyHasContent = false;
          switch (aResourceNameOrOperationType.operationType) {
            case Ci.nsIContentAnalysisRequest.eClipboard: {
              // Unlike the cases below, this can be shown when the DLP
              // agent is not available.  We use a different message for that.
              const caInfo = await this.contentAnalysis.getDiagnosticInfo();
              titleId = "contentanalysis-block-dialog-title-clipboard";
              bodyId = caInfo.connectedToAgent
                ? "contentanalysis-block-dialog-body-clipboard"
                : "contentanalysis-no-agent-connected-message-content";
              bodyHasContent = true;
              break;
            }
            case Ci.nsIContentAnalysisRequest.eDroppedText:
              titleId = "contentanalysis-block-dialog-title-dropped-text";
              bodyId = "contentanalysis-block-dialog-body-dropped-text";
              break;
            case Ci.nsIContentAnalysisRequest.eOperationPrint:
              titleId = "contentanalysis-block-dialog-title-print";
              bodyId = "contentanalysis-block-dialog-body-print";
              break;
          }
          if (!titleId || !bodyId) {
            console.error(
              "Unknown operationTypeForDisplay: ",
              aResourceNameOrOperationType
            );
            return null;
          }
          if (bodyHasContent) {
            body = this.l10n.formatValueSync(bodyId, {
              agent: lazy.agentName,
              content: "",
            });
          } else {
            body = this.l10n.formatValueSync(bodyId);
          }
        }
        let alertBrowsingContext = aBrowsingContext;
        if (aBrowsingContext.embedderElement?.getAttribute("printpreview")) {
          // If we're in a print preview dialog, things are tricky.
          // The window itself is about to close (because of the thrown NS_ERROR_CONTENT_BLOCKED),
          // so using an async call would just immediately make the dialog disappear. (bug 1899714)
          // Using a blocking version can cause a hang if the window is resizing while
          // we show the dialog. (bug 1900798)
          // So instead, try to find the browser that this print preview dialog is on top of
          // and show the dialog there.
          let printPreviewBrowser = aBrowsingContext.embedderElement;
          let win = printPreviewBrowser.ownerGlobal;
          for (let browser of win.gBrowser.browsers) {
            if (
              win.PrintUtils.getPreviewBrowser(browser)?.browserId ===
              printPreviewBrowser.browserId
            ) {
              alertBrowsingContext = browser.browsingContext;
              break;
            }
          }
        }
        await Services.prompt.asyncAlert(
          alertBrowsingContext,
          Ci.nsIPromptService.MODAL_TYPE_TAB,
          this.l10n.formatValueSync(titleId),
          body
        );
        return null;
      }
      case Ci.nsIContentAnalysisResponse.eUnspecified:
        message = await this.l10n.formatValue(
          "contentanalysis-unspecified-error-message-content",
          {
            agent: lazy.agentName,
            content: this._getErrorDialogMessage(aResourceNameOrOperationType),
          }
        );
        timeoutMs = this._RESULT_NOTIFICATION_TIMEOUT_MS;
        break;
      case Ci.nsIContentAnalysisResponse.eCanceled:
        {
          let messageId;
          switch (aRequestCancelError) {
            case Ci.nsIContentAnalysisResponse.eUserInitiated:
              console.error(
                "Got unexpected cancel response with eUserInitiated"
              );
              return null;
            case Ci.nsIContentAnalysisResponse.eOtherRequestInGroupCancelled:
              return null;
            case Ci.nsIContentAnalysisResponse.eNoAgent:
              messageId = "contentanalysis-no-agent-connected-message-content";
              break;
            case Ci.nsIContentAnalysisResponse.eInvalidAgentSignature:
              messageId =
                "contentanalysis-invalid-agent-signature-message-content";
              break;
            case Ci.nsIContentAnalysisResponse.eErrorOther:
              messageId = "contentanalysis-unspecified-error-message-content";
              break;
            case Ci.nsIContentAnalysisResponse.eShutdown:
              // we're shutting down, no need to show a dialog
              return null;
            case Ci.nsIContentAnalysisResponse.eTimeout:
              // We only show this if the default action was to block.
              messageId = "contentanalysis-timeout-block-error-message-content";
              break;
            default:
              console.error(
                "Unexpected CA cancelError value: " + aRequestCancelError
              );
              messageId = "contentanalysis-unspecified-error-message-content";
              break;
          }
          // We got an error with this request, so close any dialogs for any other request
          // with the same user action id and also remove their data so we don't show
          // any dialogs they might later try to show.
          const busyDialogInfo =
            this.userActionToBusyDialogMap.get(aUserActionId);
          if (busyDialogInfo) {
            busyDialogInfo.requestTokenSet.forEach(requestToken => {
              this.requestTokenToRequestInfo.delete(requestToken);
              this._removeSlowCAMessage(aUserActionId, requestToken);
            });
          }
          message = await this.l10n.formatValue(messageId, {
            agent: lazy.agentName,
            content: this._getErrorDialogMessage(aResourceNameOrOperationType),
            contentName: this._getResourceNameFromNameOrOperationType(
              aResourceNameOrOperationType
            ),
          });
          timeoutMs = this._RESULT_NOTIFICATION_TIMEOUT_MS;
        }
        break;
      default:
        throw new Error("Unexpected CA result value: " + aCAResult);
    }

    if (!message) {
      console.error(
        "_showCAResult did not get a message populated for result value " +
          aCAResult
      );
      return null;
    }

    return this._showMessage(message, aBrowsingContext, timeoutMs);
  },

  /**
   * Returns the correct text for warn dialog contents.
   *
   * @param {ResourceNameOrOperationType} aResourceNameOrOperationType
   */
  async _warnDialogText(aResourceNameOrOperationType) {
    const caInfo = await this.contentAnalysis.getDiagnosticInfo();
    if (caInfo.connectedToAgent) {
      return await this.l10n.formatValue("contentanalysis-warndialogtext", {
        content: this._getResourceNameFromNameOrOperationType(
          aResourceNameOrOperationType
        ),
      });
    }
    return await this.l10n.formatValue(
      "contentanalysis-no-agent-connected-message-content",
      { agent: lazy.agentName, content: "" }
    );
  },
};
