// BEGIN FLOCK GPL
//
// Copyright Flock Inc. 2005-2009
// http://flock.com
//
// This file may be used under the terms of the
// GNU General Public License Version 2 or later (the "GPL"),
// http://www.gnu.org/licenses/gpl.html
//
// Software distributed under the License is distributed on an "AS IS" basis,
// WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
// for the specific language governing rights and limitations under the
// License.
//
// END FLOCK GPL

var EXPORTED_SYMBOLS = ["onlineFavoritesBackend"];

const CC = Components.classes;
const CI = Components.interfaces;
const CR = Components.results;
const CU = Components.utils;

CU.import("resource:///modules/FlockScheduler.jsm");
CU.import("resource:///modules/FlockStringBundleHelpers.jsm");

/**************************************************************************
 * Module: Online Favorites Back End
 **************************************************************************/

/* Note: This module depends on access to the following PRIVATE properties
         of the services being supported:
         - aService._coop
         - aService._logger
         - aService._c_svc
*/

/* EXPLANATION ABOUT THE WAY THE ONLINE BOOKMARKS ARE STORED
  # We create two folders in the Places database: the online root and
    the internal online root.
  # The online root is exposed in the UI and contains one _query_ for each
    account. That query generates a 2-level structure with tags then bookmarks.
    That means that each account is assocated to one query created when
    the account is created, and deleted when ths accound is deleted.
  # The internal online root contains all online bookmarks, regardless of which
    account they belong to. We don't really use it but all bookmarks need to
    have a parent.
  # Online bookmarks get an annotation of the form "flock/account_" + accountUrn.
    This is how we know what bookmark belongs to what account. Note that the
    annotation is on the page (the URI), not the item (the bookmark) because
    Places' query system works on annotations on pages.
  # Online bookmarks with no tag get the single tag "0000:flock:unfiled". This
    is a hack to generates the correct result set without having to deal with
    too complex SQL queries. The four 0 at the beginning ensure that this tag
    is at the top in the alphabetical list. The label gets replaced by a l10n
    string in the UI.
*/

// Bookmark stream refresh interval in seconds.
const DEFAULT_REFRESH_INTERVAL = 5 * 60;
const PREF_ONLINEFAVE_REFRESH_INTERVAL = "flock.favorites.online.refreshInterval";

const ANNO_ONLINE_ROOT = "flock/onlineroot";
const ANNO_INTERNAL_ROOT = "flock/internalroot";
const ANNO_PRIVACY = "flock/onlinePrivacy";
const ANNO_ACCOUNT = "flock/account_";

const FLOCK_MAGIC_FAVORITE = "flock:learnmore";
const FLOCK_QUERY = "flock:query:";
const FLOCK_UNFILED = "0000:flock:unfiled";

const PRIVATE = 0;
const PUBLIC = 1;

/**************************************************************************
 * Private Data and Functions
 **************************************************************************/

// Helper function to assist in comparing strings.
function stringify(aObj) {
  if (!aObj) {
    return "";
  } else {
    return aObj;
  }
}

// Helper function to assist in comparing dates.
function dateify(aObj) {
  if (!aObj) {
    return 0;
  } else {
    return aObj.getTime();
  }
}

// Helper function to assist in comparing lists of tags.
function sanitizeTags(aTagList) {
  return aTagList ? aTagList.split(/[\s,]/).sort().join(",") : "";
}

/**************************************************************************
 * Online Favorites Back End Public Interface
 **************************************************************************/

var onlineFavoritesBackend = {
};
var timersHash = {};

onlineFavoritesBackend.FLOCK_UNFILED = FLOCK_UNFILED;

onlineFavoritesBackend.getBMSVC =
function onlineFavoritesBackend_getBMSVC() {
  if (!this._bmsvc) {
    this._bmsvc = CC["@mozilla.org/browser/nav-bookmarks-service;1"]
                  .getService(CI.nsINavBookmarksService);
  }
  return this._bmsvc;
}

onlineFavoritesBackend.getHISTSVC =
function onlineFavoritesBackend_getHISTSVC() {
  if (!this._histsvc) {
    this._histsvc = CC["@mozilla.org/browser/nav-history-service;1"]
                 .getService(CI.nsINavHistoryService);
  }
  return this._histsvc;
}

onlineFavoritesBackend.getTAGSVC =
function onlineFavoritesBackend_getTAGSVC() {
  if (!this._tagsvc) {
    this._tagsvc = CC["@mozilla.org/browser/tagging-service;1"]
                   .getService(CI.nsITaggingService);
  }
  return this._tagsvc;
}

onlineFavoritesBackend.getANNOSVC =
function onlineFavoritesBackend_getANNOSVC() {
  if (!this._annosvc) {
    this._annosvc = CC["@mozilla.org/browser/annotation-service;1"]
                    .getService(CI.nsIAnnotationService);
  }
  return this._annosvc;
}

onlineFavoritesBackend.getBundle =
function onlineFavoritesBackend_getBundle() {
  if (!this._bundle) {
    this._bundle = CC["@mozilla.org/intl/stringbundle;1"]
                   .getService(CI.nsIStringBundleService)
                   .createBundle("chrome://flock/locale/favorites/favorites.properties");
  }
  return this._bundle;
};

onlineFavoritesBackend.learnAboutOnlineFavoritesURL =
function onlineFavoritesBackend_learnAboutOnlineFavoritesURL() {
  var version = "";
  try {
    version = CC["@mozilla.org/xre/app-info;1"]
              .getService(CI.nsIXULAppInfo)
              .version;
  } catch (ex) {
    // Version is unavailable
  }
  var locale = CC["@mozilla.org/chrome/chrome-registry;1"]
               .getService(CI.nsIXULChromeRegistry)
               .getSelectedLocale("global");

  return "http://www.flock.com/UIpage/"
         + version + "/" + locale + "/onlinefavorites";
};

onlineFavoritesBackend._createOnlineFavesDiscovery =
function onlineFavoritesBackend__createOnlineFavesDiscovery(aFolderId) {
  var magicId = this.getBMSVC().getItemIdForGUID(FLOCK_MAGIC_FAVORITE);
  if (magicId != -1) {
    return;
  }

  var title = this.getBundle()
                  .GetStringFromName("flock.favs.online.discovery.title");
  var url = this.learnAboutOnlineFavoritesURL();
  var uri = CC["@mozilla.org/network/io-service;1"]
            .getService(CI.nsIIOService)
            .newURI(url, null, null);
  var itemId = this.getBMSVC().insertBookmark(aFolderId, uri, -1, title);
  this.getBMSVC().setItemGUID(itemId, FLOCK_MAGIC_FAVORITE);
};

onlineFavoritesBackend._deleteOnlineFavesDiscovery =
function onlineFavoritesBackend__deleteOnlineFavesDiscovery () {
  var magicId = this.getBMSVC().getItemIdForGUID(FLOCK_MAGIC_FAVORITE);
  if (magicId != -1) {
    this.getBMSVC().removeItem(magicId);
  }
};

onlineFavoritesBackend._getFlockRoot =
function onlineFavoritesBackend__getUniqueAnnotatedItem(aAnnoName,
                                                        aDefaultName)
{
  var bmsvc = this.getBMSVC();
  var annos = this.getANNOSVC();

  var results = annos.getItemsWithAnnotation(aAnnoName, {});
  switch (results.length) {
    case 1:
      return results[0];
    case 0:
      var folderId = bmsvc.createFolder(bmsvc.placesRoot,
                                        aDefaultName,
                                        -1);
      bmsvc.setFolderReadonly(folderId, true);
      annos.setItemAnnotation(folderId,
                              aAnnoName,
                              true,
                              0,
                              annos.EXPIRE_NEVER);
      if (aAnnoName == ANNO_ONLINE_ROOT) {
        this._createOnlineFavesDiscovery(folderId);
      }
      return folderId;
    default:
      throw "Multiple root folders found";
      return null;
  }
}

// This is a folder where we dump all online bookmarks; not exposed in the UI
onlineFavoritesBackend.getInternalOnlineRootId =
function onlineFavoritesBackend_getInternalOnlineRootId() {
  if (!this._internalOnlineRootId) {
    this._internalOnlineRootId = this._getFlockRoot(ANNO_INTERNAL_ROOT, "");
  }
  return this._internalOnlineRootId;
}

// This is a folder that contains one folder for each account,
// each containing queries to the tags.
// This is exposed in the UI through a query added in the favorites manager,
// in $m/browser/components/places/content/utils.js
//
// On a fresh profile with no account, it contains a favorite to a page
// describing online faves. This favorite gets automatically deleted
// when the user configures an account.
// (see _createOnlineFavesDiscovery())
onlineFavoritesBackend.getOnlineRootId =
function onlineFavoritesBackend_getOnlineRootId() {
  if (!this._onlineRootId) {
    var title = flockGetString("favorites/favorites",
                               "flock.favs.online.onlineFavorites");
    this._onlineRootId = this._getFlockRoot(ANNO_ONLINE_ROOT, title);
  }
  return this._onlineRootId;
}

onlineFavoritesBackend.getAcUtils =
function onlineFavoritesBackend_getAcUtils() {
  if (!this._acUtils) {
    this._acUtils = CC["@flock.com/account-utils;1"]
                    .getService(CI.flockIAccountUtils);
  }
  return this._acUtils;
}

onlineFavoritesBackend.favoriteExists =
function OFBE_favoriteExists(aService, aAccountId, aURL) {
  var guid = aService.urn + ":" + aAccountId + ":" + aURL;
  var localId = this.getBMSVC().getItemIdForGUID(guid);
  return (localId != -1);
}

onlineFavoritesBackend.isPrivate =
function OFBE_favoriteExists(aService, aAccountId, aURL) {
  var guid = aService.urn + ":" + aAccountId + ":" + aURL;
  var localId = this.getBMSVC().getItemIdForGUID(guid);
  if (localId == -1) {
    // Doesn't exist
    return false;
  }
  try {
    return (this.getANNOSVC()
                .getItemAnnotation(localId, ANNO_PRIVACY) === PRIVATE);
  } catch(ex) {
    // The annotation is not defined
    return false;
  }
};

onlineFavoritesBackend.createAccount =
function OFBE_createAccount(aService, aAccountId, aIsTransient) {
  var account_urn = aService.urn + ":" + aAccountId;
  var timers = this.getTimers(account_urn);
  if (timers.length > 0) {
    // the user just clicked "Forget Account" before. 
    // so abort the deletion of bookmarks
    FlockScheduler.cancel(timers, 0);
  }

  var prefService = CC["@mozilla.org/preferences-service;1"]
                    .getService(CI.nsIPrefBranch);
  var prefRefreshInterval = DEFAULT_REFRESH_INTERVAL;
  if (prefService.getPrefType(PREF_ONLINEFAVE_REFRESH_INTERVAL)) {
    prefRefreshInterval = prefService.getIntPref(PREF_ONLINEFAVE_REFRESH_INTERVAL);
  }

  var coopAccount = aService._coop.get(account_urn);
  if (!coopAccount) {
    coopAccount = new aService._coop.Account(account_urn, {
      name: aAccountId,
      serviceId: aService.contractId,
      service: aService._c_svc,
      accountId: aAccountId,
      favicon: aService.icon,
      URL: aService.getUserUrl(aAccountId),
      isTransient: aIsTransient,
      refreshInterval: prefRefreshInterval
    });
    aService._coop.accounts_root.children.addOnce(coopAccount);
  }
  var account = aService.getAccount(account_urn);
  account.setCustomParam("last_update_time", "never");

  // Add a query for the given account in the online root
  var accountFolderGUID = FLOCK_QUERY + account_urn;
  if (this.getBMSVC().getItemIdForGUID(accountFolderGUID) == -1) {
    var accountAnnotation = ANNO_ACCOUNT + account_urn;
    var placesQueryURL = "place:annotation="
                       + encodeURIComponent(accountAnnotation)
                       + "&type="
                       + CI.nsINavHistoryQueryOptions.RESULTS_AS_TAG_QUERY
                       + "&sort="
                       + CI.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING;

    var uri = CC["@mozilla.org/network/io-service;1"]
              .getService(CI.nsIIOService)
              .newURI(placesQueryURL, null, null);
    var accountLabel = this.getBundle()
                           .formatStringFromName("flock.favs.online.accountLabel",
                                                 [aAccountId, aService.shortName],
                                                 2);
    itemId = this.getBMSVC().insertBookmark(this.getOnlineRootId(),
                                            uri,
                                            -1,
                                            accountLabel);
    this.getBMSVC().setItemGUID(itemId, accountFolderGUID);
  }

  this._deleteOnlineFavesDiscovery();

  // We set this account to isPollable only now because
  // we need the custom params to be there before the initial refresh kicks out
  coopAccount.isPollable = true;

  return account;
};

onlineFavoritesBackend.removeAccount =
function OFBE_removeAccount(aService, aAccountUrn) {
  var timers = this.getTimers(aAccountUrn);
  if (timers.length > 0) {
    FlockScheduler.cancel(timers, 0);
    aService._logger.info("Aborted update of " + aAccountUrn);
  }

  var history = this.getHISTSVC();
  var bmsvc = this.getBMSVC();
  var tagsvc = this.getTAGSVC();
  var annosvc = this.getANNOSVC();

  var account = aService.getAccount(aAccountUrn);
  var accountAnnotation = ANNO_ACCOUNT + aAccountUrn;
  var queryGUID = FLOCK_QUERY + aAccountUrn;

  // Delete the query corresponding to that account from the faves mgr...
  var queryItemId = bmsvc.getItemIdForGUID(queryGUID);
  if (queryItemId != -1) {
    bmsvc.removeItem(queryItemId);
  }

  // ...delete the account itself...
  this.getAcUtils().removeAccount(aAccountUrn);

  // No more online BM account? Then put back the "learn about" favorite
  var accountsEnum = this.getAcUtils()
                         .getAccountsByInterface("flockIBookmarkWebService");
  if (!accountsEnum.hasMoreElements()) {
    this._createOnlineFavesDiscovery(this.getOnlineRootId());
  }

  // Finally delete the favorites, without blocking the UI
  var synchronize = function removeAccount_sync(aShouldYield) {
    var query = history.getNewQuery();
    query.annotation = accountAnnotation;
    var options = history.getNewQueryOptions();
    options.queryType = CI.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS;
    var result = history.executeQuery(query, options);

    var folderNode = result.root;
    folderNode.containerOpen = true;
    for (var i = 0; i < folderNode.childCount; ++i) {
      var childNode = folderNode.getChild(i);
      var uri = bmsvc.getBookmarkURI(childNode.itemId);
      aService._logger.debug("Account deleted: Deleting " + uri.spec);
      tagsvc.untagURI(uri, null);
      annosvc.removePageAnnotation(uri, accountAnnotation);
      // No need to recurse here because we know we created a flat structure
      // (i.e. what we're deleting is a bookmark, not a folder)
      bmsvc.removeItem(childNode.itemId);
      if (aShouldYield()) {
        yield;
      }
    }
  }

  FlockScheduler.schedule(timers, 0.3, 10, synchronize);
};

onlineFavoritesBackend.updateBookmark =
function OFBE_updateBookmark(aService, aAccount, aServerBookmark) {
  var guid = aAccount.urn + ":" + aServerBookmark.URL;
  var serverTags = aServerBookmark.tags.split(/[\s,]/);

  var bmsvc = this.getBMSVC();
  var tagsvc = this.getTAGSVC();
  var annosvc = this.getANNOSVC();

  try {
    var uri = Components.classes["@mozilla.org/network/io-service;1"]
              .getService(Components.interfaces.nsIIOService)
              .newURI(aServerBookmark.URL, null, null);
    var localId = bmsvc.getItemIdForGUID(guid);
    // Create/update the bookmark itself
    if (localId != -1) {
      // Need to check if any modification is needed before we assert anything
      var modified = false;
      if (stringify(aServerBookmark.name) !==
          stringify(bmsvc.getItemTitle(localId)))
      {
        modified = true;
        bmsvc.setItemTitle(localId, aServerBookmark.name);
      }
      if (aServerBookmark.time > bmsvc.getItemLastModified(localId)) {
        modified = true;
        bmsvc.setItemLastModified(localId, aServerBookmark.time);
      }
      var shared = annosvc.getItemAnnotation(localId, ANNO_PRIVACY) !== PRIVATE;
      if (shared != aServerBookmark.shared) {
        modified = true;
        annosvc.setItemAnnotation(localId,
                                  ANNO_PRIVACY,
                                  aServerBookmark.shared ? PUBLIC : PRIVATE,
                                  0,
                                  CI.nsIAnnotationService.EXPIRE_NEVER);
      }
      serverTags.sort(); // getTagsForURI returns sorted tags
      if (serverTags.join(" ") != tagsvc.getTagsForURI(uri, {}).join(" ")) {
        modified = true;
        tagsvc.untagURI(uri, null);
      } else {
        serverTags = null;
      }
      if (modified) {
        aService._logger.debug("Modified on server : Updating "
                               + aServerBookmark.URL);
      }
    } else {
      // New bookmark
      aService._logger.debug("New on server      : Creating "
                             + aServerBookmark.URL);

      localId = bmsvc.insertBookmark(this.getInternalOnlineRootId(),
                                     uri,
                                     CI.nsINavBookmarksService.DEFAULT_INDEX,
                                     aServerBookmark.name);
      bmsvc.setItemLastModified(localId, aServerBookmark.time);
      bmsvc.setItemGUID(localId, guid);
      annosvc.setItemAnnotation(localId,
                                ANNO_PRIVACY,
                                aServerBookmark.shared ? PUBLIC : PRIVATE,
                                0,
                                CI.nsIAnnotationService.EXPIRE_NEVER);
      annosvc.setPageAnnotation(uri,
                                ANNO_ACCOUNT + aAccount.urn,
                                aAccount.urn,
                                0,
                                CI.nsIAnnotationService.EXPIRE_NEVER);
    }

    if (serverTags != null) {
      if ((aServerBookmark.tags === "system:unfiled") ||
          (aServerBookmark.tags === ""))
      {
        // We use this hack (system tag for untagged bookmark) to generate the
        // "no tag" folder, that would be a special case otherwise
        serverTags = [FLOCK_UNFILED];
      }
      tagsvc.tagURI(uri, serverTags);
    }
  } catch (ex) {
    // Usually happen when we get an invalid URL (newURI throws)
    var msg = aServerBookmark.URL + "' -- " + ex.message;
    aService._logger.error("Error creating the local cache for: '" + msg);
  }
};

onlineFavoritesBackend.destroyBookmark =
function OFBE_destroyBookmark(aService, aAccountId, aUrl) {
  var urn = aService.urn + ":" + aAccountId + ":" + aUrl;
  var bookmark = aService._coop.get(urn);

  if (bookmark) {
    bookmark.destroy();
  }
};

onlineFavoritesBackend.getTimers =
function OFBE_getTimers(aAccountUrn) {
  var timers = timersHash[aAccountUrn];
  if (!timers) {
    timers = [];
    timersHash[aAccountUrn] = timers;
  }
  return timers;
};

onlineFavoritesBackend.updateLocal =
function OFBE_updateLocal(aService, aPostsList, aLastUpdate, aAccountUrn) {
  var timers = this.getTimers(aAccountUrn);
  if (timers.length > 0) {
    // let the previous update finish.
    aService._logger.debug("Ignoring refresh of " + aAccountUrn);
    return;
  }

  var i;

  var be = this;
  var synchronize = function OFBE_updateLocal_sync(should_yield) {
    var urlHash = {}; // Useful to optimize deletion

    var account = aService.getAccount(aAccountUrn);

    var coopAccount = aService._coop.get(aAccountUrn);
    // Create new bookmarks, or update existing bookmarks
    for (i = 0; i < aPostsList.length; i++) {
      // To optimize deletion: put the URLs in a hash
      urlHash[aPostsList[i].URL] = true;
      be.updateBookmark(aService, account, aPostsList[i]);
      if (should_yield()) {
        yield;
        var trigger = new Date(Date.now() + DEFAULT_REFRESH_INTERVAL * 1000);
        if (trigger > coopAccount.nextRefresh) {
          // Make sure that we have time to finish this update
          // before another one comes
          coopAccount.nextRefresh =
            new Date(trigger.getTime() + coopAccount.refreshInterval * 1000);
          aService._logger.debug("Pushing next refresh to "
                                 + coopAccount.nextRefresh);
        }
      }
    }

    // Remove local cache of bookmarks deleted on the server
    var history = be.getHISTSVC();
    var bmsvc = be.getBMSVC();
    var tagsvc = be.getTAGSVC();
    var annosvc = be.getANNOSVC();

    var accountAnnotation = ANNO_ACCOUNT + aAccountUrn;
    var query = history.getNewQuery();
    query.annotation = accountAnnotation;
    var options = history.getNewQueryOptions();
    options.queryType = CI.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS;
    var result = history.executeQuery(query, options);

    // Open the folder, and iterate over its contents.
    var folderNode = result.root;
    folderNode.containerOpen = true;
    for (var i = 0; i < folderNode.childCount; ++i) {
      var childNode = folderNode.getChild(i);
      var uri = bmsvc.getBookmarkURI(childNode.itemId);
      if (!urlHash[uri.spec]) {
        aService._logger.debug("Deleted on server  : Deleting " + uri.spec);
        tagsvc.untagURI(uri, null);
        annosvc.removePageAnnotation(uri, accountAnnotation);
        bmsvc.removeItem(childNode.itemId);
      }
      if (should_yield()) {
        yield;
      }
    }

    // Update the last update time for the bookmark stream.
    // Do it last in case the user shuts down before it's finished.
    account.setCustomParam("last_update_time", aLastUpdate);
  };

  FlockScheduler.schedule(timers, 0.1, 10, synchronize);
};

onlineFavoritesBackend.showNotification =
function OFBE_showNotification(aMessage) {
  var wm = CC["@mozilla.org/appshell/window-mediator;1"]
           .getService(CI.nsIWindowMediator);
  var topNavWindow = wm.getMostRecentWindow("navigator:browser");

  var nBox = topNavWindow.gBrowser.getNotificationBox();
  var notification = nBox.getNotificationWithValue("favorite-error");
  if (notification) {
    notification.label = aMessage;
  } else {
    nBox.appendNotification(aMessage,
                            "favorite-error",
                            "chrome://browser/skin/Info.png",
                            nBox.FLOCK_PRIORITY_HIGH,
                            null);
  }
};
