/* 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.EXPORTED_SYMBOLS = [];

const DEBUG = false;
function debug(s) { dump("-*- NotificationDB component: " + s + "\n"); }

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

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

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

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

XPCOMUtils.defineLazyServiceGetter(this, "notificationStorage",
                                   "@mozilla.org/notificationStorage;1",
                                   "nsINotificationStorage");

const NOTIFICATION_STORE_DIR = OS.Constants.Path.profileDir;
const NOTIFICATION_STORE_PATH =
        OS.Path.join(NOTIFICATION_STORE_DIR, "notificationstore.json");

const kMessages = [
  "Notification:Save",
  "Notification:Delete",
  "Notification:GetAll"
];

var NotificationDB = {

  // Ensure we won't call init() while xpcom-shutdown is performed
  _shutdownInProgress: false,

  init: function() {
    if (this._shutdownInProgress) {
      return;
    }

    this.notifications = {};
    this.byTag = {};
    this.loaded = false;

    this.tasks = []; // read/write operation queue
    this.runningTask = null;

    Services.obs.addObserver(this, "xpcom-shutdown", false);
    this.registerListeners();
  },

  registerListeners: function() {
    for (let message of kMessages) {
      ppmm.addMessageListener(message, this);
    }
  },

  unregisterListeners: function() {
    for (let message of kMessages) {
      ppmm.removeMessageListener(message, this);
    }
  },

  observe: function(aSubject, aTopic, aData) {
    if (DEBUG) debug("Topic: " + aTopic);
    if (aTopic == "xpcom-shutdown") {
      this._shutdownInProgress = true;
      Services.obs.removeObserver(this, "xpcom-shutdown");
      this.unregisterListeners();
    }
  },

  filterNonAppNotifications: function(notifications) {
    for (let origin in notifications) {
      let persistentNotificationCount = 0;
      for (let id in notifications[origin]) {
        if (notifications[origin][id].serviceWorkerRegistrationScope) {
          persistentNotificationCount++;
        } else {
          delete notifications[origin][id];
        }
      }
      if (persistentNotificationCount == 0) {
        if (DEBUG) debug("Origin " + origin + " is not linked to an app manifest, deleting.");
        delete notifications[origin];
      }
    }

    return notifications;
  },

  // Attempt to read notification file, if it's not there we will create it.
  load: function() {
    var promise = OS.File.read(NOTIFICATION_STORE_PATH, { encoding: "utf-8"});
    return promise.then(
      function onSuccess(data) {
        if (data.length > 0) {
          // Preprocessing phase intends to cleanly separate any migration-related
          // tasks.
          this.notifications = this.filterNonAppNotifications(JSON.parse(data));
        }

        // populate the list of notifications by tag
        if (this.notifications) {
          for (var origin in this.notifications) {
            this.byTag[origin] = {};
            for (var id in this.notifications[origin]) {
              var curNotification = this.notifications[origin][id];
              if (curNotification.tag) {
                this.byTag[origin][curNotification.tag] = curNotification;
              }
            }
          }
        }

        this.loaded = true;
      }.bind(this),

      // If read failed, we assume we have no notifications to load.
      function onFailure(reason) {
        this.loaded = true;
        return this.createStore();
      }.bind(this)
    );
  },

  // Creates the notification directory.
  createStore: function() {
    var promise = OS.File.makeDir(NOTIFICATION_STORE_DIR, {
      ignoreExisting: true
    });
    return promise.then(
      this.createFile.bind(this)
    );
  },

  // Creates the notification file once the directory is created.
  createFile: function() {
    return OS.File.writeAtomic(NOTIFICATION_STORE_PATH, "");
  },

  // Save current notifications to the file.
  save: function() {
    var data = JSON.stringify(this.notifications);
    return OS.File.writeAtomic(NOTIFICATION_STORE_PATH, data, { encoding: "utf-8"});
  },

  // Helper function: promise will be resolved once file exists and/or is loaded.
  ensureLoaded: function() {
    if (!this.loaded) {
      return this.load();
    } else {
      return Promise.resolve();
    }
  },

  receiveMessage: function(message) {
    if (DEBUG) { debug("Received message:" + message.name); }

    // sendAsyncMessage can fail if the child process exits during a
    // notification storage operation, so always wrap it in a try/catch.
    function returnMessage(name, data) {
      try {
        message.target.sendAsyncMessage(name, data);
      } catch (e) {
        if (DEBUG) { debug("Return message failed, " + name); }
      }
    }

    switch (message.name) {
      case "Notification:GetAll":
        this.queueTask("getall", message.data).then(function(notifications) {
          returnMessage("Notification:GetAll:Return:OK", {
            requestID: message.data.requestID,
            origin: message.data.origin,
            notifications: notifications
          });
        }).catch(function(error) {
          returnMessage("Notification:GetAll:Return:KO", {
            requestID: message.data.requestID,
            origin: message.data.origin,
            errorMsg: error
          });
        });
        break;

      case "Notification:Save":
        this.queueTask("save", message.data).then(function() {
          returnMessage("Notification:Save:Return:OK", {
            requestID: message.data.requestID
          });
        }).catch(function(error) {
          returnMessage("Notification:Save:Return:KO", {
            requestID: message.data.requestID,
            errorMsg: error
          });
        });
        break;

      case "Notification:Delete":
        this.queueTask("delete", message.data).then(function() {
          returnMessage("Notification:Delete:Return:OK", {
            requestID: message.data.requestID
          });
        }).catch(function(error) {
          returnMessage("Notification:Delete:Return:KO", {
            requestID: message.data.requestID,
            errorMsg: error
          });
        });
        break;

      default:
        if (DEBUG) { debug("Invalid message name" + message.name); }
    }
  },

  // We need to make sure any read/write operations are atomic,
  // so use a queue to run each operation sequentially.
  queueTask: function(operation, data) {
    if (DEBUG) { debug("Queueing task: " + operation); }

    var defer = {};

    this.tasks.push({
      operation: operation,
      data: data,
      defer: defer
    });

    var promise = new Promise(function(resolve, reject) {
      defer.resolve = resolve;
      defer.reject = reject;
    });

    // Only run immediately if we aren't currently running another task.
    if (!this.runningTask) {
      if (DEBUG) { debug("Task queue was not running, starting now..."); }
      this.runNextTask();
    }

    return promise;
  },

  runNextTask: function() {
    if (this.tasks.length === 0) {
      if (DEBUG) { debug("No more tasks to run, queue depleted"); }
      this.runningTask = null;
      return;
    }
    this.runningTask = this.tasks.shift();

    // Always make sure we are loaded before performing any read/write tasks.
    this.ensureLoaded()
    .then(function() {
      var task = this.runningTask;

      switch (task.operation) {
        case "getall":
          return this.taskGetAll(task.data);
          break;

        case "save":
          return this.taskSave(task.data);
          break;

        case "delete":
          return this.taskDelete(task.data);
          break;
      }

    }.bind(this))
    .then(function(payload) {
      if (DEBUG) {
        debug("Finishing task: " + this.runningTask.operation);
      }
      this.runningTask.defer.resolve(payload);
    }.bind(this))
    .catch(function(err) {
      if (DEBUG) {
        debug("Error while running " + this.runningTask.operation + ": " + err);
      }
      this.runningTask.defer.reject(new String(err));
    }.bind(this))
    .then(function() {
      this.runNextTask();
    }.bind(this));
  },

  taskGetAll: function(data) {
    if (DEBUG) { debug("Task, getting all"); }
    var origin = data.origin;
    var notifications = [];
    // Grab only the notifications for specified origin.
    if (this.notifications[origin]) {
      for (var i in this.notifications[origin]) {
        notifications.push(this.notifications[origin][i]);
      }
    }
    return Promise.resolve(notifications);
  },

  taskSave: function(data) {
    if (DEBUG) { debug("Task, saving"); }
    var origin = data.origin;
    var notification = data.notification;
    if (!this.notifications[origin]) {
      this.notifications[origin] = {};
      this.byTag[origin] = {};
    }

    // We might have existing notification with this tag,
    // if so we need to remove it before saving the new one.
    if (notification.tag) {
      var oldNotification = this.byTag[origin][notification.tag];
      if (oldNotification) {
        delete this.notifications[origin][oldNotification.id];
      }
      this.byTag[origin][notification.tag] = notification;
    }

    this.notifications[origin][notification.id] = notification;
    return this.save();
  },

  taskDelete: function(data) {
    if (DEBUG) { debug("Task, deleting"); }
    var origin = data.origin;
    var id = data.id;
    if (!this.notifications[origin]) {
      if (DEBUG) { debug("No notifications found for origin: " + origin); }
      return Promise.resolve();
    }

    // Make sure we can find the notification to delete.
    var oldNotification = this.notifications[origin][id];
    if (!oldNotification) {
      if (DEBUG) { debug("No notification found with id: " + id); }
      return Promise.resolve();
    }

    if (oldNotification.tag) {
      delete this.byTag[origin][oldNotification.tag];
    }
    delete this.notifications[origin][id];
    return this.save();
  }
};

NotificationDB.init();
