/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

var Cc = Components.classes;
var Ci = Components.interfaces;
var Cu = Components.utils;

this.EXPORTED_SYMBOLS = [ "PluginContent" ];

Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/Timer.jsm");
Cu.import("resource://gre/modules/BrowserUtils.jsm");

XPCOMUtils.defineLazyGetter(this, "gNavigatorBundle", function() {
  const url = "chrome://browser/locale/browser.properties";
  return Services.strings.createBundle(url);
});

XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
  "resource://gre/modules/AppConstants.jsm");

this.PluginContent = function (global) {
  this.init(global);
}

const FLASH_MIME_TYPE = "application/x-shockwave-flash";
const REPLACEMENT_STYLE_SHEET = Services.io.newURI("chrome://pluginproblem/content/pluginReplaceBinding.css", null, null);

PluginContent.prototype = {
  init: function (global) {
    this.global = global;
    // Need to hold onto the content window or else it'll get destroyed
    this.content = this.global.content;
    // Cache of plugin actions for the current page.
    this.pluginData = new Map();
    // Cache of plugin crash information sent from the parent
    this.pluginCrashData = new Map();

    // Note that the XBL binding is untrusted
    global.addEventListener("PluginBindingAttached", this, true, true);
    global.addEventListener("PluginPlaceholderReplaced", this, true, true);
    global.addEventListener("PluginCrashed",         this, true);
    global.addEventListener("PluginOutdated",        this, true);
    global.addEventListener("PluginInstantiated",    this, true);
    global.addEventListener("PluginRemoved",         this, true);
    global.addEventListener("pagehide",              this, true);
    global.addEventListener("pageshow",              this, true);
    global.addEventListener("unload",                this);
    global.addEventListener("HiddenPlugin",          this, true);

    global.addMessageListener("BrowserPlugins:ActivatePlugins", this);
    global.addMessageListener("BrowserPlugins:NotificationShown", this);
    global.addMessageListener("BrowserPlugins:ContextMenuCommand", this);
    global.addMessageListener("BrowserPlugins:NPAPIPluginProcessCrashed", this);
    global.addMessageListener("BrowserPlugins:CrashReportSubmitted", this);
    global.addMessageListener("BrowserPlugins:Test:ClearCrashData", this);

    Services.obs.addObserver(this, "decoder-doctor-notification", false);
  },

  uninit: function() {
    let global = this.global;

    global.removeEventListener("PluginBindingAttached", this, true);
    global.removeEventListener("PluginPlaceholderReplaced", this, true, true);
    global.removeEventListener("PluginCrashed",         this, true);
    global.removeEventListener("PluginOutdated",        this, true);
    global.removeEventListener("PluginInstantiated",    this, true);
    global.removeEventListener("PluginRemoved",         this, true);
    global.removeEventListener("pagehide",              this, true);
    global.removeEventListener("pageshow",              this, true);
    global.removeEventListener("unload",                this);
    global.removeEventListener("HiddenPlugin",          this, true);

    global.removeMessageListener("BrowserPlugins:ActivatePlugins", this);
    global.removeMessageListener("BrowserPlugins:NotificationShown", this);
    global.removeMessageListener("BrowserPlugins:ContextMenuCommand", this);
    global.removeMessageListener("BrowserPlugins:NPAPIPluginProcessCrashed", this);
    global.removeMessageListener("BrowserPlugins:CrashReportSubmitted", this);
    global.removeMessageListener("BrowserPlugins:Test:ClearCrashData", this);

    Services.obs.removeObserver(this, "decoder-doctor-notification");

    delete this.global;
    delete this.content;
  },

  receiveMessage: function (msg) {
    switch (msg.name) {
      case "BrowserPlugins:ActivatePlugins":
        this.activatePlugins(msg.data.pluginInfo, msg.data.newState);
        break;
      case "BrowserPlugins:NotificationShown":
        setTimeout(() => this.updateNotificationUI(), 0);
        break;
      case "BrowserPlugins:ContextMenuCommand":
        switch (msg.data.command) {
          case "play":
            this._showClickToPlayNotification(msg.objects.plugin, true);
            break;
          case "hide":
            this.hideClickToPlayOverlay(msg.objects.plugin);
            break;
        }
        break;
      case "BrowserPlugins:NPAPIPluginProcessCrashed":
        this.NPAPIPluginProcessCrashed({
          pluginName: msg.data.pluginName,
          runID: msg.data.runID,
          state: msg.data.state,
        });
        break;
      case "BrowserPlugins:CrashReportSubmitted":
        this.NPAPIPluginCrashReportSubmitted({
          runID: msg.data.runID,
          state: msg.data.state,
        })
        break;
      case "BrowserPlugins:Test:ClearCrashData":
        // This message should ONLY ever be sent by automated tests.
        if (Services.prefs.getBoolPref("plugins.testmode")) {
          this.pluginCrashData.clear();
        }
    }
  },

  observe: function observe(aSubject, aTopic, aData) {
    switch (aTopic) {
      case "decoder-doctor-notification":
        let data = JSON.parse(aData);
        if (this.haveShownNotification &&
            aSubject.top.document == this.content.document &&
            data.formats.toLowerCase().includes("application/x-mpegurl", 0)) {
          let principal = this.content.document.nodePrincipal;
          let location = this.content.document.location.href;
          this.global.content.pluginRequiresReload = true;
          this.global.sendAsyncMessage("PluginContent:ShowClickToPlayNotification",
                                       { plugins: [... this.pluginData.values()],
                                         showNow: true,
                                         location: location,
                                       }, null, principal);
        }
    }
  },

  onPageShow: function (event) {
    // Ignore events that aren't from the main document.
    if (!this.content || event.target != this.content.document) {
      return;
    }

    // The PluginClickToPlay events are not fired when navigating using the
    // BF cache. |persisted| is true when the page is loaded from the
    // BF cache, so this code reshows the notification if necessary.
    if (event.persisted) {
      this.reshowClickToPlayNotification();
    }
  },

  onPageHide: function (event) {
    // Ignore events that aren't from the main document.
    if (!this.content || event.target != this.content.document) {
      return;
    }

    this._finishRecordingFlashPluginTelemetry();
    this.clearPluginCaches();
    this.haveShownNotification = false;
  },

  getPluginUI: function (plugin, anonid) {
    return plugin.ownerDocument.
           getAnonymousElementByAttribute(plugin, "anonid", anonid);
  },

  _getPluginInfo: function (pluginElement) {
    if (pluginElement instanceof Ci.nsIDOMHTMLAnchorElement) {
      // Anchor elements are our place holders, and we only have them for Flash
      let pluginHost = Cc["@mozilla.org/plugin/host;1"].getService(Ci.nsIPluginHost);
      return {
        pluginName: "Shockwave Flash",
        mimetype: FLASH_MIME_TYPE,
        permissionString: pluginHost.getPermissionStringForType(FLASH_MIME_TYPE)
      };
    }
    let pluginHost = Cc["@mozilla.org/plugin/host;1"].getService(Ci.nsIPluginHost);
    pluginElement.QueryInterface(Ci.nsIObjectLoadingContent);

    let tagMimetype;
    let pluginName = gNavigatorBundle.GetStringFromName("pluginInfo.unknownPlugin");
    let pluginTag = null;
    let permissionString = null;
    let fallbackType = null;
    let blocklistState = null;

    tagMimetype = pluginElement.actualType;
    if (tagMimetype == "") {
      tagMimetype = pluginElement.type;
    }

    if (this.isKnownPlugin(pluginElement)) {
      pluginTag = pluginHost.getPluginTagForType(pluginElement.actualType);
      pluginName = BrowserUtils.makeNicePluginName(pluginTag.name);

      // Convert this from nsIPluginTag so it can be serialized.
      let properties = ["name", "description", "filename", "version", "enabledState", "niceName"];
      let pluginTagCopy = {};
      for (let prop of properties) {
        pluginTagCopy[prop] = pluginTag[prop];
      }
      pluginTag = pluginTagCopy;

      permissionString = pluginHost.getPermissionStringForType(pluginElement.actualType);
      fallbackType = pluginElement.defaultFallbackType;
      blocklistState = pluginHost.getBlocklistStateForType(pluginElement.actualType);
      // Make state-softblocked == state-notblocked for our purposes,
      // they have the same UI. STATE_OUTDATED should not exist for plugin
      // items, but let's alias it anyway, just in case.
      if (blocklistState == Ci.nsIBlocklistService.STATE_SOFTBLOCKED ||
          blocklistState == Ci.nsIBlocklistService.STATE_OUTDATED) {
        blocklistState = Ci.nsIBlocklistService.STATE_NOT_BLOCKED;
      }
    }

    return { mimetype: tagMimetype,
             pluginName: pluginName,
             pluginTag: pluginTag,
             permissionString: permissionString,
             fallbackType: fallbackType,
             blocklistState: blocklistState,
           };
  },

  _getPluginInfoForTag: function (pluginTag, tagMimetype) {
    let pluginHost = Cc["@mozilla.org/plugin/host;1"].getService(Ci.nsIPluginHost);

    let pluginName = gNavigatorBundle.GetStringFromName("pluginInfo.unknownPlugin");
    let permissionString = null;
    let blocklistState = null;

    if (pluginTag) {
      pluginName = BrowserUtils.makeNicePluginName(pluginTag.name);

      permissionString = pluginHost.getPermissionStringForTag(pluginTag);
      blocklistState = pluginTag.blocklistState;

      // Convert this from nsIPluginTag so it can be serialized.
      let properties = ["name", "description", "filename", "version", "enabledState", "niceName"];
      let pluginTagCopy = {};
      for (let prop of properties) {
        pluginTagCopy[prop] = pluginTag[prop];
      }
      pluginTag = pluginTagCopy;

      // Make state-softblocked == state-notblocked for our purposes,
      // they have the same UI. STATE_OUTDATED should not exist for plugin
      // items, but let's alias it anyway, just in case.
      if (blocklistState == Ci.nsIBlocklistService.STATE_SOFTBLOCKED ||
          blocklistState == Ci.nsIBlocklistService.STATE_OUTDATED) {
        blocklistState = Ci.nsIBlocklistService.STATE_NOT_BLOCKED;
      }
    }

    return { mimetype: tagMimetype,
             pluginName: pluginName,
             pluginTag: pluginTag,
             permissionString: permissionString,
             fallbackType: null,
             blocklistState: blocklistState,
           };
  },

  /**
   * Update the visibility of the plugin overlay.
   */
  setVisibility : function (plugin, overlay, shouldShow) {
    overlay.classList.toggle("visible", shouldShow);
    if (shouldShow) {
      overlay.removeAttribute("dismissed");
    }
  },

  /**
   * Check whether the plugin should be visible on the page. A plugin should
   * not be visible if the overlay is too big, or if any other page content
   * overlays it.
   *
   * This function will handle showing or hiding the overlay.
   * @returns true if the plugin is invisible.
   */
  shouldShowOverlay : function (plugin, overlay) {
    // If the overlay size is 0, we haven't done layout yet. Presume that
    // plugins are visible until we know otherwise.
    if (overlay.scrollWidth == 0) {
      return true;
    }

    // Is the <object>'s size too small to hold what we want to show?
    let pluginRect = plugin.getBoundingClientRect();
    // XXX bug 446693. The text-shadow on the submitted-report text at
    //     the bottom causes scrollHeight to be larger than it should be.
    let overflows = (overlay.scrollWidth > Math.ceil(pluginRect.width)) ||
                    (overlay.scrollHeight - 5 > Math.ceil(pluginRect.height));
    if (overflows) {
      return false;
    }

    // Is the plugin covered up by other content so that it is not clickable?
    // Floating point can confuse .elementFromPoint, so inset just a bit
    let left = pluginRect.left + 2;
    let right = pluginRect.right - 2;
    let top = pluginRect.top + 2;
    let bottom = pluginRect.bottom - 2;
    let centerX = left + (right - left) / 2;
    let centerY = top + (bottom - top) / 2;
    let points = [[left, top],
                   [left, bottom],
                   [right, top],
                   [right, bottom],
                   [centerX, centerY]];

    if (right <= 0 || top <= 0) {
      return false;
    }

    let contentWindow = plugin.ownerGlobal;
    let cwu = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
                           .getInterface(Ci.nsIDOMWindowUtils);

    for (let [x, y] of points) {
      let el = cwu.elementFromPoint(x, y, true, true);
      if (el !== plugin) {
        return false;
      }
    }

    return true;
  },

  addLinkClickCallback: function (linkNode, callbackName /* callbackArgs...*/) {
    // XXX just doing (callback)(arg) was giving a same-origin error. bug?
    let self = this;
    let callbackArgs = Array.prototype.slice.call(arguments).slice(2);
    linkNode.addEventListener("click",
                              function(evt) {
                                if (!evt.isTrusted)
                                  return;
                                evt.preventDefault();
                                if (callbackArgs.length == 0)
                                  callbackArgs = [ evt ];
                                (self[callbackName]).apply(self, callbackArgs);
                              },
                              true);

    linkNode.addEventListener("keydown",
                              function(evt) {
                                if (!evt.isTrusted)
                                  return;
                                if (evt.keyCode == evt.DOM_VK_RETURN) {
                                  evt.preventDefault();
                                  if (callbackArgs.length == 0)
                                    callbackArgs = [ evt ];
                                  evt.preventDefault();
                                  (self[callbackName]).apply(self, callbackArgs);
                                }
                              },
                              true);
  },

  // Helper to get the binding handler type from a plugin object
  _getBindingType : function(plugin) {
    if (!(plugin instanceof Ci.nsIObjectLoadingContent))
      return null;

    switch (plugin.pluginFallbackType) {
      case Ci.nsIObjectLoadingContent.PLUGIN_UNSUPPORTED:
        return "PluginNotFound";
      case Ci.nsIObjectLoadingContent.PLUGIN_DISABLED:
        return "PluginDisabled";
      case Ci.nsIObjectLoadingContent.PLUGIN_BLOCKLISTED:
        return "PluginBlocklisted";
      case Ci.nsIObjectLoadingContent.PLUGIN_OUTDATED:
        return "PluginOutdated";
      case Ci.nsIObjectLoadingContent.PLUGIN_CLICK_TO_PLAY:
        return "PluginClickToPlay";
      case Ci.nsIObjectLoadingContent.PLUGIN_VULNERABLE_UPDATABLE:
        return "PluginVulnerableUpdatable";
      case Ci.nsIObjectLoadingContent.PLUGIN_VULNERABLE_NO_UPDATE:
        return "PluginVulnerableNoUpdate";
      default:
        // Not all states map to a handler
        return null;
    }
  },

  handleEvent: function (event) {
    let eventType = event.type;

    if (eventType == "unload") {
      this.uninit();
      return;
    }

    if (eventType == "pagehide") {
      this.onPageHide(event);
      return;
    }

    if (eventType == "pageshow") {
      this.onPageShow(event);
      return;
    }

    if (eventType == "PluginRemoved") {
      this.updateNotificationUI(event.target);
      return;
    }

    if (eventType == "click") {
      this.onOverlayClick(event);
      return;
    }

    if (eventType == "PluginCrashed" &&
        !(event.target instanceof Ci.nsIObjectLoadingContent)) {
      // If the event target is not a plugin object (i.e., an <object> or
      // <embed> element), this call is for a window-global plugin.
      this.onPluginCrashed(event.target, event);
      return;
    }

    if (eventType == "HiddenPlugin") {
      let win = event.target.defaultView;
      if (!win.mozHiddenPluginTouched) {
        let pluginTag = event.tag.QueryInterface(Ci.nsIPluginTag);
        if (win.top.document != this.content.document) {
          return;
        }
        this._showClickToPlayNotification(pluginTag, false);
        let winUtils = win.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils);
        try {
          winUtils.loadSheet(REPLACEMENT_STYLE_SHEET, win.AGENT_SHEET);
          win.mozHiddenPluginTouched = true;
        } catch (e) {
          Cu.reportError("Error adding plugin replacement style sheet: " + e);
        }
      }
    }

    let plugin = event.target;

    if (eventType == "PluginPlaceholderReplaced") {
      plugin.removeAttribute("href");
      let overlay = this.getPluginUI(plugin, "main");
      this.setVisibility(plugin, overlay, true);
      let inIDOMUtils = Cc["@mozilla.org/inspector/dom-utils;1"]
                          .getService(Ci.inIDOMUtils);
      // Add psuedo class so our styling will take effect
      inIDOMUtils.addPseudoClassLock(plugin, "-moz-handler-clicktoplay");
      overlay.addEventListener("click", this, true);
      return;
    }

    if (!(plugin instanceof Ci.nsIObjectLoadingContent))
      return;

    if (eventType == "PluginBindingAttached") {
      // The plugin binding fires this event when it is created.
      // As an untrusted event, ensure that this object actually has a binding
      // and make sure we don't handle it twice
      let overlay = this.getPluginUI(plugin, "main");
      if (!overlay || overlay._bindingHandled) {
        return;
      }
      overlay._bindingHandled = true;

      // Lookup the handler for this binding
      eventType = this._getBindingType(plugin);
      if (!eventType) {
        // Not all bindings have handlers
        return;
      }
    }

    let shouldShowNotification = false;
    switch (eventType) {
      case "PluginCrashed":
        this.onPluginCrashed(plugin, event);
        break;

      case "PluginNotFound": {
        /* NOP */
        break;
      }

      case "PluginBlocklisted":
      case "PluginOutdated":
        shouldShowNotification = true;
        break;

      case "PluginVulnerableUpdatable":
        let updateLink = this.getPluginUI(plugin, "checkForUpdatesLink");
        let { pluginTag } = this._getPluginInfo(plugin);
        this.addLinkClickCallback(updateLink, "forwardCallback",
                                  "openPluginUpdatePage", pluginTag);
        /* FALLTHRU */

      case "PluginVulnerableNoUpdate":
      case "PluginClickToPlay":
        this._handleClickToPlayEvent(plugin);
        let pluginName = this._getPluginInfo(plugin).pluginName;
        let messageString = gNavigatorBundle.formatStringFromName("PluginClickToActivate", [pluginName], 1);
        let overlayText = this.getPluginUI(plugin, "clickToPlay");
        overlayText.textContent = messageString;
        if (eventType == "PluginVulnerableUpdatable" ||
            eventType == "PluginVulnerableNoUpdate") {
          let vulnerabilityString = gNavigatorBundle.GetStringFromName(eventType);
          let vulnerabilityText = this.getPluginUI(plugin, "vulnerabilityStatus");
          vulnerabilityText.textContent = vulnerabilityString;
        }
        shouldShowNotification = true;
        break;

      case "PluginDisabled":
        let manageLink = this.getPluginUI(plugin, "managePluginsLink");
        this.addLinkClickCallback(manageLink, "forwardCallback", "managePlugins");
        shouldShowNotification = true;
        break;

      case "PluginInstantiated":
        let key = this._getPluginInfo(plugin).pluginTag.niceName;
        Services.telemetry.getKeyedHistogramById('PLUGIN_ACTIVATION_COUNT').add(key);
        shouldShowNotification = true;
        let pluginRect = plugin.getBoundingClientRect();
        if (pluginRect.width <= 5 && pluginRect.height <= 5) {
          Services.telemetry.getHistogramById('PLUGIN_TINY_CONTENT').add(1);
        }
        break;
    }

    if (this._getPluginInfo(plugin).mimetype === FLASH_MIME_TYPE) {
      this._recordFlashPluginTelemetry(eventType, plugin);
    }

    // Show the in-content UI if it's not too big. The crashed plugin handler already did this.
    let overlay = this.getPluginUI(plugin, "main");
    if (eventType != "PluginCrashed") {
      if (overlay != null) {
        this.setVisibility(plugin, overlay,
                           this.shouldShowOverlay(plugin, overlay));
        let resizeListener = (event) => {
          this.setVisibility(plugin, overlay,
            this.shouldShowOverlay(plugin, overlay));
          this.updateNotificationUI();
        };
        plugin.addEventListener("overflow", resizeListener);
        plugin.addEventListener("underflow", resizeListener);
      }
    }

    let closeIcon = this.getPluginUI(plugin, "closeIcon");
    if (closeIcon) {
      closeIcon.addEventListener("click", event => {
        if (event.button == 0 && event.isTrusted) {
          this.hideClickToPlayOverlay(plugin);
          overlay.setAttribute("dismissed", "true");
        }
      }, true);
    }

    if (shouldShowNotification) {
      this._showClickToPlayNotification(plugin, false);
    }
  },

  _recordFlashPluginTelemetry: function (eventType, plugin) {
    if (!Services.telemetry.canRecordExtended) {
      return;
    }

    if (!this.flashPluginStats) {
      this.flashPluginStats = {
        instancesCount: 0,
        plugins: new WeakSet()
      };
    }

    if (!this.flashPluginStats.plugins.has(plugin)) {
      // Reporting plugin instance and its dimensions only once.
      this.flashPluginStats.plugins.add(plugin);

      this.flashPluginStats.instancesCount++;

      let pluginRect = plugin.getBoundingClientRect();
      Services.telemetry.getHistogramById('FLASH_PLUGIN_WIDTH')
                       .add(pluginRect.width);
      Services.telemetry.getHistogramById('FLASH_PLUGIN_HEIGHT')
                       .add(pluginRect.height);
      Services.telemetry.getHistogramById('FLASH_PLUGIN_AREA')
                       .add(pluginRect.width * pluginRect.height);

      let state = this._getPluginInfo(plugin).fallbackType;
      if (state === null) {
        state = Ci.nsIObjectLoadingContent.PLUGIN_UNSUPPORTED;
      }
      Services.telemetry.getHistogramById('FLASH_PLUGIN_STATES')
                       .add(state);
    }
  },

  _finishRecordingFlashPluginTelemetry: function () {
    if (this.flashPluginStats) {
      Services.telemetry.getHistogramById('FLASH_PLUGIN_INSTANCES_ON_PAGE')
                        .add(this.flashPluginStats.instancesCount);
    delete this.flashPluginStats;
    }
  },

  isKnownPlugin: function (objLoadingContent) {
    return (objLoadingContent.getContentTypeForMIMEType(objLoadingContent.actualType) ==
            Ci.nsIObjectLoadingContent.TYPE_PLUGIN);
  },

  canActivatePlugin: function (objLoadingContent) {
    // if this isn't a known plugin, we can't activate it
    // (this also guards pluginHost.getPermissionStringForType against
    // unexpected input)
    if (!this.isKnownPlugin(objLoadingContent))
      return false;

    let pluginHost = Cc["@mozilla.org/plugin/host;1"].getService(Ci.nsIPluginHost);
    let permissionString = pluginHost.getPermissionStringForType(objLoadingContent.actualType);
    let principal = objLoadingContent.ownerGlobal.top.document.nodePrincipal;
    let pluginPermission = Services.perms.testPermissionFromPrincipal(principal, permissionString);

    let isFallbackTypeValid =
      objLoadingContent.pluginFallbackType >= Ci.nsIObjectLoadingContent.PLUGIN_CLICK_TO_PLAY &&
      objLoadingContent.pluginFallbackType <= Ci.nsIObjectLoadingContent.PLUGIN_VULNERABLE_NO_UPDATE;

    return !objLoadingContent.activated &&
           pluginPermission != Ci.nsIPermissionManager.DENY_ACTION &&
           isFallbackTypeValid;
  },

  hideClickToPlayOverlay: function (plugin) {
    let overlay = this.getPluginUI(plugin, "main");
    if (overlay) {
      overlay.classList.remove("visible");
    }
  },

  // Forward a link click callback to the chrome process.
  forwardCallback: function (name, pluginTag) {
    this.global.sendAsyncMessage("PluginContent:LinkClickCallback",
      { name, pluginTag });
  },

  submitReport: function submitReport(plugin) {
    if (!AppConstants.MOZ_CRASHREPORTER) {
      return;
    }
    if (!plugin) {
      Cu.reportError("Attempted to submit crash report without an associated plugin.");
      return;
    }
    if (!(plugin instanceof Ci.nsIObjectLoadingContent)) {
      Cu.reportError("Attempted to submit crash report on plugin that does not" +
                     "implement nsIObjectLoadingContent.");
      return;
    }

    let runID = plugin.runID;
    let submitURLOptIn = this.getPluginUI(plugin, "submitURLOptIn").checked;
    let keyVals = {};
    let userComment = this.getPluginUI(plugin, "submitComment").value.trim();
    if (userComment)
      keyVals.PluginUserComment = userComment;
    if (submitURLOptIn)
      keyVals.PluginContentURL = plugin.ownerDocument.URL;

    this.global.sendAsyncMessage("PluginContent:SubmitReport",
                                 { runID, keyVals, submitURLOptIn });
  },

  reloadPage: function () {
    this.global.content.location.reload();
  },

  // Event listener for click-to-play plugins.
  _handleClickToPlayEvent: function (plugin) {
    let doc = plugin.ownerDocument;
    let pluginHost = Cc["@mozilla.org/plugin/host;1"].getService(Ci.nsIPluginHost);
    let permissionString;
    if (plugin instanceof Ci.nsIDOMHTMLAnchorElement) {
      // We only have replacement content for Flash installs
      permissionString = pluginHost.getPermissionStringForType(FLASH_MIME_TYPE);
    } else {
      let objLoadingContent = plugin.QueryInterface(Ci.nsIObjectLoadingContent);
      // guard against giving pluginHost.getPermissionStringForType a type
      // not associated with any known plugin
      if (!this.isKnownPlugin(objLoadingContent))
        return;
      permissionString = pluginHost.getPermissionStringForType(objLoadingContent.actualType);
    }

    let principal = doc.defaultView.top.document.nodePrincipal;
    let pluginPermission = Services.perms.testPermissionFromPrincipal(principal, permissionString);

    let overlay = this.getPluginUI(plugin, "main");

    if (pluginPermission == Ci.nsIPermissionManager.DENY_ACTION) {
      if (overlay) {
        overlay.classList.remove("visible");
      }
      return;
    }

    if (overlay) {
      overlay.addEventListener("click", this, true);
    }
  },

  onOverlayClick: function (event) {
    let document = event.target.ownerDocument;
    let plugin = document.getBindingParent(event.target);
    let contentWindow = plugin.ownerGlobal.top;
    let overlay = this.getPluginUI(plugin, "main");
    // Have to check that the target is not the link to update the plugin
    if (!(event.originalTarget instanceof contentWindow.HTMLAnchorElement) &&
        (event.originalTarget.getAttribute('anonid') != 'closeIcon') &&
        !overlay.hasAttribute('dismissed') &&
        event.button == 0 &&
        event.isTrusted) {
      this._showClickToPlayNotification(plugin, true);
    event.stopPropagation();
    event.preventDefault();
    }
  },

  reshowClickToPlayNotification: function () {
    let contentWindow = this.global.content;
    let cwu = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
                           .getInterface(Ci.nsIDOMWindowUtils);
    let plugins = cwu.plugins;
    for (let plugin of plugins) {
      let overlay = this.getPluginUI(plugin, "main");
      if (overlay)
        overlay.removeEventListener("click", this, true);
      let objLoadingContent = plugin.QueryInterface(Ci.nsIObjectLoadingContent);
      if (this.canActivatePlugin(objLoadingContent))
        this._handleClickToPlayEvent(plugin);
    }
    this._showClickToPlayNotification(null, false);
  },

  /**
   * Activate the plugins that the user has specified.
   */
  activatePlugins: function (pluginInfo, newState) {
    let contentWindow = this.global.content;
    let cwu = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
                           .getInterface(Ci.nsIDOMWindowUtils);
    let plugins = cwu.plugins;
    let pluginHost = Cc["@mozilla.org/plugin/host;1"].getService(Ci.nsIPluginHost);

    let pluginFound = false;
    let placeHolderFound = false;
    for (let plugin of plugins) {
      plugin.QueryInterface(Ci.nsIObjectLoadingContent);
      if (!this.isKnownPlugin(plugin)) {
        continue;
      }
      if (pluginInfo.permissionString == pluginHost.getPermissionStringForType(plugin.actualType)) {
        let overlay = this.getPluginUI(plugin, "main");
        if (plugin instanceof Ci.nsIDOMHTMLAnchorElement) {
          placeHolderFound = true;
        } else {
          pluginFound = true;
        }
        if (newState == "block") {
          if (overlay) {
            overlay.addEventListener("click", this, true);
          }
          plugin.reload(true);
        } else if (this.canActivatePlugin(plugin)) {
          if (overlay) {
            overlay.removeEventListener("click", this, true);
          }
          plugin.playPlugin();
        }
      }
    }

    // If there are no instances of the plugin on the page any more, what the
    // user probably needs is for us to allow and then refresh. Additionally, if
    // this is content that requires HLS or we replaced the placeholder the page
    // needs to be refreshed for it to insert its plugins
    if (newState != "block" &&
       (!pluginFound || placeHolderFound || contentWindow.pluginRequiresReload)) {
      this.reloadPage();
    }
    this.updateNotificationUI();
  },

  _showClickToPlayNotification: function (plugin, showNow) {
    let plugins = [];

    // If plugin is null, that means the user has navigated back to a page with
    // plugins, and we need to collect all the plugins.
    if (plugin === null) {
      let contentWindow = this.content;
      let cwu = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
                             .getInterface(Ci.nsIDOMWindowUtils);
      // cwu.plugins may contain non-plugin <object>s, filter them out
      plugins = cwu.plugins.filter((plugin) =>
        plugin.getContentTypeForMIMEType(plugin.actualType) == Ci.nsIObjectLoadingContent.TYPE_PLUGIN);

      if (plugins.length == 0) {
        this.removeNotification("click-to-play-plugins");
        return;
      }
    } else {
      plugins = [plugin];
    }

    let pluginData = this.pluginData;

    let principal = this.content.document.nodePrincipal;
    let location = this.content.document.location.href;

    for (let p of plugins) {
      let pluginInfo;
      if (p instanceof Ci.nsIPluginTag) {
        let mimeType = p.getMimeTypes() > 0 ? p.getMimeTypes()[0] : null;
        pluginInfo = this._getPluginInfoForTag(p, mimeType);
      } else {
        pluginInfo = this._getPluginInfo(p);
      }
      if (pluginInfo.permissionString === null) {
        Cu.reportError("No permission string for active plugin.");
        continue;
      }
      if (pluginData.has(pluginInfo.permissionString)) {
        continue;
      }

      let permissionObj = Services.perms.
        getPermissionObject(principal, pluginInfo.permissionString, false);
      if (permissionObj) {
        pluginInfo.pluginPermissionPrePath = permissionObj.principal.originNoSuffix;
        pluginInfo.pluginPermissionType = permissionObj.expireType;
      }
      else {
        pluginInfo.pluginPermissionPrePath = principal.originNoSuffix;
        pluginInfo.pluginPermissionType = undefined;
      }

      this.pluginData.set(pluginInfo.permissionString, pluginInfo);
    }

    this.haveShownNotification = true;

    this.global.sendAsyncMessage("PluginContent:ShowClickToPlayNotification", {
      plugins: [... this.pluginData.values()],
      showNow: showNow,
      location: location,
    }, null, principal);
  },

  /**
   * Updates the "hidden plugin" notification bar UI.
   *
   * @param document (optional)
   *        Specify the document that is causing the update.
   *        This is useful when the document is possibly no longer
   *        the current loaded document (for example, if we're
   *        responding to a PluginRemoved event for an unloading
   *        document). If this parameter is omitted, it defaults
   *        to the current top-level document.
   */
  updateNotificationUI: function (document) {
    document = document || this.content.document;

    // We're only interested in the top-level document, since that's
    // the one that provides the Principal that we send back to the
    // parent.
    let principal = document.defaultView.top.document.nodePrincipal;
    let location = document.location.href;

    // Make a copy of the actions from the last popup notification.
    let haveInsecure = false;
    let actions = new Map();
    for (let action of this.pluginData.values()) {
      switch (action.fallbackType) {
        // haveInsecure will trigger the red flashing icon and the infobar
        // styling below
        case Ci.nsIObjectLoadingContent.PLUGIN_VULNERABLE_UPDATABLE:
        case Ci.nsIObjectLoadingContent.PLUGIN_VULNERABLE_NO_UPDATE:
          haveInsecure = true;
          // fall through

        case Ci.nsIObjectLoadingContent.PLUGIN_CLICK_TO_PLAY:
          actions.set(action.permissionString, action);
          continue;
      }
    }

    // Remove plugins that are already active, or large enough to show an overlay.
    let cwu = this.content.QueryInterface(Ci.nsIInterfaceRequestor)
                          .getInterface(Ci.nsIDOMWindowUtils);
    for (let plugin of cwu.plugins) {
      let info = this._getPluginInfo(plugin);
      if (!actions.has(info.permissionString)) {
        continue;
      }
      let fallbackType = info.fallbackType;
      if (fallbackType == Ci.nsIObjectLoadingContent.PLUGIN_ACTIVE) {
        actions.delete(info.permissionString);
        if (actions.size == 0) {
          break;
        }
        continue;
      }
      if (fallbackType != Ci.nsIObjectLoadingContent.PLUGIN_CLICK_TO_PLAY &&
          fallbackType != Ci.nsIObjectLoadingContent.PLUGIN_VULNERABLE_UPDATABLE &&
          fallbackType != Ci.nsIObjectLoadingContent.PLUGIN_VULNERABLE_NO_UPDATE) {
        continue;
      }
      let overlay = this.getPluginUI(plugin, "main");
      if (!overlay) {
        continue;
      }
      let shouldShow = this.shouldShowOverlay(plugin, overlay);
      this.setVisibility(plugin, overlay, shouldShow);
      if (shouldShow) {
        actions.delete(info.permissionString);
        if (actions.size == 0) {
          break;
        }
      }
    }

    // If there are any items remaining in `actions` now, they are hidden
    // plugins that need a notification bar.
    this.global.sendAsyncMessage("PluginContent:UpdateHiddenPluginUI", {
      haveInsecure: haveInsecure,
      actions: [... actions.values()],
      location: location,
    }, null, principal);
  },

  removeNotification: function (name) {
    this.global.sendAsyncMessage("PluginContent:RemoveNotification", { name: name });
  },

  clearPluginCaches: function () {
    this.pluginData.clear();
    this.pluginCrashData.clear();
  },

  hideNotificationBar: function (name) {
    this.global.sendAsyncMessage("PluginContent:HideNotificationBar", { name: name });
  },

  /**
   * The PluginCrashed event handler. Note that the PluginCrashed event is
   * fired for both NPAPI and Gecko Media plugins. In the latter case, the
   * target of the event is the document that the GMP is being used in.
   */
  onPluginCrashed: function (target, aEvent) {
    if (!(aEvent instanceof this.content.PluginCrashedEvent))
      return;

    if (aEvent.gmpPlugin) {
      this.GMPCrashed(aEvent);
      return;
    }

    if (!(target instanceof Ci.nsIObjectLoadingContent))
      return;

    let crashData = this.pluginCrashData.get(target.runID);
    if (!crashData) {
      // We haven't received information from the parent yet about
      // this crash, so we should hold off showing the crash report
      // UI.
      return;
    }

    crashData.instances.delete(target);
    if (crashData.instances.length == 0) {
      this.pluginCrashData.delete(target.runID);
    }

    this.setCrashedNPAPIPluginState({
      plugin: target,
      state: crashData.state,
      message: crashData.message,
    });
  },

  NPAPIPluginProcessCrashed: function ({pluginName, runID, state}) {
    let message =
      gNavigatorBundle.formatStringFromName("crashedpluginsMessage.title",
                                            [pluginName], 1);

    let contentWindow = this.global.content;
    let cwu = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
                           .getInterface(Ci.nsIDOMWindowUtils);
    let plugins = cwu.plugins;

    for (let plugin of plugins) {
      if (plugin instanceof Ci.nsIObjectLoadingContent &&
          plugin.runID == runID) {
        // The parent has told us that the plugin process has died.
        // It's possible that this content process hasn't yet noticed,
        // in which case we need to stash this data around until the
        // PluginCrashed events get sent up.
        if (plugin.pluginFallbackType == Ci.nsIObjectLoadingContent.PLUGIN_CRASHED) {
          // This plugin has already been put into the crashed state by the
          // content process, so we can tweak its crash UI without delay.
          this.setCrashedNPAPIPluginState({plugin, state, message});
        } else {
          // The content process hasn't yet determined that the plugin has crashed.
          // Stash the data in our map, and throw the plugin into a WeakSet. When
          // the PluginCrashed event fires on the <object>/<embed>, we'll retrieve
          // the information we need from the Map and remove the instance from the
          // WeakSet. Once the WeakSet is empty, we can clear the map.
          if (!this.pluginCrashData.has(runID)) {
            this.pluginCrashData.set(runID, {
              state: state,
              message: message,
              instances: new WeakSet(),
            });
          }
          let crashData = this.pluginCrashData.get(runID);
          crashData.instances.add(plugin);
        }
      }
    }
  },

  setCrashedNPAPIPluginState: function ({plugin, state, message}) {
    // Force a layout flush so the binding is attached.
    plugin.clientTop;
    let overlay = this.getPluginUI(plugin, "main");
    let statusDiv = this.getPluginUI(plugin, "submitStatus");
    let optInCB = this.getPluginUI(plugin, "submitURLOptIn");

    this.getPluginUI(plugin, "submitButton")
        .addEventListener("click", (event) => {
          if (event.button != 0 || !event.isTrusted)
            return;
          this.submitReport(plugin);
        });

    let pref = Services.prefs.getBranch("dom.ipc.plugins.reportCrashURL");
    optInCB.checked = pref.getBoolPref("");

    statusDiv.setAttribute("status", state);

    let helpIcon = this.getPluginUI(plugin, "helpIcon");
    this.addLinkClickCallback(helpIcon, "openHelpPage");

    let crashText = this.getPluginUI(plugin, "crashedText");
    crashText.textContent = message;

    let link = this.getPluginUI(plugin, "reloadLink");
    this.addLinkClickCallback(link, "reloadPage");

    let isShowing = this.shouldShowOverlay(plugin, overlay);

    // Is the <object>'s size too small to hold what we want to show?
    if (!isShowing) {
      // First try hiding the crash report submission UI.
      statusDiv.removeAttribute("status");

      isShowing = this.shouldShowOverlay(plugin, overlay);
    }
    this.setVisibility(plugin, overlay, isShowing);

    let doc = plugin.ownerDocument;
    let runID = plugin.runID;

    if (isShowing) {
      // If a previous plugin on the page was too small and resulted in adding a
      // notification bar, then remove it because this plugin instance it big
      // enough to serve as in-content notification.
      this.hideNotificationBar("plugin-crashed");
      doc.mozNoPluginCrashedNotification = true;

      // Notify others that the crash reporter UI is now ready.
      // Currently, this event is only used by tests.
      let winUtils = this.content.QueryInterface(Ci.nsIInterfaceRequestor)
                                 .getInterface(Ci.nsIDOMWindowUtils);
      let event = new this.content.CustomEvent("PluginCrashReporterDisplayed", {bubbles: true});
      winUtils.dispatchEventToChromeOnly(plugin, event);
    } else if (!doc.mozNoPluginCrashedNotification) {
      // If another plugin on the page was large enough to show our UI, we don't
      // want to show a notification bar.
      this.global.sendAsyncMessage("PluginContent:ShowPluginCrashedNotification",
                                   { messageString: message, pluginID: runID });
      // Remove the notification when the page is reloaded.
      doc.defaultView.top.addEventListener("unload", event => {
        this.hideNotificationBar("plugin-crashed");
      }, false);
    }
  },

  NPAPIPluginCrashReportSubmitted: function({ runID, state }) {
    this.pluginCrashData.delete(runID);
    let contentWindow = this.global.content;
    let cwu = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
                           .getInterface(Ci.nsIDOMWindowUtils);
    let plugins = cwu.plugins;

    for (let plugin of plugins) {
      if (plugin instanceof Ci.nsIObjectLoadingContent &&
          plugin.runID == runID) {
        let statusDiv = this.getPluginUI(plugin, "submitStatus");
        statusDiv.setAttribute("status", state);
      }
    }
  },

  GMPCrashed: function(aEvent) {
    let target          = aEvent.target;
    let pluginName      = aEvent.pluginName;
    let gmpPlugin       = aEvent.gmpPlugin;
    let pluginID        = aEvent.pluginID;
    let doc             = target.document;

    if (!gmpPlugin || !doc) {
      // TODO: Throw exception? How did we get here?
      return;
    }

    let messageString =
      gNavigatorBundle.formatStringFromName("crashedpluginsMessage.title",
                                            [pluginName], 1);

    this.global.sendAsyncMessage("PluginContent:ShowPluginCrashedNotification",
                                 { messageString, pluginID });

    // Remove the notification when the page is reloaded.
    doc.defaultView.top.addEventListener("unload", event => {
      this.hideNotificationBar("plugin-crashed");
    }, false);
  },
};
