/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* 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/. */

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

const STRING_BUNDLE_URI = "chrome://browser/locale/feeds/subscribe.properties";

export function WebProtocolHandlerRegistrar() {}

const lazy = {};

XPCOMUtils.defineLazyServiceGetters(lazy, {
  ExternalProtocolService: [
    "@mozilla.org/uriloader/external-protocol-service;1",
    "nsIExternalProtocolService",
  ],
});

ChromeUtils.defineESModuleGetters(lazy, {
  NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
});

ChromeUtils.defineLazyGetter(lazy, "log", () => {
  let { ConsoleAPI } = ChromeUtils.importESModule(
    "resource://gre/modules/Console.sys.mjs"
  );
  let consoleOptions = {
    // tip: set maxLogLevel to "debug" and use lazy.log.debug() to create
    // detailed messages during development. See LOG_LEVELS in Console.sys.mjs
    // for details.
    maxLogLevel: "warning",
    maxLogLevelPref: "browser.protocolhandler.loglevel",
    prefix: "WebProtocolHandlerRegistrar.sys.mjs",
  };
  return new ConsoleAPI(consoleOptions);
});

WebProtocolHandlerRegistrar.prototype = {
  get stringBundle() {
    let sb = Services.strings.createBundle(STRING_BUNDLE_URI);
    delete WebProtocolHandlerRegistrar.prototype.stringBundle;
    return (WebProtocolHandlerRegistrar.prototype.stringBundle = sb);
  },

  _getFormattedString(key, params) {
    return this.stringBundle.formatStringFromName(key, params);
  },

  _getString(key) {
    return this.stringBundle.GetStringFromName(key);
  },

  /* Because we want to iterate over the known webmailers in the observe method
   * and with each site visited, we want to check as fast as possible if the
   * current site is already registered as a mailto handler. Using the sites
   * domain name as a key ensures that we can use Map.has(...) later to find
   * it.
   */
  _addedObservers: 0,
  _knownWebmailerCache: new Map(),
  _ensureWebmailerCache() {
    this._knownWebmailerCache = new Map();

    const handler =
      lazy.ExternalProtocolService.getProtocolHandlerInfo("mailto");

    for (const h of handler.possibleApplicationHandlers.enumerate()) {
      // Services.io.newURI could fail for broken handlers in which case we
      // simply leave them out, but write a debug message (just in case)
      try {
        if (h instanceof Ci.nsIWebHandlerApp && h.uriTemplate) {
          const mailerUri = Services.io.newURI(h.uriTemplate);
          if (mailerUri.scheme == "https") {
            this._knownWebmailerCache.set(
              Services.io.newURI(h.uriTemplate).host,
              {
                uriPath: Services.io.newURI(h.uriTemplate).resolve("."),
                uriTemplate: Services.io.newURI(h.uriTemplate),
                name: h.name,
              }
            );
          }
        }
      } catch (e) {
        lazy.log.debug(`Could not add ${h.uriTemplate} to cache: ${e.message}`);
      }
    }
  },

  /* This function can be called multiple times and (re-)initializes the cache
   * if the feature is toggled on. If called with the feature off it will also
   * unregister the observers.
   *
   * @param {boolean} firstInit
   *
   */
  init(firstInit = false) {
    if (firstInit) {
      lazy.NimbusFeatures.mailto.onUpdate(() =>
        // make firstInit explicitly false to avoid multiple registrations.
        this.init(false)
      );
    }

    const observers = ["mailto::onLocationChange", "mailto::onClearCache"];
    if (
      lazy.NimbusFeatures.mailto.getVariable("dualPrompt") &&
      lazy.NimbusFeatures.mailto.getVariable("dualPrompt.onLocationChange")
    ) {
      this._ensureWebmailerCache();
      // Make sure, that our local observers are never registered twice:
      if (0 == this._addedObservers) {
        observers.forEach(o => {
          this._addedObservers++;
          Services.obs.addObserver(this, o);
        });
        lazy.log.debug(`mailto observers activated: [${observers}]`);
      }
    } else {
      // With `dualPrompt` and `dualPrompt.onLocationChange` toggled on we get
      // up to two notifications when we turn the feature off again, but we
      // only want to unregister the observers once.
      //
      // Using `hasObservers` would allow us to loop over all observers as long
      // as there are more, but hasObservers is not implemented hence why we
      // use `enumerateObservers` here to create the loop and `hasMoreElements`
      // to return true or false as `hasObservers` would if it existed.
      observers.forEach(o => {
        if (
          0 < this._addedObservers &&
          Services.obs.enumerateObservers(o).hasMoreElements()
        ) {
          Services.obs.removeObserver(this, o);
          this._addedObservers--;
          lazy.log.debug(`mailto observer "${o}" deactivated.`);
        }
      });
    }
  },

  async observe(aBrowser, aTopic) {
    try {
      switch (aTopic) {
        case "mailto::onLocationChange": {
          // registerProtocolHandler only works for https
          const uri = aBrowser.currentURI;
          if (!uri?.schemeIs("https")) {
            return;
          }

          const host = uri.host;
          if (this._knownWebmailerCache.has(host)) {
            // second: search the cache for an entry which starts with the path
            // of the current uri. If it exists we identified the current page as
            // webmailer (again).
            const value = this._knownWebmailerCache.get(host);
            this._askUserToSetMailtoHandler(
              aBrowser,
              "mailto",
              value.uriTemplate,
              value.name
            );
          }
          break; // the switch(topic) statement
        }
        case "mailto::onClearCache":
          // clear the cache for now. We could try to dynamically update the
          // cache, which is easy if a webmailer is added to the settings, but
          // becomes more complicated when webmailers are removed, because then
          // the store gets rewritten and we would require an event to deal with
          // that as well. So instead we recreate it entirely.
          this._ensureWebmailerCache();
          break;
        default:
          lazy.log.debug(`observe reached with unknown topic: ${aTopic}`);
      }
    } catch (e) {
      lazy.log.debug(`Problem in observer: ${e}`);
    }
  },

  /**
   * See nsIWebProtocolHandlerRegistrar
   */
  removeProtocolHandler(aProtocol, aURITemplate) {
    let handlerInfo =
      lazy.ExternalProtocolService.getProtocolHandlerInfo(aProtocol);
    let handlers = handlerInfo.possibleApplicationHandlers;
    for (let i = 0; i < handlers.length; i++) {
      try {
        // We only want to test web handlers
        let handler = handlers.queryElementAt(i, Ci.nsIWebHandlerApp);
        if (handler.uriTemplate == aURITemplate) {
          handlers.removeElementAt(i);
          let hs = Cc["@mozilla.org/uriloader/handler-service;1"].getService(
            Ci.nsIHandlerService
          );
          hs.store(handlerInfo);
          return;
        }
      } catch (e) {
        /* it wasn't a web handler */
      }
    }
  },

  /**
   * Determines if a web handler is already registered.
   *
   * @param {string} aProtocol
   *        The scheme of the web handler we are checking for.
   * @param {string} aURITemplate
   *        The URI template that the handler uses to handle the protocol.
   * @returns {boolean} true if it is already registered, false otherwise.
   */
  _protocolHandlerRegistered(aProtocol, aURITemplate) {
    let handlerInfo =
      lazy.ExternalProtocolService.getProtocolHandlerInfo(aProtocol);
    let handlers = handlerInfo.possibleApplicationHandlers;
    for (let handler of handlers.enumerate()) {
      // We only want to test web handlers
      if (
        handler instanceof Ci.nsIWebHandlerApp &&
        handler.uriTemplate == aURITemplate
      ) {
        return true;
      }
    }
    return false;
  },

  /**
   * Returns true if aURITemplate.spec points to the currently configured
   * handler for aProtocol links and the OS default is also readily configured.
   * Returns false if some of it can be made default.
   *
   * @param {string} aProtocol
   *        The scheme of the web handler we are checking for.
   * @param {string} aURITemplate
   *        The URI template that the handler uses to handle the protocol.
   */
  _isProtocolHandlerDefault(aProtocol, aURITemplate) {
    const handlerInfo =
      lazy.ExternalProtocolService.getProtocolHandlerInfo(aProtocol);

    if (
      handlerInfo.preferredAction == Ci.nsIHandlerInfo.useHelperApp &&
      handlerInfo.preferredApplicationHandler instanceof Ci.nsIWebHandlerApp
    ) {
      let webHandlerApp =
        handlerInfo.preferredApplicationHandler.QueryInterface(
          Ci.nsIWebHandlerApp
        );

      // If we are already configured as default, we cannot set a new default
      // and if the current site is already registered as default webmailer we
      // are fully set up as the default app for webmail.
      if (
        !this._canSetOSDefault(aProtocol) &&
        webHandlerApp.uriTemplate == aURITemplate.spec
      ) {
        return true;
      }
    }
    return false;
  },

  /**
   * Private method to return the installHash, which is important for app
   * registration on OS level. Without it apps cannot be default/handler apps
   * under Windows. Because this value is used to check if its possible to reset
   * the default and to actually set it as well, this function puts the
   * acquisition of the installHash in one place in the hopes that check and set
   * conditions will never deviate.
   *
   * @returns {string} installHash
   */
  _getInstallHash() {
    const xreDirProvider = Cc[
      "@mozilla.org/xre/directory-provider;1"
    ].getService(Ci.nsIXREDirProvider);
    return xreDirProvider.getInstallHash();
  },

  /* Private method to check if we are already the default protocolhandler
   * for `protocol`.
   *
   * @param {string} protocol name, e.g. mailto (without ://)
   * @returns {boolean}
   */
  _isOsDefault(protocol) {
    let shellService = Cc[
      "@mozilla.org/browser/shell-service;1"
    ].createInstance(Ci.nsIWindowsShellService);

    if (shellService.isDefaultHandlerFor(protocol)) {
      lazy.log.debug("_isOsDefault returns true.");
      return true;
    }

    lazy.log.debug("_isOsDefault returns false.");
    return false;
  },

  /**
   * Private method to determine if we can set a new OS default for a certain
   * protocol.
   *
   * @param {string} protocol name, e.g. mailto (without ://)
   * @returns {boolean}
   */
  _canSetOSDefault(protocol) {
    // an installHash is required for the association with a scheme handler,
    // also see _setOSDefault()
    if ("" == this._getInstallHash()) {
      lazy.log.debug("_canSetOSDefault returns false.");
      return false;
    }

    if (this._isOsDefault(protocol)) {
      return false;
    }

    return true;
  },

  /**
   * Private method to reset the OS default for a certain protocol/uri scheme.
   * We basically ignore, that setDefaultExtensionHandlersUserChoice can fail
   * when the installHash is wrong or cannot be determined.
   *
   * @param {string} protocol name, e.g. mailto (without ://)
   * @returns {boolean}
   */
  _setOSDefault(protocol) {
    try {
      let defaultAgent = Cc["@mozilla.org/default-agent;1"].createInstance(
        Ci.nsIDefaultAgent
      );
      defaultAgent.setDefaultExtensionHandlersUserChoice(
        this._getInstallHash(),
        [protocol, "FirefoxURL"]
      );
      return true;
    } catch (e) {
      // TODO: why could not we just add the installHash and promote the running
      // install to be a properly installed one?
      lazy.log.debug(
        "Could not set Firefox as default application for " +
          protocol +
          ", because: " +
          e.message
      );
    }
    return false;
  },

  /**
   * Private method to set the default uri to handle a certain protocol. This
   * automates in a way what a user can do in settings under applications,
   * where different 'actions' can be chosen for different 'content types'.
   *
   * @param {string} protocol
   * @param {handler} handler
   */
  _setProtocolHandlerDefault(protocol, handler) {
    let handlerInfo =
      lazy.ExternalProtocolService.getProtocolHandlerInfo(protocol);
    handlerInfo.preferredAction = Ci.nsIHandlerInfo.useHelperApp;
    handlerInfo.preferredApplicationHandler = handler;
    handlerInfo.alwaysAskBeforeHandling = false;
    let hs = Cc["@mozilla.org/uriloader/handler-service;1"].getService(
      Ci.nsIHandlerService
    );
    hs.store(handlerInfo);
    return handlerInfo;
  },

  /**
   * Private method to add a ProtocolHandler of type nsIWebHandlerApp to the
   * list of possible handlers for a protocol.
   *
   * @param {string} protocol - e.g. 'mailto', so again without ://
   * @param {string} name - the protocol associated 'Action'
   * @param {string} uri - the uri (compare 'use other...' in the preferences)
   * @returns {handler} handler - either the existing one or a newly created
   */
  _addWebProtocolHandler(protocol, name, uri) {
    let phi = lazy.ExternalProtocolService.getProtocolHandlerInfo(protocol);
    // not adding duplicates and bail out with the existing entry
    for (let h of phi.possibleApplicationHandlers.enumerate()) {
      if (h instanceof Ci.nsIWebHandlerApp && h.uriTemplate === uri) {
        return h;
      }
    }

    let handler = Cc["@mozilla.org/uriloader/web-handler-app;1"].createInstance(
      Ci.nsIWebHandlerApp
    );
    handler.name = name;
    handler.uriTemplate = uri;

    let handlerInfo =
      lazy.ExternalProtocolService.getProtocolHandlerInfo(protocol);
    handlerInfo.possibleApplicationHandlers.appendElement(handler);

    let hs = Cc["@mozilla.org/uriloader/handler-service;1"].getService(
      Ci.nsIHandlerService
    );
    hs.store(handlerInfo);

    return handler;
  },

  /*
   * Function to store a value associated to a domain using the content pref
   * service.
   *
   * @param {string} domain: the domain for this setting
   * @param {string} setting: the name of the setting
   * @param {string} value: the actual setting to be stored
   * @param {string} context (optional): private window or not
   * @returns {string} the stored preference (see: nsIContentPrefService2.idl)
   */
  async _saveSiteSpecificSetting(domain, setting, value, context = null) {
    const gContentPrefs = Cc["@mozilla.org/content-pref/service;1"].getService(
      Ci.nsIContentPrefService2
    );

    return new Promise((resolve, reject) => {
      gContentPrefs.set(domain, setting, value, context, {
        handleResult(pref) {
          resolve(pref);
        },
        handleCompletion() {},
        handleError(err) {
          reject(err);
        },
      });
    });
  },

  /*
   * Function to return a stored value from the content pref service. Returns
   * a promise, so await can be used to synchonize the retrieval.
   *
   * @param {string} domain: the domain for this setting
   * @param {string} setting: the name of the setting
   * @param {string} context (optional): private window or not
   * @param {string} def (optional): the default value to return
   * @returns {string} either stored value or ""
   */
  async _getSiteSpecificSetting(domain, setting, context = null, def = null) {
    const gContentPrefs = Cc["@mozilla.org/content-pref/service;1"].getService(
      Ci.nsIContentPrefService2
    );

    return await new Promise((resolve, reject) => {
      gContentPrefs.getByDomainAndName(domain, setting, context, {
        _result: def,
        handleResult(pref) {
          this._result = pref.value;
        },
        handleCompletion(_) {
          resolve(this._result);
        },
        handleError(err) {
          reject(err);
        },
      });
    });
  },

  /**
   * See nsIWebProtocolHandlerRegistrar
   */
  registerProtocolHandler(
    aProtocol,
    aURI,
    aTitle,
    aDocumentURI,
    aBrowserOrWindow
  ) {
    // first mitigation: check if the API call comes from another domain
    aProtocol = (aProtocol || "").toLowerCase();
    if (!aURI || !aDocumentURI) {
      return;
    }

    let browser = aBrowserOrWindow; // This is the e10s case.
    if (aBrowserOrWindow instanceof Ci.nsIDOMWindow) {
      // In the non-e10s case, grab the browser off the same-process window.
      let rootDocShell = aBrowserOrWindow.docShell.sameTypeRootTreeItem;
      browser = rootDocShell.QueryInterface(Ci.nsIDocShell).chromeEventHandler;
    }

    let browserWindow = browser.ownerGlobal;
    try {
      browserWindow.navigator.checkProtocolHandlerAllowed(
        aProtocol,
        aURI,
        aDocumentURI
      );
    } catch (ex) {
      // We should have already shown the user an error.
      return;
    }
    if (lazy.NimbusFeatures.mailto.getVariable("dualPrompt")) {
      if ("mailto" === aProtocol) {
        lazy.NimbusFeatures.mailto.recordExposureEvent();
        this._askUserToSetMailtoHandler(browser, aProtocol, aURI, aTitle);
        return;
      }
    }

    // If the protocol handler is already registered, just return early.
    if (this._protocolHandlerRegistered(aProtocol, aURI.spec)) {
      return;
    }

    // Now Ask the user and provide the proper callback
    let message = this._getFormattedString("addProtocolHandlerMessage", [
      aURI.host,
      aProtocol,
    ]);

    let notificationIcon = aURI.prePath + "/favicon.ico";
    let notificationValue = "Protocol Registration: " + aProtocol;
    let addButton = {
      label: this._getString("addProtocolHandlerAddButton"),
      accessKey: this._getString("addProtocolHandlerAddButtonAccesskey"),
      protocolInfo: { protocol: aProtocol, uri: aURI.spec, name: aTitle },
      primary: true,

      callback(aNotification, aButtonInfo) {
        let protocol = aButtonInfo.protocolInfo.protocol;
        let name = aButtonInfo.protocolInfo.name;

        let handler = Cc[
          "@mozilla.org/uriloader/web-handler-app;1"
        ].createInstance(Ci.nsIWebHandlerApp);
        handler.name = name;
        handler.uriTemplate = aButtonInfo.protocolInfo.uri;

        let handlerInfo =
          lazy.ExternalProtocolService.getProtocolHandlerInfo(protocol);
        handlerInfo.possibleApplicationHandlers.appendElement(handler);

        // Since the user has agreed to add a new handler, chances are good
        // that the next time they see a handler of this type, they're going
        // to want to use it.  Reset the handlerInfo to ask before the next
        // use.
        handlerInfo.alwaysAskBeforeHandling = true;

        let hs = Cc["@mozilla.org/uriloader/handler-service;1"].getService(
          Ci.nsIHandlerService
        );
        hs.store(handlerInfo);
      },
    };

    let notificationBox = browser.getTabBrowser().getNotificationBox(browser);

    // check if the notification box is already shown
    if (notificationBox.getNotificationWithValue(notificationValue)) {
      return;
    }

    notificationBox.appendNotification(
      notificationValue,
      {
        label: message,
        image: notificationIcon,
        priority: notificationBox.PRIORITY_INFO_LOW,
      },
      [addButton]
    );
  },

  /*
   * Special implementation for mailto: A prompt (notificationbox.js) is only
   * shown if there is a realistic chance that we can really set the OS default,
   * e.g. if we have been properly installed and the current page is not already
   * the default and we have not asked users too often the same question.
   *
   * @param {string} browser
   * @param {string} aProtocol
   * @param {nsIURI} aURI
   * @param {string} aTitle
   */
  async _askUserToSetMailtoHandler(browser, aProtocol, aURI, aTitle) {
    let notificationId = "OS Protocol Registration: " + aProtocol;

    // guard: we do not want to reconfigure settings in private browsing mode
    if (lazy.PrivateBrowsingUtils.isWindowPrivate(browser.ownerGlobal)) {
      lazy.log.debug("prompt not shown, because this is a private window.");
      return;
    }

    // guard: check if everything has been configured to use the current site
    // as default webmailer and bail out if so.
    if (this._isProtocolHandlerDefault(aProtocol, aURI)) {
      lazy.log.debug(
        `prompt not shown, because ${aTitle} is already configured to` +
          ` handle ${aProtocol}-links under ${aURI.spec}.`
      );
      return;
    }

    // guard: bail out if this site has been dismissed before (either by
    // clicking the 'x' button or the 'not now' button.
    let principal = browser.getTabBrowser().contentPrincipal;
    if (
      Ci.nsIPermissionManager.DENY_ACTION ==
      Services.perms.testExactPermissionFromPrincipal(
        principal,
        "mailto-infobar-dismissed"
      )
    ) {
      let expiry =
        Services.perms.getPermissionObject(
          principal,
          "mailto-infobar-dismissed",
          true
        ).expireTime - Date.now();

      lazy.log.debug(
        `prompt not shown, because it is still dismissed for` +
          ` ${principal.host} and will be shown in` +
          ` ${(expiry / 1000).toFixed()} seconds again.`
      );
      return;
    }

    // Now show the prompt if there is not already one...
    let osDefaultNotificationBox = browser
      .getTabBrowser()
      .getNotificationBox(browser);

    if (!osDefaultNotificationBox.getNotificationWithValue(notificationId)) {
      let win = browser.ownerGlobal;
      win.MozXULElement.insertFTLIfNeeded("browser/webProtocolHandler.ftl");

      let notification = await osDefaultNotificationBox.appendNotification(
        notificationId,
        {
          label: {
            "l10n-id": "protocolhandler-mailto-handler-set",
            "l10n-args": { url: aURI.host },
          },
          priority: osDefaultNotificationBox.PRIORITY_INFO_LOW,
          eventCallback: eventType => {
            if (eventType === "dismissed") {
              // after a click on 'X' save a timestamp after which we can show
              // the prompt again
              Services.perms.addFromPrincipal(
                principal,
                "mailto-infobar-dismissed",
                Ci.nsIPermissionManager.DENY_ACTION,
                Ci.nsIPermissionManager.EXPIRE_TIME,
                lazy.NimbusFeatures.mailto.getVariable(
                  "dualPrompt.dismissXClickMinutes"
                ) *
                  60 *
                  1000 +
                  Date.now()
              );
              Glean.protocolhandlerMailto.promptClicked.dismiss_os_default.add();
            }
          },
        },
        [
          {
            "l10n-id": "protocolhandler-mailto-os-handler-yes-button",
            primary: true,
            callback: newitem => {
              let currentHandler = this._addWebProtocolHandler(
                aProtocol,
                aTitle,
                aURI.spec
              );
              this._setProtocolHandlerDefault(aProtocol, currentHandler);
              Glean.protocolhandlerMailto.promptClicked.set_local_default.add();

              if (this._canSetOSDefault(aProtocol)) {
                if (this._setOSDefault(aProtocol)) {
                  Glean.protocolhandlerMailto.promptClicked.set_os_default.add();
                  newitem.messageL10nId =
                    "protocolhandler-mailto-handler-confirm";
                  newitem.removeChild(newitem.buttonContainer);
                  newitem.setAttribute("type", "success"); // from moz-message-bar.css
                  newitem.eventCallback = null; // disable show only once per day for success
                  return true; // `true` does not hide the bar
                }

                // if anything goes wrong with setting the OS default, we want
                // to be informed so that we can fix it.
                Glean.protocolhandlerMailto.promptClicked.set_os_default_error.add();
                return false;
              }

              // if the installation does not have an install hash, we cannot
              // set the OS default, but mailto links from within the browser
              // should still work.
              Glean.protocolhandlerMailto.promptClicked.set_os_default_impossible.add();
              return false;
            },
          },
          {
            "l10n-id": "protocolhandler-mailto-os-handler-no-button",
            callback: () => {
              // after a click on 'Not Now' save a timestamp after which we can
              // show the prompt again
              Services.perms.addFromPrincipal(
                principal,
                "mailto-infobar-dismissed",
                Ci.nsIPermissionManager.DENY_ACTION,
                Ci.nsIPermissionManager.EXPIRE_TIME,
                lazy.NimbusFeatures.mailto.getVariable(
                  "dualPrompt.dismissNotNowMinutes"
                ) *
                  60 *
                  1000 +
                  Date.now()
              );
              Glean.protocolhandlerMailto.promptClicked.dismiss_os_default.add();
              return false;
            },
          },
        ]
      );

      Glean.protocolhandlerMailto.handlerPromptShown.os_default.add();
      // remove the icon from the infobar, which is automatically assigned
      // after its priority, because the priority is also an indicator which
      // type of bar it is, e.g. a warning or error:
      notification.setAttribute("type", "system");
    }
  },

  /**
   * See nsISupports
   */
  QueryInterface: ChromeUtils.generateQI(["nsIWebProtocolHandlerRegistrar"]),
};
