/* jshint moz: true, esnext: true */
/* 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";

const Cc = Components.classes;
const Ci = Components.interfaces;
const Cu = Components.utils;
const Cr = Components.results;

const {PushDB} = Cu.import("resource://gre/modules/PushDB.jsm");
const {PushRecord} = Cu.import("resource://gre/modules/PushRecord.jsm");
const {PushCrypto} = Cu.import("resource://gre/modules/PushCrypto.jsm");
Cu.import("resource://gre/modules/Messaging.jsm"); /*global: Messaging */
Cu.import("resource://gre/modules/Services.jsm"); /*global: Services */
Cu.import("resource://gre/modules/Preferences.jsm"); /*global: Preferences */
Cu.import("resource://gre/modules/Promise.jsm"); /*global: Promise */
Cu.import("resource://gre/modules/XPCOMUtils.jsm"); /*global: XPCOMUtils */

const Log = Cu.import("resource://gre/modules/AndroidLog.jsm", {}).AndroidLog.bind("Push");

this.EXPORTED_SYMBOLS = ["PushServiceAndroidGCM"];

XPCOMUtils.defineLazyGetter(this, "console", () => {
  let {ConsoleAPI} = Cu.import("resource://gre/modules/Console.jsm", {});
  return new ConsoleAPI({
    dump: Log.i,
    maxLogLevelPref: "dom.push.loglevel",
    prefix: "PushServiceAndroidGCM",
  });
});

const kPUSHANDROIDGCMDB_DB_NAME = "pushAndroidGCM";
const kPUSHANDROIDGCMDB_DB_VERSION = 5; // Change this if the IndexedDB format changes
const kPUSHANDROIDGCMDB_STORE_NAME = "pushAndroidGCM";

const FXA_PUSH_SCOPE = "chrome://fxa-push";

const prefs = new Preferences("dom.push.");

/**
 * The implementation of WebPush push backed by Android's GCM
 * delivery.
 */
this.PushServiceAndroidGCM = {
  _mainPushService: null,
  _serverURI: null,

  newPushDB: function() {
    return new PushDB(kPUSHANDROIDGCMDB_DB_NAME,
                      kPUSHANDROIDGCMDB_DB_VERSION,
                      kPUSHANDROIDGCMDB_STORE_NAME,
                      "channelID",
                      PushRecordAndroidGCM);
  },

  validServerURI: function(serverURI) {
    if (!serverURI) {
      return false;
    }

    if (serverURI.scheme == "https") {
      return true;
    }
    if (serverURI.scheme == "http") {
      // Allow insecure server URLs for development and testing.
      return !!prefs.get("testing.allowInsecureServerURL");
    }
    console.info("Unsupported Android GCM dom.push.serverURL scheme", serverURI.scheme);
    return false;
  },

  observe: function(subject, topic, data) {
    switch (topic) {
      case "nsPref:changed":
        if (data == "dom.push.debug") {
          // Reconfigure.
          let debug = !!prefs.get("debug");
          console.info("Debug parameter changed; updating configuration with new debug", debug);
          this._configure(this._serverURI, debug);
        }
        break;
      case "PushServiceAndroidGCM:ReceivedPushMessage":
        this._onPushMessageReceived(data);
        break;
      default:
        break;
    }
  },

  _onPushMessageReceived(data) {
    // TODO: Use Messaging.jsm for this.
    if (this._mainPushService == null) {
      // Shouldn't ever happen, but let's be careful.
      console.error("No main PushService!  Dropping message.");
      return;
    }
    if (!data) {
      console.error("No data from Java!  Dropping message.");
      return;
    }
    data = JSON.parse(data);
    console.debug("ReceivedPushMessage with data", data);

    let { headers, message } = this._messageAndHeaders(data);

    console.debug("Delivering message to main PushService:", message, headers);
    this._mainPushService.receivedPushMessage(
      data.channelID, "", headers, message, (record) => {
        // Always update the stored record.
        return record;
      });
  },

  _messageAndHeaders(data) {
    // Default is no data (and no encryption).
    let message = null;
    let headers = null;

    if (data.message && data.enc && (data.enckey || data.cryptokey)) {
      headers = {
        encryption_key: data.enckey,
        crypto_key: data.cryptokey,
        encryption: data.enc,
        encoding: data.con,
      };
      // Ciphertext is (urlsafe) Base 64 encoded.
      message = ChromeUtils.base64URLDecode(data.message, {
        // The Push server may append padding.
        padding: "ignore",
      });
    }
    return { headers, message };
  },

  _configure: function(serverURL, debug) {
    return Messaging.sendRequestForResult({
      type: "PushServiceAndroidGCM:Configure",
      endpoint: serverURL.spec,
      debug: debug,
    });
  },

  init: function(options, mainPushService, serverURL) {
    console.debug("init()");
    this._mainPushService = mainPushService;
    this._serverURI = serverURL;

    prefs.observe("debug", this);
    Services.obs.addObserver(this, "PushServiceAndroidGCM:ReceivedPushMessage", false);

    return this._configure(serverURL, !!prefs.get("debug")).then(() => {
      Messaging.sendRequestForResult({
        type: "PushServiceAndroidGCM:Initialized"
      });
    });
  },

  uninit: function() {
    console.debug("uninit()");
    Messaging.sendRequestForResult({
      type: "PushServiceAndroidGCM:Uninitialized"
    });

    this._mainPushService = null;
    Services.obs.removeObserver(this, "PushServiceAndroidGCM:ReceivedPushMessage");
    prefs.ignore("debug", this);
  },

  onAlarmFired: function() {
    // No action required.
  },

  connect: function(records) {
    console.debug("connect:", records);
    // It's possible for the registration or subscriptions backing the
    // PushService to not be registered with the underlying AndroidPushService.
    // Expire those that are unrecognized.
    return Messaging.sendRequestForResult({
      type: "PushServiceAndroidGCM:DumpSubscriptions",
    })
    .then(subscriptions => {
      console.debug("connect:", subscriptions);
      // subscriptions maps chid => subscription data.
      return Promise.all(records.map(record => {
        if (subscriptions.hasOwnProperty(record.keyID)) {
          console.debug("connect:", "hasOwnProperty", record.keyID);
          return Promise.resolve();
        }
        console.debug("connect:", "!hasOwnProperty", record.keyID);
        // Subscription is known to PushService.jsm but not to AndroidPushService.  Drop it.
        return this._mainPushService.dropRegistrationAndNotifyApp(record.keyID)
          .catch(error => {
            console.error("connect: Error dropping registration", record.keyID, error);
          });
      }));
    });
  },

  isConnected: function() {
    return this._mainPushService != null;
  },

  disconnect: function() {
    console.debug("disconnect");
  },

  register: function(record) {
    console.debug("register:", record);
    let ctime = Date.now();
    let appServerKey = record.appServerKey ?
      ChromeUtils.base64URLEncode(record.appServerKey, {
        // The Push server requires padding.
        pad: true,
      }) : null;
    let message = {
      type: "PushServiceAndroidGCM:SubscribeChannel",
      appServerKey: appServerKey,
    }
    if (record.scope == FXA_PUSH_SCOPE) {
      message.service = "fxa";
    }
    // Caller handles errors.
    return Messaging.sendRequestForResult(message)
    .then(data => {
      console.debug("Got data:", data);
      return PushCrypto.generateKeys()
        .then(exportedKeys =>
          new PushRecordAndroidGCM({
            // Straight from autopush.
            channelID: data.channelID,
            pushEndpoint: data.endpoint,
            // Common to all PushRecord implementations.
            scope: record.scope,
            originAttributes: record.originAttributes,
            ctime: ctime,
            systemRecord: record.systemRecord,
            // Cryptography!
            p256dhPublicKey: exportedKeys[0],
            p256dhPrivateKey: exportedKeys[1],
            authenticationSecret: PushCrypto.generateAuthenticationSecret(),
            appServerKey: record.appServerKey,
          })
      );
    });
  },

  unregister: function(record) {
    console.debug("unregister: ", record);
    return Messaging.sendRequestForResult({
      type: "PushServiceAndroidGCM:UnsubscribeChannel",
      channelID: record.keyID,
    });
  },

  reportDeliveryError: function(messageID, reason) {
    console.warn("reportDeliveryError: Ignoring message delivery error",
      messageID, reason);
  },
};

function PushRecordAndroidGCM(record) {
  PushRecord.call(this, record);
  this.channelID = record.channelID;
}

PushRecordAndroidGCM.prototype = Object.create(PushRecord.prototype, {
  keyID: {
    get() {
      return this.channelID;
    },
  },
});