/* 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";

/**
 * This file exports XPCOM components for C++ and chrome JavaScript callers to
 * interact with the Push service.
 */

const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;

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

var isParent = Services.appinfo.processType === Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT;

// The default Push service implementation.
XPCOMUtils.defineLazyGetter(this, "PushService", function() {
  const {PushService} = Cu.import("resource://gre/modules/PushService.jsm",
                                  {});
  PushService.init();
  return PushService;
});

// Observer notification topics for push messages and subscription status
// changes. These are duplicated and used in `nsIPushNotifier`. They're exposed
// on `nsIPushService` so that JS callers only need to import this service.
const OBSERVER_TOPIC_PUSH = "push-message";
const OBSERVER_TOPIC_SUBSCRIPTION_CHANGE = "push-subscription-change";
const OBSERVER_TOPIC_SUBSCRIPTION_MODIFIED = "push-subscription-modified";

/**
 * `PushServiceBase`, `PushServiceParent`, and `PushServiceContent` collectively
 * implement the `nsIPushService` interface. This interface provides calls
 * similar to the Push DOM API, but does not require service workers.
 *
 * Push service methods may be called from the parent or content process. The
 * parent process implementation loads `PushService.jsm` at app startup, and
 * calls its methods directly. The content implementation forwards calls to
 * the parent Push service via IPC.
 *
 * The implementations share a class and contract ID.
 */
function PushServiceBase() {
  this.wrappedJSObject = this;
  this._addListeners();
}

PushServiceBase.prototype = {
  classID: Components.ID("{daaa8d73-677e-4233-8acd-2c404bd01658}"),
  contractID: "@mozilla.org/push/Service;1",
  QueryInterface: XPCOMUtils.generateQI([
    Ci.nsIObserver,
    Ci.nsISupportsWeakReference,
    Ci.nsIPushService,
    Ci.nsIPushQuotaManager,
    Ci.nsIPushErrorReporter,
  ]),

  pushTopic: OBSERVER_TOPIC_PUSH,
  subscriptionChangeTopic: OBSERVER_TOPIC_SUBSCRIPTION_CHANGE,
  subscriptionModifiedTopic: OBSERVER_TOPIC_SUBSCRIPTION_MODIFIED,

  _handleReady() {},

  _addListeners() {
    for (let message of this._messages) {
      this._mm.addMessageListener(message, this);
    }
  },

  _isValidMessage(message) {
    return this._messages.includes(message.name);
  },

  observe(subject, topic, data) {
    if (topic === "app-startup") {
      Services.obs.addObserver(this, "sessionstore-windows-restored", true);
      return;
    }
    if (topic === "sessionstore-windows-restored") {
      Services.obs.removeObserver(this, "sessionstore-windows-restored");
      this._handleReady();
      return;
    }
  },

  _deliverSubscription(request, props) {
    if (!props) {
      request.onPushSubscription(Cr.NS_OK, null);
      return;
    }
    request.onPushSubscription(Cr.NS_OK, new PushSubscription(props));
  },

  _deliverSubscriptionError(request, error) {
    let result = typeof error.result == "number" ?
                 error.result : Cr.NS_ERROR_FAILURE;
    request.onPushSubscription(result, null);
  },
};

/**
 * The parent process implementation of `nsIPushService`. This version loads
 * `PushService.jsm` at startup and calls its methods directly. It also
 * receives and responds to requests from the content process.
 */
function PushServiceParent() {
  PushServiceBase.call(this);
}

PushServiceParent.prototype = Object.create(PushServiceBase.prototype);

XPCOMUtils.defineLazyServiceGetter(PushServiceParent.prototype, "_mm",
  "@mozilla.org/parentprocessmessagemanager;1", "nsIMessageBroadcaster");

Object.assign(PushServiceParent.prototype, {
  _xpcom_factory: XPCOMUtils.generateSingletonFactory(PushServiceParent),

  _messages: [
    "Push:Register",
    "Push:Registration",
    "Push:Unregister",
    "Push:Clear",
    "Push:NotificationForOriginShown",
    "Push:NotificationForOriginClosed",
    "Push:ReportError",
  ],

  // nsIPushService methods

  subscribe(scope, principal, callback) {
    this.subscribeWithKey(scope, principal, 0, null, callback);
  },

  subscribeWithKey(scope, principal, keyLen, key, callback) {
    this._handleRequest("Push:Register", principal, {
      scope: scope,
      appServerKey: key,
    }).then(result => {
      this._deliverSubscription(callback, result);
    }, error => {
      this._deliverSubscriptionError(callback, error);
    }).catch(Cu.reportError);
  },

  unsubscribe(scope, principal, callback) {
    this._handleRequest("Push:Unregister", principal, {
      scope: scope,
    }).then(result => {
      callback.onUnsubscribe(Cr.NS_OK, result);
    }, error => {
      callback.onUnsubscribe(Cr.NS_ERROR_FAILURE, false);
    }).catch(Cu.reportError);
  },

  getSubscription(scope, principal, callback) {
    return this._handleRequest("Push:Registration", principal, {
      scope: scope,
    }).then(result => {
      this._deliverSubscription(callback, result);
    }, error => {
      this._deliverSubscriptionError(callback, error);
    }).catch(Cu.reportError);
  },

  clearForDomain(domain, callback) {
    return this._handleRequest("Push:Clear", null, {
      domain: domain,
    }).then(result => {
      callback.onClear(Cr.NS_OK);
    }, error => {
      callback.onClear(Cr.NS_ERROR_FAILURE);
    }).catch(Cu.reportError);
  },

  // nsIPushQuotaManager methods

  notificationForOriginShown(origin) {
    this.service.notificationForOriginShown(origin);
  },

  notificationForOriginClosed(origin) {
    this.service.notificationForOriginClosed(origin);
  },

  // nsIPushErrorReporter methods

  reportDeliveryError(messageId, reason) {
    this.service.reportDeliveryError(messageId, reason);
  },

  receiveMessage(message) {
    if (!this._isValidMessage(message)) {
      return;
    }
    let {name, principal, target, data} = message;
    if (name === "Push:NotificationForOriginShown") {
      this.notificationForOriginShown(data);
      return;
    }
    if (name === "Push:NotificationForOriginClosed") {
      this.notificationForOriginClosed(data);
      return;
    }
    if (!target.assertPermission("push")) {
      return;
    }
    if (name === "Push:ReportError") {
      this.reportDeliveryError(data.messageId, data.reason);
      return;
    }
    let sender = target.QueryInterface(Ci.nsIMessageSender);
    return this._handleRequest(name, principal, data).then(result => {
      sender.sendAsyncMessage(this._getResponseName(name, "OK"), {
        requestID: data.requestID,
        result: result
      });
    }, error => {
      sender.sendAsyncMessage(this._getResponseName(name, "KO"), {
        requestID: data.requestID,
        result: error.result,
      });
    }).catch(Cu.reportError);
  },

  _handleReady() {
    this.service.init();
  },

  _toPageRecord(principal, data) {
    if (!data.scope) {
      throw new Error("Invalid page record: missing scope");
    }
    if (!principal) {
      throw new Error("Invalid page record: missing principal");
    }
    if (principal.isNullPrincipal || principal.isExpandedPrincipal) {
      throw new Error("Invalid page record: unsupported principal");
    }

    // System subscriptions can only be created by chrome callers, and are
    // exempt from the background message quota and permission checks. They
    // also do not fire service worker events.
    data.systemRecord = principal.isSystemPrincipal;

    data.originAttributes =
      ChromeUtils.originAttributesToSuffix(principal.originAttributes);

    return data;
  },

  _handleRequest(name, principal, data) {
    if (name == "Push:Clear") {
      return this.service.clear(data);
    }

    let pageRecord;
    try {
      pageRecord = this._toPageRecord(principal, data);
    } catch (e) {
      return Promise.reject(e);
    }

    if (name === "Push:Register") {
      return this.service.register(pageRecord);
    }
    if (name === "Push:Registration") {
      return this.service.registration(pageRecord);
    }
    if (name === "Push:Unregister") {
      return this.service.unregister(pageRecord);
    }

    return Promise.reject(new Error("Invalid request: unknown name"));
  },

  _getResponseName(requestName, suffix) {
    let name = requestName.slice("Push:".length);
    return "PushService:" + name + ":" + suffix;
  },

  // Methods used for mocking in tests.

  replaceServiceBackend(options) {
    return this.service.changeTestServer(options.serverURI, options);
  },

  restoreServiceBackend() {
    var defaultServerURL = Services.prefs.getCharPref("dom.push.serverURL");
    return this.service.changeTestServer(defaultServerURL);
  },
});

// Used to replace the implementation with a mock.
Object.defineProperty(PushServiceParent.prototype, "service", {
  get() {
    return this._service || PushService;
  },
  set(impl) {
    this._service = impl;
  },
});

/**
 * The content process implementation of `nsIPushService`. This version
 * uses the child message manager to forward calls to the parent process.
 * The parent Push service instance handles the request, and responds with a
 * message containing the result.
 */
function PushServiceContent() {
  PushServiceBase.apply(this, arguments);
  this._requests = new Map();
  this._requestId = 0;
}

PushServiceContent.prototype = Object.create(PushServiceBase.prototype);

XPCOMUtils.defineLazyServiceGetter(PushServiceContent.prototype,
  "_mm", "@mozilla.org/childprocessmessagemanager;1",
  "nsISyncMessageSender");

Object.assign(PushServiceContent.prototype, {
  _xpcom_factory: XPCOMUtils.generateSingletonFactory(PushServiceContent),

  _messages: [
    "PushService:Register:OK",
    "PushService:Register:KO",
    "PushService:Registration:OK",
    "PushService:Registration:KO",
    "PushService:Unregister:OK",
    "PushService:Unregister:KO",
    "PushService:Clear:OK",
    "PushService:Clear:KO",
  ],

  // nsIPushService methods

  subscribe(scope, principal, callback) {
    this.subscribeWithKey(scope, principal, 0, null, callback);
  },

  subscribeWithKey(scope, principal, keyLen, key, callback) {
    let requestId = this._addRequest(callback);
    this._mm.sendAsyncMessage("Push:Register", {
      scope: scope,
      appServerKey: key,
      requestID: requestId,
    }, null, principal);
  },

  unsubscribe(scope, principal, callback) {
    let requestId = this._addRequest(callback);
    this._mm.sendAsyncMessage("Push:Unregister", {
      scope: scope,
      requestID: requestId,
    }, null, principal);
  },

  getSubscription(scope, principal, callback) {
    let requestId = this._addRequest(callback);
    this._mm.sendAsyncMessage("Push:Registration", {
      scope: scope,
      requestID: requestId,
    }, null, principal);
  },

  clearForDomain(domain, callback) {
    let requestId = this._addRequest(callback);
    this._mm.sendAsyncMessage("Push:Clear", {
      domain: domain,
      requestID: requestId,
    });
  },

  // nsIPushQuotaManager methods

  notificationForOriginShown(origin) {
    this._mm.sendAsyncMessage("Push:NotificationForOriginShown", origin);
  },

  notificationForOriginClosed(origin) {
    this._mm.sendAsyncMessage("Push:NotificationForOriginClosed", origin);
  },

  // nsIPushErrorReporter methods

  reportDeliveryError(messageId, reason) {
    this._mm.sendAsyncMessage("Push:ReportError", {
      messageId: messageId,
      reason: reason,
    });
  },

  _addRequest(data) {
    let id = ++this._requestId;
    this._requests.set(id, data);
    return id;
  },

  _takeRequest(requestId) {
    let d = this._requests.get(requestId);
    this._requests.delete(requestId);
    return d;
  },

  receiveMessage(message) {
    if (!this._isValidMessage(message)) {
      return;
    }
    let {name, data} = message;
    let request = this._takeRequest(data.requestID);

    if (!request) {
      return;
    }

    switch (name) {
      case "PushService:Register:OK":
      case "PushService:Registration:OK":
        this._deliverSubscription(request, data.result);
        break;

      case "PushService:Register:KO":
      case "PushService:Registration:KO":
        this._deliverSubscriptionError(request, data);
        break;

      case "PushService:Unregister:OK":
        if (typeof data.result === "boolean") {
          request.onUnsubscribe(Cr.NS_OK, data.result);
        } else {
          request.onUnsubscribe(Cr.NS_ERROR_FAILURE, false);
        }
        break;

      case "PushService:Unregister:KO":
        request.onUnsubscribe(Cr.NS_ERROR_FAILURE, false);
        break;

      case "PushService:Clear:OK":
        request.onClear(Cr.NS_OK);
        break;

      case "PushService:Clear:KO":
        request.onClear(Cr.NS_ERROR_FAILURE);
        break;

      default:
        break;
    }
  },
});

/** `PushSubscription` instances are passed to all subscription callbacks. */
function PushSubscription(props) {
  this._props = props;
}

PushSubscription.prototype = {
  QueryInterface: XPCOMUtils.generateQI([Ci.nsIPushSubscription]),

  /** The URL for sending messages to this subscription. */
  get endpoint() {
    return this._props.endpoint;
  },

  /** The last time a message was sent to this subscription. */
  get lastPush() {
    return this._props.lastPush;
  },

  /** The total number of messages sent to this subscription. */
  get pushCount() {
    return this._props.pushCount;
  },

  /** The number of remaining background messages that can be sent to this
   * subscription, or -1 of the subscription is exempt from the quota.
   */
  get quota() {
    return this._props.quota;
  },

  /**
   * Indicates whether this subscription was created with the system principal.
   * System subscriptions are exempt from the background message quota and
   * permission checks.
   */
  get isSystemSubscription() {
    return !!this._props.systemRecord;
  },

  /** The private key used to decrypt incoming push messages, in JWK format */
  get p256dhPrivateKey() {
    return this._props.p256dhPrivateKey;
  },

  /**
   * Indicates whether this subscription is subject to the background message
   * quota.
   */
  quotaApplies() {
    return this.quota >= 0;
  },

  /**
   * Indicates whether this subscription exceeded the background message quota,
   * or the user revoked the notification permission. The caller must request a
   * new subscription to continue receiving push messages.
   */
  isExpired() {
    return this.quota === 0;
  },

  /**
   * Returns a key for encrypting messages sent to this subscription. JS
   * callers receive the key buffer as a return value, while C++ callers
   * receive the key size and buffer as out parameters.
   */
  getKey(name, outKeyLen) {
    switch (name) {
      case "p256dh":
        return this._getRawKey(this._props.p256dhKey, outKeyLen);

      case "auth":
        return this._getRawKey(this._props.authenticationSecret, outKeyLen);

      case "appServer":
        return this._getRawKey(this._props.appServerKey, outKeyLen);
    }
    return null;
  },

  _getRawKey(key, outKeyLen) {
    if (!key) {
      return null;
    }
    let rawKey = new Uint8Array(key);
    if (outKeyLen) {
      outKeyLen.value = rawKey.length;
    }
    return rawKey;
  },
};

this.NSGetFactory = XPCOMUtils.generateNSGetFactory([
  // Export the correct implementation depending on whether we're running in
  // the parent or content process.
  isParent ? PushServiceParent : PushServiceContent,
]);
