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

/* static functions */
const DEBUG = false;

function debug(aStr) {
  if (DEBUG)
    dump("AlarmService: " + aStr + "\n");
}

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

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

this.EXPORTED_SYMBOLS = ["AlarmService"];

XPCOMUtils.defineLazyGetter(this, "appsService", function() {
  return Cc["@mozilla.org/AppsService;1"].getService(Ci.nsIAppsService);
});

XPCOMUtils.defineLazyServiceGetter(this, "ppmm",
                                   "@mozilla.org/parentprocessmessagemanager;1",
                                   "nsIMessageListenerManager");

XPCOMUtils.defineLazyGetter(this, "messenger", function() {
  return Cc["@mozilla.org/system-message-internal;1"].getService(Ci.nsISystemMessagesInternal);
});

XPCOMUtils.defineLazyGetter(this, "powerManagerService", function() {
  return Cc["@mozilla.org/power/powermanagerservice;1"].getService(Ci.nsIPowerManagerService);
});

let myGlobal = this;

/**
 * AlarmService provides an API to schedule alarms using the device's RTC.
 *
 * AlarmService is primarily used by the mozAlarms API (navigator.mozAlarms)
 * which uses IPC to communicate with the service.
 *
 * AlarmService can also be used by Gecko code by importing the module and then
 * using AlarmService.add() and AlarmService.remove(). Only Gecko code running
 * in the parent process should do this.
 */

this.AlarmService = {
  init: function init() {
    debug("init()");
    Services.obs.addObserver(this, "profile-change-teardown", false);
    Services.obs.addObserver(this, "webapps-clear-data",false);

    this._currentTimezoneOffset = (new Date()).getTimezoneOffset();

    let alarmHalService =
      this._alarmHalService = Cc["@mozilla.org/alarmHalService;1"]
                              .getService(Ci.nsIAlarmHalService);

    alarmHalService.setAlarmFiredCb(this._onAlarmFired.bind(this));
    alarmHalService.setTimezoneChangedCb(this._onTimezoneChanged.bind(this));

    // Add the messages to be listened to.
    this._messages = ["AlarmsManager:GetAll",
                      "AlarmsManager:Add",
                      "AlarmsManager:Remove"];
    this._messages.forEach(function addMessage(msgName) {
      ppmm.addMessageListener(msgName, this);
    }.bind(this));

    // Set the indexeddb database.
    let idbManager = Cc["@mozilla.org/dom/indexeddb/manager;1"]
                     .getService(Ci.nsIIndexedDatabaseManager);
    idbManager.initWindowless(myGlobal);
    this._db = new AlarmDB(myGlobal);
    this._db.init(myGlobal);

    // Variable to save alarms waiting to be set.
    this._alarmQueue = [];

    this._restoreAlarmsFromDb();
  },

  // Getter/setter to access the current alarm set in system.
  _alarm: null,
  get _currentAlarm() {
    return this._alarm;
  },
  set _currentAlarm(aAlarm) {
    this._alarm = aAlarm;
    if (!aAlarm) {
      return;
    }

    let alarmTimeInMs = this._getAlarmTime(aAlarm);
    let ns = (alarmTimeInMs % 1000) * 1000000;
    if (!this._alarmHalService.setAlarm(alarmTimeInMs / 1000, ns)) {
      throw Components.results.NS_ERROR_FAILURE;
    }
  },

  receiveMessage: function receiveMessage(aMessage) {
    debug("receiveMessage(): " + aMessage.name);
    let json = aMessage.json;

    // To prevent the hacked child process from sending commands to parent
    // to schedule alarms, we need to check its permission and manifest URL.
    if (this._messages.indexOf(aMessage.name) != -1) {
      if (!aMessage.target.assertPermission("alarms")) {
        debug("Got message from a child process with no 'alarms' permission.");
        return null;
      }
      if (!aMessage.target.assertContainApp(json.manifestURL)) {
        debug("Got message from a child process containing illegal manifest URL.");
        return null;
      }
    }

    let mm = aMessage.target.QueryInterface(Ci.nsIMessageSender);
    switch (aMessage.name) {
      case "AlarmsManager:GetAll":
        this._db.getAll(
          json.manifestURL,
          function getAllSuccessCb(aAlarms) {
            debug("Callback after getting alarms from database: " +
                  JSON.stringify(aAlarms));
            this._sendAsyncMessage(mm, "GetAll", true, json.requestId, aAlarms);
          }.bind(this),
          function getAllErrorCb(aErrorMsg) {
            this._sendAsyncMessage(mm, "GetAll", false, json.requestId, aErrorMsg);
          }.bind(this)
        );
        break;

      case "AlarmsManager:Add":
        // Prepare a record for the new alarm to be added.
        let newAlarm = {
          date: json.date,
          ignoreTimezone: json.ignoreTimezone,
          data: json.data,
          pageURL: json.pageURL,
          manifestURL: json.manifestURL
        };

        this.add(newAlarm, null,
          // Receives the alarm ID as the last argument.
          this._sendAsyncMessage.bind(this, mm, "Add", true, json.requestId),
          // Receives the error message as the last argument.
          this._sendAsyncMessage.bind(this, mm, "Add", false, json.requestId)
        );
        break;

      case "AlarmsManager:Remove":
        this.remove(json.id, json.manifestURL);
        break;

      default:
        throw Components.results.NS_ERROR_NOT_IMPLEMENTED;
        break;
    }
  },

  _sendAsyncMessage: function _sendAsyncMessage(aMessageManager, aMessageName,
                                                aSuccess, aRequestId, aData) {
    debug("_sendAsyncMessage()");

    if (!aMessageManager) {
      debug("Invalid message manager: null");
      throw Components.results.NS_ERROR_FAILURE;
    }

    let json = null;
    switch (aMessageName)
    {
      case "Add":
        json = aSuccess ?
          { requestId: aRequestId, id: aData } :
          { requestId: aRequestId, errorMsg: aData };
        break;

      case "GetAll":
        json = aSuccess ?
          { requestId: aRequestId, alarms: aData } :
          { requestId: aRequestId, errorMsg: aData };
        break;

      default:
        throw Components.results.NS_ERROR_NOT_IMPLEMENTED;
        break;
    }

    aMessageManager.sendAsyncMessage("AlarmsManager:" + aMessageName +
                                     ":Return:" + (aSuccess ? "OK" : "KO"), json);
  },

  _removeAlarmFromDb: function _removeAlarmFromDb(aId, aManifestURL,
                                                  aRemoveSuccessCb) {
    debug("_removeAlarmFromDb()");

    // If the aRemoveSuccessCb is undefined or null, set a dummy callback for
    // it which is needed for _db.remove().
    if (!aRemoveSuccessCb) {
      aRemoveSuccessCb = function removeSuccessCb() {
        debug("Remove alarm from DB successfully.");
      };
    }

    this._db.remove(
      aId,
      aManifestURL,
      aRemoveSuccessCb,
      function removeErrorCb(aErrorMsg) {
        throw Components.results.NS_ERROR_NOT_IMPLEMENTED;
      }
    );
  },

  /**
   * Create a copy of the alarm that does not expose internal fields to
   * receivers and sticks to the public |respectTimezone| API rather than the
   * boolean |ignoreTimezone| field.
   */
  _publicAlarm: function _publicAlarm(aAlarm) {
    let alarm = {
      "id":              aAlarm.id,
      "date":            aAlarm.date,
      "respectTimezone": aAlarm.ignoreTimezone ?
                           "ignoreTimezone" : "honorTimezone",
      "data":            aAlarm.data
    };

    return alarm;
  },

  _fireSystemMessage: function _fireSystemMessage(aAlarm) {
    debug("Fire system message: " + JSON.stringify(aAlarm));

    let manifestURI = Services.io.newURI(aAlarm.manifestURL, null, null);
    let pageURI = Services.io.newURI(aAlarm.pageURL, null, null);

    messenger.sendMessage("alarm", this._publicAlarm(aAlarm),
                          pageURI, manifestURI);
  },

  _notifyAlarmObserver: function _notifyAlarmObserver(aAlarm) {
    debug("_notifyAlarmObserver()");

    if (aAlarm.manifestURL) {
      this._fireSystemMessage(aAlarm);
    } else if (typeof aAlarm.alarmFiredCb === "function") {
      aAlarm.alarmFiredCb(this._publicAlarm(aAlarm));
    }
  },

  _onAlarmFired: function _onAlarmFired() {
    debug("_onAlarmFired()");

    if (this._currentAlarm) {
      this._removeAlarmFromDb(this._currentAlarm.id, null);
      this._notifyAlarmObserver(this._currentAlarm);
      this._currentAlarm = null;
    }

    // Reset the next alarm from the queue.
    let alarmQueue = this._alarmQueue;
    while (alarmQueue.length > 0) {
      let nextAlarm = alarmQueue.shift();
      let nextAlarmTime = this._getAlarmTime(nextAlarm);

      // If the next alarm has been expired, directly notify the observer.
      // it instead of setting it.
      if (nextAlarmTime <= Date.now()) {
        this._removeAlarmFromDb(nextAlarm.id, null);
        this._notifyAlarmObserver(nextAlarm);
      } else {
        this._currentAlarm = nextAlarm;
        break;
      }
    }
    this._debugCurrentAlarm();
  },

  _onTimezoneChanged: function _onTimezoneChanged(aTimezoneOffset) {
    debug("_onTimezoneChanged()");

    this._currentTimezoneOffset = aTimezoneOffset;
    this._restoreAlarmsFromDb();
  },

  _restoreAlarmsFromDb: function _restoreAlarmsFromDb() {
    debug("_restoreAlarmsFromDb()");

    this._db.getAll(
      null,
      function getAllSuccessCb(aAlarms) {
        debug("Callback after getting alarms from database: " +
              JSON.stringify(aAlarms));

        // Clear any alarms set or queued in the cache.
        let alarmQueue = this._alarmQueue;
        alarmQueue.length = 0;
        this._currentAlarm = null;

        // Only restore the alarm that's not yet expired; otherwise, remove it
        // from the database and notify the observer.
        aAlarms.forEach(function addAlarm(aAlarm) {
          if (this._getAlarmTime(aAlarm) > Date.now()) {
            alarmQueue.push(aAlarm);
          } else {
            this._removeAlarmFromDb(aAlarm.id, null);
            this._notifyAlarmObserver(aAlarm);
          }
        }.bind(this));

        // Set the next alarm from queue.
        if (alarmQueue.length) {
          alarmQueue.sort(this._sortAlarmByTimeStamps.bind(this));
          this._currentAlarm = alarmQueue.shift();
        }

        this._debugCurrentAlarm();
      }.bind(this),
      function getAllErrorCb(aErrorMsg) {
        throw Components.results.NS_ERROR_NOT_IMPLEMENTED;
      }
    );
  },

  _getAlarmTime: function _getAlarmTime(aAlarm) {
    let alarmTime = (new Date(aAlarm.date)).getTime();

    // For an alarm specified with "ignoreTimezone", it must be fired respect
    // to the user's timezone.  Supposing an alarm was set at 7:00pm at Tokyo,
    // it must be gone off at 7:00pm respect to Paris' local time when the user
    // is located at Paris.  We can adjust the alarm UTC time by calculating
    // the difference of the orginal timezone and the current timezone.
    if (aAlarm.ignoreTimezone)
       alarmTime += (this._currentTimezoneOffset - aAlarm.timezoneOffset) * 60000;

    return alarmTime;
  },

  _sortAlarmByTimeStamps: function _sortAlarmByTimeStamps(aAlarm1, aAlarm2) {
    return this._getAlarmTime(aAlarm1) - this._getAlarmTime(aAlarm2);
  },

  _debugCurrentAlarm: function _debugCurrentAlarm() {
    debug("Current alarm: " + JSON.stringify(this._currentAlarm));
    debug("Alarm queue: " + JSON.stringify(this._alarmQueue));
  },

  /**
   *
   * Add a new alarm. This will set the RTC to fire at the selected date and
   * notify the caller. Notifications are delivered via System Messages if the
   * alarm is added on behalf of a app. Otherwise aAlarmFiredCb is called.
   *
   * @param object aNewAlarm
   *        Should contain the following literal properties:
   *          - |date| date: when the alarm should timeout.
   *          - |ignoreTimezone| boolean: See [1] for the details.
   *          - |manifestURL| string: Manifest of app on whose behalf the alarm
   *                                  is added.
   *          - |pageURL| string: The page in the app that receives the system
   *                              message.
   *          - |data| object [optional]: Data that can be stored in DB.
   * @param function aAlarmFiredCb
   *        Callback function invoked when the alarm is fired.
   *        It receives a single argument, the alarm object.
   *        May be null.
   * @param function aSuccessCb
   *        Callback function to receive an alarm ID (number).
   * @param function aErrorCb
   *        Callback function to receive an error message (string).
   * @returns void
   *
   * Notes:
   * [1] https://wiki.mozilla.org/WebAPI/AlarmAPI#Proposed_API
   */

  add: function(aNewAlarm, aAlarmFiredCb, aSuccessCb, aErrorCb) {
    debug("add(" + aNewAlarm.date + ")");

    aSuccessCb = aSuccessCb || function() {};
    aErrorCb = aErrorCb || function() {};

    if (!aNewAlarm) {
      aErrorCb("alarm is null");
      return;
    }

    if (!aNewAlarm.date) {
      aErrorCb("alarm.date is null");
      return;
    }

    aNewAlarm['timezoneOffset'] = this._currentTimezoneOffset;

    this._db.add(
      aNewAlarm,
      function addSuccessCb(aNewId) {
        debug("Callback after adding alarm in database.");

        aNewAlarm['id'] = aNewId;

        // Now that the alarm has been added to the database, we can tack on
        // the non-serializable callback to the in-memory object.
        aNewAlarm['alarmFiredCb'] = aAlarmFiredCb;

        // If there is no alarm being set in system, set the new alarm.
        if (this._currentAlarm == null) {
          this._currentAlarm = aNewAlarm;
          this._debugCurrentAlarm();
          aSuccessCb(aNewId);
          return;
        }

        // If the new alarm is earlier than the current alarm, swap them and
        // push the previous alarm back to queue.
        let alarmQueue = this._alarmQueue;
        let aNewAlarmTime = this._getAlarmTime(aNewAlarm);
        let currentAlarmTime = this._getAlarmTime(this._currentAlarm);
        if (aNewAlarmTime < currentAlarmTime) {
          alarmQueue.unshift(this._currentAlarm);
          this._currentAlarm = aNewAlarm;
          this._debugCurrentAlarm();
          aSuccessCb(aNewId);
          return;
        }

        // Push the new alarm in the queue.
        alarmQueue.push(aNewAlarm);
        alarmQueue.sort(this._sortAlarmByTimeStamps.bind(this));
        this._debugCurrentAlarm();
        aSuccessCb(aNewId);
      }.bind(this),
      function addErrorCb(aErrorMsg) {
        aErrorCb(aErrorMsg);
      }.bind(this)
    );
  },

  /*
   * Remove the alarm associated with an ID.
   *
   * @param number aAlarmId
   *        The ID of the alarm to be removed.
   * @param string aManifestURL
   *        Manifest URL for application which added the alarm. (Optional)
   * @returns void
   */
  remove: function(aAlarmId, aManifestURL) {
    debug("remove(" + aAlarmId + ", " + aManifestURL + ")");
    this._removeAlarmFromDb(
      aAlarmId,
      aManifestURL,
      function removeSuccessCb() {
        debug("Callback after removing alarm from database.");

        // If there are no alarms set, nothing to do.
        if (!this._currentAlarm) {
          debug("No alarms set.");
          return;
        }

        // Check if the alarm to be removed is in the queue and whether it
        // belongs to the requesting app.
        let alarmQueue = this._alarmQueue;
        if (this._currentAlarm.id != aAlarmId ||
            this._currentAlarm.manifestURL != aManifestURL) {

          for (let i = 0; i < alarmQueue.length; i++) {
            if (alarmQueue[i].id == aAlarmId &&
                alarmQueue[i].manifestURL == aManifestURL) {

              alarmQueue.splice(i, 1);
              break;
            }
          }
          this._debugCurrentAlarm();
          return;
        }

        // The alarm to be removed is the current alarm reset the next alarm
        // from queue if any.
        if (alarmQueue.length) {
          this._currentAlarm = alarmQueue.shift();
          this._debugCurrentAlarm();
          return;
        }

        // No alarm waiting to be set in the queue.
        this._currentAlarm = null;
        this._debugCurrentAlarm();
      }.bind(this)
    );
  },

  observe: function(aSubject, aTopic, aData) {
    switch (aTopic) {
      case "profile-change-teardown":
        this.uninit();
        break;
      case "webapps-clear-data":
        let params =
          aSubject.QueryInterface(Ci.mozIApplicationClearPrivateDataParams);
        if (!params) {
          debug("Error! Fail to remove alarms for an uninstalled app.");
          return;
        }

        let manifestURL = appsService.getManifestURLByLocalId(params.appId);
        if (!manifestURL) {
          debug("Error! Fail to remove alarms for an uninstalled app.");
          return;
        }

        this._db.getAll(
          manifestURL,
          function getAllSuccessCb(aAlarms) {
            aAlarms.forEach(function removeAlarm(aAlarm) {
              this.remove(aAlarm.id, manifestURL);
            }, this);
          }.bind(this),
          function getAllErrorCb(aErrorMsg) {
            throw Components.results.NS_ERROR_NOT_IMPLEMENTED;
          }
        );
        break;
    }
  },

  uninit: function uninit() {
    debug("uninit()");
    Services.obs.removeObserver(this, "profile-change-teardown");
    Services.obs.removeObserver(this, "webapps-clear-data");

    this._messages.forEach(function(aMsgName) {
      ppmm.removeMessageListener(aMsgName, this);
    }.bind(this));
    ppmm = null;

    if (this._db) {
      this._db.close();
    }
    this._db = null;

    this._alarmHalService = null;
  }
}

AlarmService.init();
