/* 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 = ["Bookmarks"];

const Cu = Components.utils;

Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://services-common/utils.js");
Cu.import("resource://services-crypto/utils.js");
Cu.import("resource://gre/modules/PlacesUtils.jsm");
Cu.import("resource:///modules/PlacesUIUtils.jsm");

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

Cu.import("resource://gre/modules/Promise.jsm");
Cu.import("resource://gre/modules/Task.jsm");
Cu.import("resource://gre/modules/CloudSyncPlacesWrapper.jsm");
Cu.import("resource://gre/modules/CloudSyncEventSource.jsm");
Cu.import("resource://gre/modules/CloudSyncBookmarksFolderCache.jsm");

const ITEM_TYPES = [
  "NULL",
  "BOOKMARK",
  "FOLDER",
  "SEPARATOR",
  "DYNAMIC_CONTAINER", // no longer used by Places, but this ID should not be used for future item types
];

const CS_UNKNOWN = 0x1;
const CS_FOLDER = 0x1 << 1;
const CS_SEPARATOR = 0x1 << 2;
const CS_QUERY = 0x1 << 3;
const CS_LIVEMARK = 0x1 << 4;
const CS_BOOKMARK = 0x1 << 5;

const EXCLUDE_BACKUP_ANNO = "places/excludeFromBackup";

const DATA_VERSION = 1;

function asyncCallback(ctx, func, args) {
  function invoke() {
    func.apply(ctx, args);
  }
  CommonUtils.nextTick(invoke);
}

var Record = function (params) {
  this.id = params.guid;
  this.parent = params.parent || null;
  this.index = params.position;
  this.title = params.title;
  this.dateAdded = Math.floor(params.dateAdded/1000);
  this.lastModified = Math.floor(params.lastModified/1000);
  this.uri = params.url;

  let annos = params.annos || {};
  Object.defineProperty(this, "annos", {
    get: function () {
      return annos;
    },
    enumerable: false
  });

  switch (params.type) {
    case PlacesUtils.bookmarks.TYPE_FOLDER:
      if (PlacesUtils.LMANNO_FEEDURI in annos) {
        this.type = CS_LIVEMARK;
        this.feed = annos[PlacesUtils.LMANNO_FEEDURI];
        this.site = annos[PlacesUtils.LMANNO_SITEURI];
      } else {
        this.type = CS_FOLDER;
      }
      break;
    case PlacesUtils.bookmarks.TYPE_BOOKMARK:
      if (this.uri.startsWith("place:")) {
        this.type = CS_QUERY;
      } else {
        this.type = CS_BOOKMARK;
      }
      break;
    case PlacesUtils.bookmarks.TYPE_SEPARATOR:
      this.type = CS_SEPARATOR;
      break;
    default:
      this.type = CS_UNKNOWN;
  }
};

Record.prototype = {
  version: DATA_VERSION,
};

var Bookmarks = function () {
  let createRootFolder = function (name) {
    let ROOT_FOLDER_ANNO = "cloudsync/rootFolder/" + name;
    let ROOT_SHORTCUT_ANNO = "cloudsync/rootShortcut/" + name;

    let deferred = Promise.defer();
    let placesRootId = PlacesUtils.placesRootId;
    let rootFolderId;
    let rootShortcutId;

    function createAdapterShortcut(result) {
      rootFolderId = result;
      let uri = "place:folder=" + rootFolderId;
      return PlacesWrapper.insertBookmark(PlacesUIUtils.allBookmarksFolderId, uri,
                                          PlacesUtils.bookmarks.DEFAULT_INDEX, name);
    }

    function setRootFolderCloudSyncAnnotation(result) {
      rootShortcutId = result;
      return PlacesWrapper.setItemAnnotation(rootFolderId, ROOT_FOLDER_ANNO,
                                             1, 0, PlacesUtils.annotations.EXPIRE_NEVER);
    }

    function setRootShortcutCloudSyncAnnotation() {
      return PlacesWrapper.setItemAnnotation(rootShortcutId, ROOT_SHORTCUT_ANNO,
                                             1, 0, PlacesUtils.annotations.EXPIRE_NEVER);
    }

    function setRootFolderExcludeFromBackupAnnotation() {
      return PlacesWrapper.setItemAnnotation(rootFolderId, EXCLUDE_BACKUP_ANNO,
                                             1, 0, PlacesUtils.annotations.EXPIRE_NEVER);
    }

    function finish() {
      deferred.resolve(rootFolderId);
    }

    Promise.resolve(PlacesUtils.bookmarks.createFolder(placesRootId, name, PlacesUtils.bookmarks.DEFAULT_INDEX))
      .then(createAdapterShortcut)
      .then(setRootFolderCloudSyncAnnotation)
      .then(setRootShortcutCloudSyncAnnotation)
      .then(setRootFolderExcludeFromBackupAnnotation)
      .then(finish, deferred.reject);

    return deferred.promise;
  };

  let getRootFolder = function (name) {
    let ROOT_FOLDER_ANNO = "cloudsync/rootFolder/" + name;
    let ROOT_SHORTCUT_ANNO = "cloudsync/rootShortcut/" + name;
    let deferred = Promise.defer();

    function checkRootFolder(folderIds) {
      if (!folderIds.length) {
        return createRootFolder(name);
      }
      return Promise.resolve(folderIds[0]);
    }

    function createFolderObject(folderId) {
      return new RootFolder(folderId, name);
    }

    PlacesWrapper.getLocalIdsWithAnnotation(ROOT_FOLDER_ANNO)
      .then(checkRootFolder, deferred.reject)
      .then(createFolderObject)
      .then(deferred.resolve, deferred.reject);

    return deferred.promise;
  };

  let deleteRootFolder = function (name) {
    let ROOT_FOLDER_ANNO = "cloudsync/rootFolder/" + name;
    let ROOT_SHORTCUT_ANNO = "cloudsync/rootShortcut/" + name;

    let deferred = Promise.defer();
    let placesRootId = PlacesUtils.placesRootId;

    function getRootShortcutId() {
      return PlacesWrapper.getLocalIdsWithAnnotation(ROOT_SHORTCUT_ANNO);
    }

    function deleteShortcut(shortcutIds) {
      if (!shortcutIds.length) {
        return Promise.resolve();
      }
      return PlacesWrapper.removeItem(shortcutIds[0]);
    }

    function getRootFolderId() {
      return PlacesWrapper.getLocalIdsWithAnnotation(ROOT_FOLDER_ANNO);
    }

    function deleteFolder(folderIds) {
      let deleteFolderDeferred = Promise.defer();

      if (!folderIds.length) {
        return Promise.resolve();
      }

      let rootFolderId = folderIds[0];
      PlacesWrapper.removeFolderChildren(rootFolderId).then(
        function () {
          return PlacesWrapper.removeItem(rootFolderId);
        }
      ).then(deleteFolderDeferred.resolve, deleteFolderDeferred.reject);

      return deleteFolderDeferred.promise;
    }

    getRootShortcutId().then(deleteShortcut)
                       .then(getRootFolderId)
                       .then(deleteFolder)
                       .then(deferred.resolve, deferred.reject);

    return deferred.promise;
  };

  /* PUBLIC API */
  this.getRootFolder = getRootFolder.bind(this);
  this.deleteRootFolder = deleteRootFolder.bind(this);

};

this.Bookmarks = Bookmarks;

var RootFolder = function (rootId, rootName) {
  let suspended = true;
  let ignoreAll = false;

  let suspend = function () {
    if (!suspended) {
      PlacesUtils.bookmarks.removeObserver(observer);
      suspended = true;
    }
  }.bind(this);

  let resume = function () {
    if (suspended) {
      PlacesUtils.bookmarks.addObserver(observer, false);
      suspended = false;
    }
  }.bind(this);

  let eventTypes = [
    "add",
    "remove",
    "change",
    "move",
  ];

  let eventSource = new EventSource(eventTypes, suspend, resume);

  let folderCache = new FolderCache;
  folderCache.insert(rootId, null);

  let getCachedFolderIds = function (cache, roots) {
    let nodes = [...roots];
    let results = [];

    while (nodes.length) {
      let node = nodes.shift();
      results.push(node);
      let children = cache.getChildren(node);
      nodes = nodes.concat([...children]);
    }
    return results;
  };

  let getLocalItems = function () {
    let deferred = Promise.defer();

    let folders = getCachedFolderIds(folderCache, folderCache.getChildren(rootId));

    function getFolders(ids) {
      let types = [
        PlacesUtils.bookmarks.TYPE_FOLDER,
      ];
      return PlacesWrapper.getItemsById(ids, types);
    }

    function getContents(parents) {
      parents.push(rootId);
      let types = [
        PlacesUtils.bookmarks.TYPE_BOOKMARK,
        PlacesUtils.bookmarks.TYPE_SEPARATOR,
      ];
      return PlacesWrapper.getItemsByParentId(parents, types)
    }

    function getParentGuids(results) {
      results = Array.prototype.concat.apply([], results);
      let promises = [];
      results.map(function (result) {
        let promise = PlacesWrapper.localIdToGuid(result.parent).then(
          function (guidResult) {
            result.parent = guidResult;
            return Promise.resolve(result);
          },
          Promise.reject.bind(Promise)
        );
        promises.push(promise);
      });
      return Promise.all(promises);
    }

    function getAnnos(results) {
      results = Array.prototype.concat.apply([], results);
      let promises = [];
      results.map(function (result) {
        let promise = PlacesWrapper.getItemAnnotationsForLocalId(result.id).then(
          function (annos) {
            result.annos = annos;
            return Promise.resolve(result);
          },
          Promise.reject.bind(Promise)
        );
        promises.push(promise);
      });
      return Promise.all(promises);
    }

    let promises = [
      getFolders(folders),
      getContents(folders),
    ];

    Promise.all(promises)
           .then(getParentGuids)
           .then(getAnnos)
           .then(function (results) {
                   results = results.map((result) => new Record(result));
                   deferred.resolve(results);
                 },
                 deferred.reject);

    return deferred.promise;
  };

  let getLocalItemsById = function (guids) {
    let deferred = Promise.defer();

    let types = [
      PlacesUtils.bookmarks.TYPE_BOOKMARK,
      PlacesUtils.bookmarks.TYPE_FOLDER,
      PlacesUtils.bookmarks.TYPE_SEPARATOR,
      PlacesUtils.bookmarks.TYPE_DYNAMIC_CONTAINER,
    ];

    function getParentGuids(results) {
      let promises = [];
      results.map(function (result) {
        let promise = PlacesWrapper.localIdToGuid(result.parent).then(
          function (guidResult) {
            result.parent = guidResult;
            return Promise.resolve(result);
          },
          Promise.reject.bind(Promise)
        );
        promises.push(promise);
      });
      return Promise.all(promises);
    }

    PlacesWrapper.getItemsByGuid(guids, types)
                 .then(getParentGuids)
                 .then(function (results) {
                         results = results.map((result) => new Record(result));
                         deferred.resolve(results);
                       },
                       deferred.reject);

    return deferred.promise;
  };

  let _createItem = function (item) {
    let deferred = Promise.defer();

    function getFolderId() {
      if (item.parent) {
        return PlacesWrapper.guidToLocalId(item.parent);
      }
      return Promise.resolve(rootId);
    }

    function create(folderId) {
      let deferred = Promise.defer();

      if (!folderId) {
        folderId = rootId;
      }
      let index = item.hasOwnProperty("index") ? item.index : PlacesUtils.bookmarks.DEFAULT_INDEX;

      function complete(localId) {
        folderCache.insert(localId, folderId);
        deferred.resolve(localId);
      }

      switch (item.type) {
        case CS_BOOKMARK:
        case CS_QUERY:
          PlacesWrapper.insertBookmark(folderId, item.uri, index, item.title, item.id)
                       .then(complete, deferred.reject);
          break;
        case CS_FOLDER:
          PlacesWrapper.createFolder(folderId, item.title, index, item.id)
                       .then(complete, deferred.reject);
          break;
        case CS_SEPARATOR:
          PlacesWrapper.insertSeparator(folderId, index, item.id)
                       .then(complete, deferred.reject);
          break;
        case CS_LIVEMARK:
          let livemark = {
            title: item.title,
            parentId: folderId,
            index: item.index,
            feedURI: item.feed,
            siteURI: item.site,
            guid: item.id,
          };
          PlacesUtils.livemarks.addLivemark(livemark)
                               .then(complete, deferred.reject);
          break;
        default:
          deferred.reject("invalid item type: " + item.type);
      }

      return deferred.promise;
    }

    getFolderId().then(create)
                 .then(deferred.resolve, deferred.reject);

    return deferred.promise;
  };

  let _deleteItem = function (item) {
    let deferred = Promise.defer();

    PlacesWrapper.guidToLocalId(item.id).then(
      function (localId) {
        folderCache.remove(localId);
        return PlacesWrapper.removeItem(localId);
      }
    ).then(deferred.resolve, deferred.reject);

    return deferred.promise;
  };

  let _updateItem = function (item) {
    let deferred = Promise.defer();

    PlacesWrapper.guidToLocalId(item.id).then(
      function (localId) {
        let promises = [];

        if (item.hasOwnProperty("dateAdded")) {
          promises.push(PlacesWrapper.setItemDateAdded(localId, item.dateAdded));
        }

        if (item.hasOwnProperty("lastModified")) {
          promises.push(PlacesWrapper.setItemLastModified(localId, item.lastModified));
        }

        if ((CS_BOOKMARK | CS_FOLDER) & item.type && item.hasOwnProperty("title")) {
          promises.push(PlacesWrapper.setItemTitle(localId, item.title));
        }

        if (CS_BOOKMARK & item.type && item.hasOwnProperty("uri")) {
          promises.push(PlacesWrapper.changeBookmarkURI(localId, item.uri));
        }

        if (item.hasOwnProperty("parent")) {
          let deferred = Promise.defer();
          PlacesWrapper.guidToLocalId(item.parent)
            .then(
                function (parent) {
                  let index = item.hasOwnProperty("index") ? item.index : PlacesUtils.bookmarks.DEFAULT_INDEX;
                  if (CS_FOLDER & item.type) {
                    folderCache.setParent(localId, parent);
                  }
                  return PlacesWrapper.moveItem(localId, parent, index);
                }
              )
            .then(deferred.resolve, deferred.reject);
          promises.push(deferred.promise);
        }

        if (item.hasOwnProperty("index") && !item.hasOwnProperty("parent")) {
          promises.push(Task.spawn(function* () {
            let localItem = (yield getLocalItemsById([item.id]))[0];
            let parent = yield PlacesWrapper.guidToLocalId(localItem.parent);
            let index = item.index;
            if (CS_FOLDER & item.type) {
              folderCache.setParent(localId, parent);
            }
            yield PlacesWrapper.moveItem(localId, parent, index);
          }));
        }

        Promise.all(promises)
               .then(deferred.resolve, deferred.reject);
      }
    );

    return deferred.promise;
  };

  let mergeRemoteItems = function (items) {
    ignoreAll = true;
    let deferred = Promise.defer();

    let newFolders = {};
    let newItems = [];
    let updatedItems = [];
    let deletedItems = [];

    let sortItems = function () {
      let promises = [];

      let exists = function (item) {
        let existsDeferred = Promise.defer();
        if (!item.id) {
          Object.defineProperty(item, "__exists__", {
            value: false,
            enumerable: false
          });
          existsDeferred.resolve(item);
        } else {
          PlacesWrapper.guidToLocalId(item.id).then(
            function (localId) {
              Object.defineProperty(item, "__exists__", {
                value: localId ? true : false,
                enumerable: false
              });
              existsDeferred.resolve(item);
            },
            existsDeferred.reject
          );
        }
        return existsDeferred.promise;
      }

      let handleSortedItem = function (item) {
        if (!item.__exists__ && !item.deleted) {
          if (CS_FOLDER == item.type) {
            newFolders[item.id] = item;
            item._children = [];
          } else {
            newItems.push(item);
          }
        } else if (item.__exists__ && item.deleted) {
          deletedItems.push(item);
        } else if (item.__exists__) {
          updatedItems.push(item);
        }
      }

      for (let item of items) {
        if (!item || 'object' !== typeof(item)) {
          continue;
        }

        let promise = exists(item).then(handleSortedItem, Promise.reject.bind(Promise));
        promises.push(promise);
      }

      return Promise.all(promises);
    }

    let processNewFolders = function () {
      let newFolderGuids = Object.keys(newFolders);
      let newFolderRoots = [];

      for (let guid of newFolderGuids) {
        let item = newFolders[guid];
        if (item.parent && newFolderGuids.indexOf(item.parent) >= 0) {
          let parent = newFolders[item.parent];
          parent._children.push(item.id);
        } else {
          newFolderRoots.push(guid);
        }
      };

      let promises = [];
      for (let guid of newFolderRoots) {
        let root = newFolders[guid];
        let promise = Promise.resolve();
        promise = promise.then(
          function () {
            return _createItem(root);
          },
          Promise.reject.bind(Promise)
        );
        let items = [].concat(root._children);

        while (items.length) {
          let item = newFolders[items.shift()];
          items = items.concat(item._children);
          promise = promise.then(
            function () {
              return _createItem(item);
            },
            Promise.reject.bind(Promise)
          );
        }
        promises.push(promise);
      }

      return Promise.all(promises);
    }

    let processItems = function () {
      let promises = [];

      for (let item of newItems) {
        promises.push(_createItem(item));
      }

      for (let item of updatedItems) {
        promises.push(_updateItem(item));
      }

      for (let item of deletedItems) {
        _deleteItem(item);
      }

      return Promise.all(promises);
    }

    sortItems().then(processNewFolders)
               .then(processItems)
               .then(function () {
                       ignoreAll = false;
                       deferred.resolve(items);
                     },
                     function (err) {
                       ignoreAll = false;
                       deferred.reject(err);
                     });

    return deferred.promise;
  };

  let ignore = function (id, parent) {
    if (ignoreAll) {
      return true;
    }

    if (rootId == parent || folderCache.has(parent)) {
      return false;
    }

    return true;
  };

  let handleItemAdded = function (id, parent, index, type, uri, title, dateAdded, guid, parentGuid) {
    let deferred = Promise.defer();

    if (PlacesUtils.bookmarks.TYPE_FOLDER == type) {
      folderCache.insert(id, parent);
    }

    eventSource.emit("add", guid);
    deferred.resolve();

    return deferred.promise;
  };

  let handleItemRemoved = function (id, parent, index, type, uri, guid, parentGuid) {
    let deferred = Promise.defer();

    if (PlacesUtils.bookmarks.TYPE_FOLDER == type) {
      folderCache.remove(id);
    }

    eventSource.emit("remove", guid);
    deferred.resolve();

    return deferred.promise;
  };

  let handleItemChanged = function (id, property, isAnnotation, newValue, lastModified, type, parent, guid, parentGuid) {
    let deferred = Promise.defer();

    eventSource.emit('change', guid);
    deferred.resolve();

    return deferred.promise;
  };

  let handleItemMoved = function (id, oldParent, oldIndex, newParent, newIndex, type, guid, oldParentGuid, newParentGuid) {
    let deferred = Promise.defer();

    function complete() {
      eventSource.emit('move', guid);
      deferred.resolve();
    }

    if (PlacesUtils.bookmarks.TYPE_FOLDER != type) {
      complete();
      return deferred.promise;
    }

    if (folderCache.has(oldParent) && folderCache.has(newParent)) {
      // Folder move inside cloudSync root, so just update parents/children.
      folderCache.setParent(id, newParent);
      complete();
    } else if (!folderCache.has(oldParent)) {
      // Folder moved in from ouside cloudSync root.
      PlacesWrapper.updateCachedFolderIds(folderCache, newParent)
                   .then(complete, complete);
    } else if (!folderCache.has(newParent)) {
      // Folder moved out from inside cloudSync root.
      PlacesWrapper.updateCachedFolderIds(folderCache, oldParent)
                   .then(complete, complete);
    }

    return deferred.promise;
  };

  let observer = {
    onBeginBatchUpdate: function () {
    },

    onEndBatchUpdate: function () {
    },

    onItemAdded: function (id, parent, index, type, uri, title, dateAdded, guid, parentGuid) {
      if (ignore(id, parent)) {
        return;
      }

      asyncCallback(this, handleItemAdded, Array.prototype.slice.call(arguments));
    },

    onItemRemoved: function (id, parent, index, type, uri, guid, parentGuid) {
      if (ignore(id, parent)) {
        return;
      }

      asyncCallback(this, handleItemRemoved, Array.prototype.slice.call(arguments));
    },

    onItemChanged: function (id, property, isAnnotation, newValue, lastModified, type, parent, guid, parentGuid) {
      if (ignore(id, parent)) {
        return;
      }

      asyncCallback(this, handleItemChanged, Array.prototype.slice.call(arguments));
    },

    onItemMoved: function (id, oldParent, oldIndex, newParent, newIndex, type, guid, oldParentGuid, newParentGuid) {
      if (ignore(id, oldParent) && ignore(id, newParent)) {
        return;
      }

      asyncCallback(this, handleItemMoved, Array.prototype.slice.call(arguments));
    }
  };

  /* PUBLIC API */
  this.addEventListener = eventSource.addEventListener;
  this.removeEventListener = eventSource.removeEventListener;
  this.getLocalItems = getLocalItems.bind(this);
  this.getLocalItemsById = getLocalItemsById.bind(this);
  this.mergeRemoteItems = mergeRemoteItems.bind(this);

  let rootGuid = null; // resolved before becoming ready (below)
  this.__defineGetter__("id", function () {
    return rootGuid;
  });
  this.__defineGetter__("name", function () {
    return rootName;
  });

  let deferred = Promise.defer();
  let getGuidForRootFolder = function () {
    return PlacesWrapper.localIdToGuid(rootId);
  }
  PlacesWrapper.updateCachedFolderIds(folderCache, rootId)
               .then(getGuidForRootFolder, getGuidForRootFolder)
               .then(function (guid) {
                       rootGuid = guid;
                       deferred.resolve(this);
                     }.bind(this),
                     deferred.reject);
  return deferred.promise;
};

RootFolder.prototype = {
  BOOKMARK: CS_BOOKMARK,
  FOLDER: CS_FOLDER,
  SEPARATOR: CS_SEPARATOR,
  QUERY: CS_QUERY,
  LIVEMARK: CS_LIVEMARK,
};