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

let EXPORTED_SYMBOLS = ["NewTabUtils"];

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

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

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

XPCOMUtils.defineLazyServiceGetter(this, "gPrivateBrowsing",
  "@mozilla.org/privatebrowsing;1", "nsIPrivateBrowsingService");

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

// The preference that tells whether this feature is enabled.
const PREF_NEWTAB_ENABLED = "browser.newtabpage.enabled";

// The maximum number of results we want to retrieve from history.
const HISTORY_RESULTS_LIMIT = 100;

/**
 * Singleton that provides storage functionality.
 */
let Storage = {
  /**
   * The dom storage instance used to persist data belonging to the New Tab Page.
   */
  get domStorage() {
    let uri = Services.io.newURI("about:newtab", null, null);
    let principal = Services.scriptSecurityManager.getCodebasePrincipal(uri);

    let sm = Services.domStorageManager;
    let storage = sm.getLocalStorageForPrincipal(principal, "");

    // Cache this value, overwrite the getter.
    let descriptor = {value: storage, enumerable: true};
    Object.defineProperty(this, "domStorage", descriptor);

    return storage;
  },

  /**
   * The current storage used to persist New Tab Page data. If we're currently
   * in private browsing mode this will return a PrivateBrowsingStorage
   * instance.
   */
  get currentStorage() {
    let storage = this.domStorage;

    // Check if we're starting in private browsing mode.
    if (gPrivateBrowsing.privateBrowsingEnabled)
      storage = new PrivateBrowsingStorage(storage);

    // Register an observer to listen for private browsing mode changes.
    Services.obs.addObserver(this, "private-browsing", true);

    // Cache this value, overwrite the getter.
    let descriptor = {value: storage, enumerable: true, writable: true};
    Object.defineProperty(this, "currentStorage", descriptor);

    return storage;
  },

  /**
   * Gets the value for a given key from the storage.
   * @param aKey The storage key (a string).
   * @param aDefault A default value if the key doesn't exist.
   * @return The value for the given key.
   */
  get: function Storage_get(aKey, aDefault) {
    let value;

    try {
      value = JSON.parse(this.currentStorage.getItem(aKey));
    } catch (e) {}

    return value || aDefault;
  },

  /**
   * Sets the storage value for a given key.
   * @param aKey The storage key (a string).
   * @param aValue The value to set.
   */
  set: function Storage_set(aKey, aValue) {
    this.currentStorage.setItem(aKey, JSON.stringify(aValue));
  },

  /**
   * Clears the storage and removes all values.
   */
  clear: function Storage_clear() {
    this.currentStorage.clear();
  },

  /**
   * Implements the nsIObserver interface to get notified about private
   * browsing mode changes.
   */
  observe: function Storage_observe(aSubject, aTopic, aData) {
    if (aData == "enter") {
      // When switching to private browsing mode we keep the current state
      // of the grid and provide a volatile storage for it that is
      // discarded upon leaving private browsing.
      this.currentStorage = new PrivateBrowsingStorage(this.domStorage);
    } else {
      // Reset to normal DOM storage.
      this.currentStorage = this.domStorage;

      // When switching back from private browsing we need to reset the
      // grid and re-read its values from the underlying storage. We don't
      // want any data from private browsing to show up.
      PinnedLinks.resetCache();
      BlockedLinks.resetCache();
    }
  },

  QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver,
                                         Ci.nsISupportsWeakReference])
};

/**
 * This class implements a temporary storage used while the user is in private
 * browsing mode. It is discarded when leaving pb mode.
 */
function PrivateBrowsingStorage(aStorage) {
  this._data = new Dict();

  for (let i = 0; i < aStorage.length; i++) {
    let key = aStorage.key(i);
    this._data.set(key, aStorage.getItem(key));
  }
}

PrivateBrowsingStorage.prototype = {
  /**
   * The data store.
   */
  _data: null,

  /**
   * Gets the value for a given key from the storage.
   * @param aKey The storage key.
   * @param aDefault A default value if the key doesn't exist.
   * @return The value for the given key.
   */
  getItem: function PrivateBrowsingStorage_getItem(aKey) {
    return this._data.get(aKey);
  },

  /**
   * Sets the storage value for a given key.
   * @param aKey The storage key.
   * @param aValue The value to set.
   */
  setItem: function PrivateBrowsingStorage_setItem(aKey, aValue) {
    this._data.set(aKey, aValue);
  },

  /**
   * Clears the storage and removes all values.
   */
  clear: function PrivateBrowsingStorage_clear() {
    this._data.listkeys().forEach(function (aKey) {
      this._data.del(aKey);
    }, this);
  }
};

/**
 * Singleton that serves as a registry for all open 'New Tab Page's.
 */
let AllPages = {
  /**
   * The array containing all active pages.
   */
  _pages: [],

  /**
   * Cached value that tells whether the New Tab Page feature is enabled.
   */
  _enabled: null,

  /**
   * Adds a page to the internal list of pages.
   * @param aPage The page to register.
   */
  register: function AllPages_register(aPage) {
    this._pages.push(aPage);
    this._addObserver();
  },

  /**
   * Removes a page from the internal list of pages.
   * @param aPage The page to unregister.
   */
  unregister: function AllPages_unregister(aPage) {
    let index = this._pages.indexOf(aPage);
    this._pages.splice(index, 1);
  },

  /**
   * Returns whether the 'New Tab Page' is enabled.
   */
  get enabled() {
    if (this._enabled === null)
      this._enabled = Services.prefs.getBoolPref(PREF_NEWTAB_ENABLED);

    return this._enabled;
  },

  /**
   * Enables or disables the 'New Tab Page' feature.
   */
  set enabled(aEnabled) {
    if (this.enabled != aEnabled)
      Services.prefs.setBoolPref(PREF_NEWTAB_ENABLED, !!aEnabled);
  },

  /**
   * Returns the number of registered New Tab Pages (i.e. the number of open
   * about:newtab instances).
   */
  get length() {
    return this._pages.length;
  },

  /**
   * Updates all currently active pages but the given one.
   * @param aExceptPage The page to exclude from updating.
   */
  update: function AllPages_update(aExceptPage) {
    this._pages.forEach(function (aPage) {
      if (aExceptPage != aPage)
        aPage.update();
    });
  },

  /**
   * Implements the nsIObserver interface to get notified when the preference
   * value changes.
   */
  observe: function AllPages_observe() {
    // Clear the cached value.
    this._enabled = null;

    let args = Array.slice(arguments);

    this._pages.forEach(function (aPage) {
      aPage.observe.apply(aPage, args);
    }, this);
  },

  /**
   * Adds a preference observer and turns itself into a no-op after the first
   * invokation.
   */
  _addObserver: function AllPages_addObserver() {
    Services.prefs.addObserver(PREF_NEWTAB_ENABLED, this, true);
    this._addObserver = function () {};
  },

  QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver,
                                         Ci.nsISupportsWeakReference])
};

/**
 * Singleton that keeps track of all pinned links and their positions in the
 * grid.
 */
let PinnedLinks = {
  /**
   * The cached list of pinned links.
   */
  _links: null,

  /**
   * The array of pinned links.
   */
  get links() {
    if (!this._links)
      this._links = Storage.get("pinnedLinks", []);

    return this._links;
  },

  /**
   * Pins a link at the given position.
   * @param aLink The link to pin.
   * @param aIndex The grid index to pin the cell at.
   */
  pin: function PinnedLinks_pin(aLink, aIndex) {
    // Clear the link's old position, if any.
    this.unpin(aLink);

    this.links[aIndex] = aLink;
    Storage.set("pinnedLinks", this.links);
  },

  /**
   * Unpins a given link.
   * @param aLink The link to unpin.
   */
  unpin: function PinnedLinks_unpin(aLink) {
    let index = this._indexOfLink(aLink);
    if (index != -1) {
      this.links[index] = null;
      Storage.set("pinnedLinks", this.links);
    }
  },

  /**
   * Checks whether a given link is pinned.
   * @params aLink The link to check.
   * @return whether The link is pinned.
   */
  isPinned: function PinnedLinks_isPinned(aLink) {
    return this._indexOfLink(aLink) != -1;
  },

  /**
   * Resets the links cache.
   */
  resetCache: function PinnedLinks_resetCache() {
    this._links = null;
  },

  /**
   * Finds the index of a given link in the list of pinned links.
   * @param aLink The link to find an index for.
   * @return The link's index.
   */
  _indexOfLink: function PinnedLinks_indexOfLink(aLink) {
    for (let i = 0; i < this.links.length; i++) {
      let link = this.links[i];
      if (link && link.url == aLink.url)
        return i;
    }

    // The given link is unpinned.
    return -1;
  }
};

/**
 * Singleton that keeps track of all blocked links in the grid.
 */
let BlockedLinks = {
  /**
   * The cached list of blocked links.
   */
  _links: null,

  /**
   * The list of blocked links.
   */
  get links() {
    if (!this._links)
      this._links = Storage.get("blockedLinks", {});

    return this._links;
  },

  /**
   * Blocks a given link.
   * @param aLink The link to block.
   */
  block: function BlockedLinks_block(aLink) {
    this.links[aLink.url] = 1;

    // Make sure we unpin blocked links.
    PinnedLinks.unpin(aLink);

    Storage.set("blockedLinks", this.links);
  },

  /**
   * Returns whether a given link is blocked.
   * @param aLink The link to check.
   */
  isBlocked: function BlockedLinks_isBlocked(aLink) {
    return (aLink.url in this.links);
  },

  /**
   * Checks whether the list of blocked links is empty.
   * @return Whether the list is empty.
   */
  isEmpty: function BlockedLinks_isEmpty() {
    return Object.keys(this.links).length == 0;
  },

  /**
   * Resets the links cache.
   */
  resetCache: function BlockedLinks_resetCache() {
    this._links = null;
  }
};

/**
 * Singleton that serves as the default link provider for the grid. It queries
 * the history to retrieve the most frequently visited sites.
 */
let PlacesProvider = {
  /**
   * Gets the current set of links delivered by this provider.
   * @param aCallback The function that the array of links is passed to.
   */
  getLinks: function PlacesProvider_getLinks(aCallback) {
    let options = PlacesUtils.history.getNewQueryOptions();
    options.maxResults = HISTORY_RESULTS_LIMIT;

    // Sort by frecency, descending.
    options.sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_FRECENCY_DESCENDING

    // We don't want source redirects for this query.
    options.redirectsMode = Ci.nsINavHistoryQueryOptions.REDIRECTS_MODE_TARGET;

    let links = [];

    let callback = {
      handleResult: function (aResultSet) {
        let row;

        while (row = aResultSet.getNextRow()) {
          let url = row.getResultByIndex(1);
          let title = row.getResultByIndex(2);
          links.push({url: url, title: title});
        }
      },

      handleError: function (aError) {
        // Should we somehow handle this error?
        aCallback([]);
      },

      handleCompletion: function (aReason) {
        aCallback(links);
      }
    };

    // Execute the query.
    let query = PlacesUtils.history.getNewQuery();
    let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase);
    db.asyncExecuteLegacyQueries([query], 1, options, callback);
  }
};

/**
 * Singleton that provides access to all links contained in the grid (including
 * the ones that don't fit on the grid). A link is a plain object with title
 * and url properties.
 *
 * Example:
 *
 * {url: "http://www.mozilla.org/", title: "Mozilla"}
 */
let Links = {
  /**
   * The links cache.
   */
  _links: null,

  /**
   * The default provider for links.
   */
  _provider: PlacesProvider,

  /**
   * List of callbacks waiting for the cache to be populated.
   */
  _populateCallbacks: [],

  /**
   * Populates the cache with fresh links from the current provider.
   * @param aCallback The callback to call when finished (optional).
   * @param aForce When true, populates the cache even when it's already filled.
   */
  populateCache: function Links_populateCache(aCallback, aForce) {
    let callbacks = this._populateCallbacks;

    // Enqueue the current callback.
    callbacks.push(aCallback);

    // There was a callback waiting already, thus the cache has not yet been
    // populated.
    if (callbacks.length > 1)
      return;

    function executeCallbacks() {
      while (callbacks.length) {
        let callback = callbacks.shift();
        if (callback) {
          try {
            callback();
          } catch (e) {
            // We want to proceed even if a callback fails.
          }
        }
      }
    }

    if (this._links && !aForce) {
      executeCallbacks();
    } else {
      this._provider.getLinks(function (aLinks) {
        this._links = aLinks;
        executeCallbacks();
      }.bind(this));

      this._addObserver();
    }
  },

  /**
   * Gets the current set of links contained in the grid.
   * @return The links in the grid.
   */
  getLinks: function Links_getLinks() {
    let pinnedLinks = Array.slice(PinnedLinks.links);

    // Filter blocked and pinned links.
    let links = this._links.filter(function (link) {
      return !BlockedLinks.isBlocked(link) && !PinnedLinks.isPinned(link);
    });

    // Try to fill the gaps between pinned links.
    for (let i = 0; i < pinnedLinks.length && links.length; i++)
      if (!pinnedLinks[i])
        pinnedLinks[i] = links.shift();

    // Append the remaining links if any.
    if (links.length)
      pinnedLinks = pinnedLinks.concat(links);

    return pinnedLinks;
  },

  /**
   * Resets the links cache.
   */
  resetCache: function Links_resetCache() {
    this._links = [];
  },

  /**
   * Implements the nsIObserver interface to get notified about browser history
   * sanitization.
   */
  observe: function Links_observe(aSubject, aTopic, aData) {
    // Make sure to update open about:newtab instances. If there are no opened
    // pages we can just wait for the next new tab to populate the cache again.
    if (AllPages.length && AllPages.enabled)
      this.populateCache(function () { AllPages.update() }, true);
    else
      this._links = null;
  },

  /**
   * Adds a sanitization observer and turns itself into a no-op after the first
   * invokation.
   */
  _addObserver: function Links_addObserver() {
    Services.obs.addObserver(this, "browser:purge-session-history", true);
    this._addObserver = function () {};
  },

  QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver,
                                         Ci.nsISupportsWeakReference])
};

/**
 * Singleton that provides the public API of this JSM.
 */
let NewTabUtils = {
  /**
   * Resets the NewTabUtils module, its links and its storage.
   */
  reset: function NewTabUtils_reset() {
    Storage.clear();
    Links.resetCache();
    PinnedLinks.resetCache();
    BlockedLinks.resetCache();
  },

  allPages: AllPages,
  links: Links,
  pinnedLinks: PinnedLinks,
  blockedLinks: BlockedLinks
};
