/* 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/. */

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

const CACHE_MAX_GROUP_ENTRIES = 100;

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

function ContentPrefService() {
  if (Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_CONTENT) {
    return Cu.import("resource://gre/modules/ContentPrefServiceChild.jsm")
             .ContentPrefServiceChild;
  }

  // If this throws an exception, it causes the getService call to fail,
  // but the next time a consumer tries to retrieve the service, we'll try
  // to initialize the database again, which might work if the failure
  // was due to a temporary condition (like being out of disk space).
  this._dbInit();

  this._observerSvc.addObserver(this, "last-pb-context-exited", false);

  // Observe shutdown so we can shut down the database connection.
  this._observerSvc.addObserver(this, "xpcom-shutdown", false);
}

Cu.import("resource://gre/modules/ContentPrefStore.jsm");
const cache = new ContentPrefStore();
cache.set = function CPS_cache_set(group, name, val) {
  Object.getPrototypeOf(this).set.apply(this, arguments);
  let groupCount = this._groups.size;
  if (groupCount >= CACHE_MAX_GROUP_ENTRIES) {
    // Clean half of the entries
    for (let [group, name, ] of this) {
      this.remove(group, name);
      groupCount--;
      if (groupCount < CACHE_MAX_GROUP_ENTRIES / 2)
        break;
    }
  }
};

const privModeStorage = new ContentPrefStore();

ContentPrefService.prototype = {
  //**************************************************************************//
  // XPCOM Plumbing

  classID: Components.ID("{e3f772f3-023f-4b32-b074-36cf0fd5d414}"),

  QueryInterface: function CPS_QueryInterface(iid) {
    let supportedIIDs = [
      Ci.nsIContentPrefService,
      Ci.nsISupports,
    ];
    if (supportedIIDs.some(i => iid.equals(i)))
      return this;
    if (iid.equals(Ci.nsIContentPrefService2)) {
      if (!this._contentPrefService2) {
        let s = {};
        Cu.import("resource://gre/modules/ContentPrefService2.jsm", s);
        this._contentPrefService2 = new s.ContentPrefService2(this);
      }
      return this._contentPrefService2;
    }
    throw Cr.NS_ERROR_NO_INTERFACE;
  },

  //**************************************************************************//
  // Convenience Getters

  // Observer Service
  __observerSvc: null,
  get _observerSvc() {
    if (!this.__observerSvc)
      this.__observerSvc = Cc["@mozilla.org/observer-service;1"].
                           getService(Ci.nsIObserverService);
    return this.__observerSvc;
  },

  // Console Service
  __consoleSvc: null,
  get _consoleSvc() {
    if (!this.__consoleSvc)
      this.__consoleSvc = Cc["@mozilla.org/consoleservice;1"].
                          getService(Ci.nsIConsoleService);
    return this.__consoleSvc;
  },

  // Preferences Service
  __prefSvc: null,
  get _prefSvc() {
    if (!this.__prefSvc)
      this.__prefSvc = Cc["@mozilla.org/preferences-service;1"].
                       getService(Ci.nsIPrefBranch);
    return this.__prefSvc;
  },


  //**************************************************************************//
  // Destruction

  _destroy: function ContentPrefService__destroy() {
    this._observerSvc.removeObserver(this, "xpcom-shutdown");
    this._observerSvc.removeObserver(this, "last-pb-context-exited");

    // Finalize statements which may have been used asynchronously.
    // FIXME(696499): put them in an object cache like other components.
    if (this.__stmtSelectPrefID) {
      this.__stmtSelectPrefID.finalize();
      this.__stmtSelectPrefID = null;
    }
    if (this.__stmtSelectGlobalPrefID) {
      this.__stmtSelectGlobalPrefID.finalize();
      this.__stmtSelectGlobalPrefID = null;
    }
    if (this.__stmtInsertPref) {
      this.__stmtInsertPref.finalize();
      this.__stmtInsertPref = null;
    }
    if (this.__stmtInsertGroup) {
      this.__stmtInsertGroup.finalize();
      this.__stmtInsertGroup = null;
    }
    if (this.__stmtInsertSetting) {
      this.__stmtInsertSetting.finalize();
      this.__stmtInsertSetting = null;
    }
    if (this.__stmtSelectGroupID) {
      this.__stmtSelectGroupID.finalize();
      this.__stmtSelectGroupID = null;
    }
    if (this.__stmtSelectSettingID) {
      this.__stmtSelectSettingID.finalize();
      this.__stmtSelectSettingID = null;
    }
    if (this.__stmtSelectPref) {
      this.__stmtSelectPref.finalize();
      this.__stmtSelectPref = null;
    }
    if (this.__stmtSelectGlobalPref) {
      this.__stmtSelectGlobalPref.finalize();
      this.__stmtSelectGlobalPref = null;
    }
    if (this.__stmtSelectPrefsByName) {
      this.__stmtSelectPrefsByName.finalize();
      this.__stmtSelectPrefsByName = null;
    }
    if (this.__stmtDeleteSettingIfUnused) {
      this.__stmtDeleteSettingIfUnused.finalize();
      this.__stmtDeleteSettingIfUnused = null;
    }
    if(this.__stmtSelectPrefs) {
      this.__stmtSelectPrefs.finalize();
      this.__stmtSelectPrefs = null;
    }
    if(this.__stmtDeleteGroupIfUnused) {
      this.__stmtDeleteGroupIfUnused.finalize();
      this.__stmtDeleteGroupIfUnused = null;
    }
    if (this.__stmtDeletePref) {
      this.__stmtDeletePref.finalize();
      this.__stmtDeletePref = null;
    }
    if (this.__stmtUpdatePref) {
      this.__stmtUpdatePref.finalize();
      this.__stmtUpdatePref = null;
    }

    if (this._contentPrefService2)
      this._contentPrefService2.destroy();

    this._dbConnection.asyncClose();

    // Delete references to XPCOM components to make sure we don't leak them
    // (although we haven't observed leakage in tests).  Also delete references
    // in _observers and _genericObservers to avoid cycles with those that
    // refer to us and don't remove themselves from those observer pools.
    delete this._observers;
    delete this._genericObservers;
    delete this.__consoleSvc;
    delete this.__grouper;
    delete this.__observerSvc;
    delete this.__prefSvc;
  },


  //**************************************************************************//
  // nsIObserver

  observe: function ContentPrefService_observe(subject, topic, data) {
    switch (topic) {
      case "xpcom-shutdown":
        this._destroy();
        break;
      case "last-pb-context-exited":
        this._privModeStorage.removeAll();
        break;
    }
  },


  //**************************************************************************//
  // in-memory cache and private-browsing stores

  _cache: cache,
  _privModeStorage: privModeStorage,

  //**************************************************************************//
  // nsIContentPrefService

  getPref: function ContentPrefService_getPref(aGroup, aName, aContext, aCallback) {
    warnDeprecated();

    if (!aName)
      throw Components.Exception("aName cannot be null or an empty string",
                                 Cr.NS_ERROR_ILLEGAL_VALUE);

    var group = this._parseGroupParam(aGroup);

    if (aContext && aContext.usePrivateBrowsing) {
      if (this._privModeStorage.has(group, aName)) {
        let value = this._privModeStorage.get(group, aName);
        if (aCallback) {
          this._scheduleCallback(function(){aCallback.onResult(value);});
          return undefined;
        }
        return value;
      }
      // if we don't have a pref specific to this private mode browsing
      // session, to try to get one from normal mode
    }

    if (group == null)
      return this._selectGlobalPref(aName, aCallback);
    return this._selectPref(group, aName, aCallback);
  },

  setPref: function ContentPrefService_setPref(aGroup, aName, aValue, aContext) {
    warnDeprecated();

    // If the pref is already set to the value, there's nothing more to do.
    var currentValue = this.getPref(aGroup, aName, aContext);
    if (typeof currentValue != "undefined") {
      if (currentValue == aValue)
        return;
    }

    var group = this._parseGroupParam(aGroup);

    if (aContext && aContext.usePrivateBrowsing) {
      this._privModeStorage.setWithCast(group, aName, aValue);
      this._notifyPrefSet(group, aName, aValue, aContext.usePrivateBrowsing);
      return;
    }

    var settingID = this._selectSettingID(aName) || this._insertSetting(aName);
    var groupID, prefID;
    if (group == null) {
      groupID = null;
      prefID = this._selectGlobalPrefID(settingID);
    }
    else {
      groupID = this._selectGroupID(group) || this._insertGroup(group);
      prefID = this._selectPrefID(groupID, settingID);
    }

    // Update the existing record, if any, or create a new one.
    if (prefID)
      this._updatePref(prefID, aValue);
    else
      this._insertPref(groupID, settingID, aValue);

    this._cache.setWithCast(group, aName, aValue);

    this._notifyPrefSet(group, aName, aValue,
                        aContext ? aContext.usePrivateBrowsing : false);
  },

  hasPref: function ContentPrefService_hasPref(aGroup, aName, aContext) {
    warnDeprecated();

    // XXX If consumers end up calling this method regularly, then we should
    // optimize this to query the database directly.
    return (typeof this.getPref(aGroup, aName, aContext) != "undefined");
  },

  hasCachedPref: function ContentPrefService_hasCachedPref(aGroup, aName, aContext) {
    warnDeprecated();

    if (!aName)
      throw Components.Exception("aName cannot be null or an empty string",
                                 Cr.NS_ERROR_ILLEGAL_VALUE);

    let group = this._parseGroupParam(aGroup);
    let storage = aContext && aContext.usePrivateBrowsing ? this._privModeStorage: this._cache;
    return storage.has(group, aName);
  },

  removePref: function ContentPrefService_removePref(aGroup, aName, aContext) {
    warnDeprecated();

    // If there's no old value, then there's nothing to remove.
    if (!this.hasPref(aGroup, aName, aContext))
      return;

    var group = this._parseGroupParam(aGroup);

    if (aContext && aContext.usePrivateBrowsing) {
      this._privModeStorage.remove(group, aName);
      this._notifyPrefRemoved(group, aName);
      return;
    }

    var settingID = this._selectSettingID(aName);
    var groupID, prefID;
    if (group == null) {
      groupID = null;
      prefID = this._selectGlobalPrefID(settingID);
    }
    else {
      groupID = this._selectGroupID(group);
      prefID = this._selectPrefID(groupID, settingID);
    }

    this._deletePref(prefID);

    // Get rid of extraneous records that are no longer being used.
    this._deleteSettingIfUnused(settingID);
    if (groupID)
      this._deleteGroupIfUnused(groupID);

    this._cache.remove(group, aName);
    this._notifyPrefRemoved(group, aName);
  },

  removeGroupedPrefs: function ContentPrefService_removeGroupedPrefs(aContext) {
    warnDeprecated();

    // will not delete global preferences
    if (aContext && aContext.usePrivateBrowsing) {
        // keep only global prefs
        this._privModeStorage.removeAllGroups();
    }
    this._cache.removeAllGroups();
    this._dbConnection.beginTransaction();
    try {
      this._dbConnection.executeSimpleSQL("DELETE FROM prefs WHERE groupID IS NOT NULL");
      this._dbConnection.executeSimpleSQL("DELETE FROM groups");
      this._dbConnection.executeSimpleSQL(`
        DELETE FROM settings
        WHERE id NOT IN (SELECT DISTINCT settingID FROM prefs)
      `);
      this._dbConnection.commitTransaction();
    }
    catch(ex) {
      this._dbConnection.rollbackTransaction();
      throw ex;
    }
  },

  removePrefsByName: function ContentPrefService_removePrefsByName(aName, aContext) {
    warnDeprecated();

    if (!aName)
      throw Components.Exception("aName cannot be null or an empty string",
                                 Cr.NS_ERROR_ILLEGAL_VALUE);

    if (aContext && aContext.usePrivateBrowsing) {
      for (let [group, name, ] of this._privModeStorage) {
        if (name === aName) {
          this._privModeStorage.remove(group, aName);
          this._notifyPrefRemoved(group, aName);
        }
      }
    }

    var settingID = this._selectSettingID(aName);
    if (!settingID)
      return;

    var selectGroupsStmt = this._dbCreateStatement(`
      SELECT groups.id AS groupID, groups.name AS groupName
      FROM prefs
      JOIN groups ON prefs.groupID = groups.id
      WHERE prefs.settingID = :setting
    `);

    var groupNames = [];
    var groupIDs = [];
    try {
      selectGroupsStmt.params.setting = settingID;

      while (selectGroupsStmt.executeStep()) {
        groupIDs.push(selectGroupsStmt.row["groupID"]);
        groupNames.push(selectGroupsStmt.row["groupName"]);
      }
    }
    finally {
      selectGroupsStmt.reset();
    }

    if (this.hasPref(null, aName)) {
      groupNames.push(null);
    }

    this._dbConnection.executeSimpleSQL("DELETE FROM prefs WHERE settingID = " + settingID);
    this._dbConnection.executeSimpleSQL("DELETE FROM settings WHERE id = " + settingID);

    for (var i = 0; i < groupNames.length; i++) {
      this._cache.remove(groupNames[i], aName);
      if (groupNames[i]) // ie. not null, which will be last (and i == groupIDs.length)
        this._deleteGroupIfUnused(groupIDs[i]);
      if (!aContext || !aContext.usePrivateBrowsing) {
        this._notifyPrefRemoved(groupNames[i], aName);
      }
    }
  },

  getPrefs: function ContentPrefService_getPrefs(aGroup, aContext) {
    warnDeprecated();

    var group = this._parseGroupParam(aGroup);
    if (aContext && aContext.usePrivateBrowsing) {
        let prefs = Cc["@mozilla.org/hash-property-bag;1"].
                    createInstance(Ci.nsIWritablePropertyBag);
        for (let [sgroup, sname, sval] of this._privModeStorage) {
          if (sgroup === group)
            prefs.setProperty(sname, sval);
        }
        return prefs;
    }

    if (group == null)
      return this._selectGlobalPrefs();
    return this._selectPrefs(group);
  },

  getPrefsByName: function ContentPrefService_getPrefsByName(aName, aContext) {
    warnDeprecated();

    if (!aName)
      throw Components.Exception("aName cannot be null or an empty string",
                                 Cr.NS_ERROR_ILLEGAL_VALUE);

    if (aContext && aContext.usePrivateBrowsing) {
      let prefs = Cc["@mozilla.org/hash-property-bag;1"].
                  createInstance(Ci.nsIWritablePropertyBag);
      for (let [sgroup, sname, sval] of this._privModeStorage) {
        if (sname === aName)
          prefs.setProperty(sgroup, sval);
      }
      return prefs;
    }

    return this._selectPrefsByName(aName);
  },

  // A hash of arrays of observers, indexed by setting name.
  _observers: {},

  // An array of generic observers, which observe all settings.
  _genericObservers: [],

  addObserver: function ContentPrefService_addObserver(aName, aObserver) {
    warnDeprecated();
    this._addObserver.apply(this, arguments);
  },

  _addObserver: function ContentPrefService__addObserver(aName, aObserver) {
    var observers;
    if (aName) {
      if (!this._observers[aName])
        this._observers[aName] = [];
      observers = this._observers[aName];
    }
    else
      observers = this._genericObservers;

    if (observers.indexOf(aObserver) == -1)
      observers.push(aObserver);
  },

  removeObserver: function ContentPrefService_removeObserver(aName, aObserver) {
    warnDeprecated();
    this._removeObserver.apply(this, arguments);
  },

  _removeObserver: function ContentPrefService__removeObserver(aName, aObserver) {
    var observers;
    if (aName) {
      if (!this._observers[aName])
        return;
      observers = this._observers[aName];
    }
    else
      observers = this._genericObservers;

    if (observers.indexOf(aObserver) != -1)
      observers.splice(observers.indexOf(aObserver), 1);
  },

  /**
   * Construct a list of observers to notify about a change to some setting,
   * putting setting-specific observers before before generic ones, so observers
   * that initialize individual settings (like the page style controller)
   * execute before observers that display multiple settings and depend on them
   * being initialized first (like the content prefs sidebar).
   */
  _getObservers: function ContentPrefService__getObservers(aName) {
    var observers = [];

    if (aName && this._observers[aName])
      observers = observers.concat(this._observers[aName]);
    observers = observers.concat(this._genericObservers);

    return observers;
  },

  /**
   * Notify all observers about the removal of a preference.
   */
  _notifyPrefRemoved: function ContentPrefService__notifyPrefRemoved(aGroup, aName) {
    for (var observer of this._getObservers(aName)) {
      try {
        observer.onContentPrefRemoved(aGroup, aName);
      }
      catch(ex) {
        Cu.reportError(ex);
      }
    }
  },

  /**
   * Notify all observers about a preference change.
   */
  _notifyPrefSet: function ContentPrefService__notifyPrefSet(aGroup, aName, aValue, aIsPrivate) {
    for (var observer of this._getObservers(aName)) {
      try {
        observer.onContentPrefSet(aGroup, aName, aValue, aIsPrivate);
      }
      catch(ex) {
        Cu.reportError(ex);
      }
    }
  },

  get grouper() {
    warnDeprecated();
    return this._grouper;
  },
  __grouper: null,
  get _grouper() {
    if (!this.__grouper)
      this.__grouper = Cc["@mozilla.org/content-pref/hostname-grouper;1"].
                       getService(Ci.nsIContentURIGrouper);
    return this.__grouper;
  },

  get DBConnection() {
    warnDeprecated();
    return this._dbConnection;
  },


  //**************************************************************************//
  // Data Retrieval & Modification

  __stmtSelectPref: null,
  get _stmtSelectPref() {
    if (!this.__stmtSelectPref)
      this.__stmtSelectPref = this._dbCreateStatement(`
        SELECT prefs.value AS value
        FROM prefs
        JOIN groups ON prefs.groupID = groups.id
        JOIN settings ON prefs.settingID = settings.id
        WHERE groups.name = :group
        AND settings.name = :setting
      `);

    return this.__stmtSelectPref;
  },

  _scheduleCallback: function(func) {
    let tm = Cc["@mozilla.org/thread-manager;1"].getService(Ci.nsIThreadManager);
    tm.mainThread.dispatch(func, Ci.nsIThread.DISPATCH_NORMAL);
  },

  _selectPref: function ContentPrefService__selectPref(aGroup, aSetting, aCallback) {
    let value = undefined;
    if (this._cache.has(aGroup, aSetting)) {
      value = this._cache.get(aGroup, aSetting);
      if (aCallback) {
        this._scheduleCallback(function(){aCallback.onResult(value);});
        return undefined;
      }
      return value;
    }

    try {
      this._stmtSelectPref.params.group = aGroup;
      this._stmtSelectPref.params.setting = aSetting;

      if (aCallback) {
        let cache = this._cache;
        new AsyncStatement(this._stmtSelectPref).execute({onResult: function(aResult) {
          cache.set(aGroup, aSetting, aResult);
          aCallback.onResult(aResult);
        }});
      }
      else {
        if (this._stmtSelectPref.executeStep()) {
          value = this._stmtSelectPref.row["value"];
        }
        this._cache.set(aGroup, aSetting, value);
      }
    }
    finally {
      this._stmtSelectPref.reset();
    }

    return value;
  },

  __stmtSelectGlobalPref: null,
  get _stmtSelectGlobalPref() {
    if (!this.__stmtSelectGlobalPref)
      this.__stmtSelectGlobalPref = this._dbCreateStatement(`
        SELECT prefs.value AS value
        FROM prefs
        JOIN settings ON prefs.settingID = settings.id
        WHERE prefs.groupID IS NULL
        AND settings.name = :name
      `);

    return this.__stmtSelectGlobalPref;
  },

  _selectGlobalPref: function ContentPrefService__selectGlobalPref(aName, aCallback) {
    let value = undefined;
    if (this._cache.has(null, aName)) {
      value = this._cache.get(null, aName);
      if (aCallback) {
        this._scheduleCallback(function(){aCallback.onResult(value);});
        return undefined;
      }
      return value;
    }

    try {
      this._stmtSelectGlobalPref.params.name = aName;

      if (aCallback) {
        let cache = this._cache;
        new AsyncStatement(this._stmtSelectGlobalPref).execute({onResult: function(aResult) {
          cache.set(null, aName, aResult);
          aCallback.onResult(aResult);
        }});
      }
      else {
        if (this._stmtSelectGlobalPref.executeStep()) {
          value = this._stmtSelectGlobalPref.row["value"];
        }
        this._cache.set(null, aName, value);
      }
    }
    finally {
      this._stmtSelectGlobalPref.reset();
    }

    return value;
  },

  __stmtSelectGroupID: null,
  get _stmtSelectGroupID() {
    if (!this.__stmtSelectGroupID)
      this.__stmtSelectGroupID = this._dbCreateStatement(`
        SELECT groups.id AS id
        FROM groups
        WHERE groups.name = :name
      `);

    return this.__stmtSelectGroupID;
  },

  _selectGroupID: function ContentPrefService__selectGroupID(aName) {
    var id;

    try {
      this._stmtSelectGroupID.params.name = aName;

      if (this._stmtSelectGroupID.executeStep())
        id = this._stmtSelectGroupID.row["id"];
    }
    finally {
      this._stmtSelectGroupID.reset();
    }

    return id;
  },

  __stmtInsertGroup: null,
  get _stmtInsertGroup() {
    if (!this.__stmtInsertGroup)
      this.__stmtInsertGroup = this._dbCreateStatement(
        "INSERT INTO groups (name) VALUES (:name)"
      );

    return this.__stmtInsertGroup;
  },

  _insertGroup: function ContentPrefService__insertGroup(aName) {
    this._stmtInsertGroup.params.name = aName;
    this._stmtInsertGroup.execute();
    return this._dbConnection.lastInsertRowID;
  },

  __stmtSelectSettingID: null,
  get _stmtSelectSettingID() {
    if (!this.__stmtSelectSettingID)
      this.__stmtSelectSettingID = this._dbCreateStatement(
        "SELECT id FROM settings WHERE name = :name"
      );

    return this.__stmtSelectSettingID;
  },

  _selectSettingID: function ContentPrefService__selectSettingID(aName) {
    var id;

    try {
      this._stmtSelectSettingID.params.name = aName;

      if (this._stmtSelectSettingID.executeStep())
        id = this._stmtSelectSettingID.row["id"];
    }
    finally {
      this._stmtSelectSettingID.reset();
    }

    return id;
  },

  __stmtInsertSetting: null,
  get _stmtInsertSetting() {
    if (!this.__stmtInsertSetting)
      this.__stmtInsertSetting = this._dbCreateStatement(
        "INSERT INTO settings (name) VALUES (:name)"
      );

    return this.__stmtInsertSetting;
  },

  _insertSetting: function ContentPrefService__insertSetting(aName) {
    this._stmtInsertSetting.params.name = aName;
    this._stmtInsertSetting.execute();
    return this._dbConnection.lastInsertRowID;
  },

  __stmtSelectPrefID: null,
  get _stmtSelectPrefID() {
    if (!this.__stmtSelectPrefID)
      this.__stmtSelectPrefID = this._dbCreateStatement(
        "SELECT id FROM prefs WHERE groupID = :groupID AND settingID = :settingID"
      );

    return this.__stmtSelectPrefID;
  },

  _selectPrefID: function ContentPrefService__selectPrefID(aGroupID, aSettingID) {
    var id;

    try {
      this._stmtSelectPrefID.params.groupID = aGroupID;
      this._stmtSelectPrefID.params.settingID = aSettingID;

      if (this._stmtSelectPrefID.executeStep())
        id = this._stmtSelectPrefID.row["id"];
    }
    finally {
      this._stmtSelectPrefID.reset();
    }

    return id;
  },

  __stmtSelectGlobalPrefID: null,
  get _stmtSelectGlobalPrefID() {
    if (!this.__stmtSelectGlobalPrefID)
      this.__stmtSelectGlobalPrefID = this._dbCreateStatement(
        "SELECT id FROM prefs WHERE groupID IS NULL AND settingID = :settingID"
      );

    return this.__stmtSelectGlobalPrefID;
  },

  _selectGlobalPrefID: function ContentPrefService__selectGlobalPrefID(aSettingID) {
    var id;

    try {
      this._stmtSelectGlobalPrefID.params.settingID = aSettingID;

      if (this._stmtSelectGlobalPrefID.executeStep())
        id = this._stmtSelectGlobalPrefID.row["id"];
    }
    finally {
      this._stmtSelectGlobalPrefID.reset();
    }

    return id;
  },

  __stmtInsertPref: null,
  get _stmtInsertPref() {
    if (!this.__stmtInsertPref)
      this.__stmtInsertPref = this._dbCreateStatement(`
        INSERT INTO prefs (groupID, settingID, value)
        VALUES (:groupID, :settingID, :value)
      `);

    return this.__stmtInsertPref;
  },

  _insertPref: function ContentPrefService__insertPref(aGroupID, aSettingID, aValue) {
    this._stmtInsertPref.params.groupID = aGroupID;
    this._stmtInsertPref.params.settingID = aSettingID;
    this._stmtInsertPref.params.value = aValue;
    this._stmtInsertPref.execute();
    return this._dbConnection.lastInsertRowID;
  },

  __stmtUpdatePref: null,
  get _stmtUpdatePref() {
    if (!this.__stmtUpdatePref)
      this.__stmtUpdatePref = this._dbCreateStatement(
        "UPDATE prefs SET value = :value WHERE id = :id"
      );

    return this.__stmtUpdatePref;
  },

  _updatePref: function ContentPrefService__updatePref(aPrefID, aValue) {
    this._stmtUpdatePref.params.id = aPrefID;
    this._stmtUpdatePref.params.value = aValue;
    this._stmtUpdatePref.execute();
  },

  __stmtDeletePref: null,
  get _stmtDeletePref() {
    if (!this.__stmtDeletePref)
      this.__stmtDeletePref = this._dbCreateStatement(
        "DELETE FROM prefs WHERE id = :id"
      );

    return this.__stmtDeletePref;
  },

  _deletePref: function ContentPrefService__deletePref(aPrefID) {
    this._stmtDeletePref.params.id = aPrefID;
    this._stmtDeletePref.execute();
  },

  __stmtDeleteSettingIfUnused: null,
  get _stmtDeleteSettingIfUnused() {
    if (!this.__stmtDeleteSettingIfUnused)
      this.__stmtDeleteSettingIfUnused = this._dbCreateStatement(`
        DELETE FROM settings WHERE id = :id
        AND id NOT IN (SELECT DISTINCT settingID FROM prefs)
      `);

    return this.__stmtDeleteSettingIfUnused;
  },

  _deleteSettingIfUnused: function ContentPrefService__deleteSettingIfUnused(aSettingID) {
    this._stmtDeleteSettingIfUnused.params.id = aSettingID;
    this._stmtDeleteSettingIfUnused.execute();
  },

  __stmtDeleteGroupIfUnused: null,
  get _stmtDeleteGroupIfUnused() {
    if (!this.__stmtDeleteGroupIfUnused)
      this.__stmtDeleteGroupIfUnused = this._dbCreateStatement(`
        DELETE FROM groups WHERE id = :id
        AND id NOT IN (SELECT DISTINCT groupID FROM prefs)
      `);

    return this.__stmtDeleteGroupIfUnused;
  },

  _deleteGroupIfUnused: function ContentPrefService__deleteGroupIfUnused(aGroupID) {
    this._stmtDeleteGroupIfUnused.params.id = aGroupID;
    this._stmtDeleteGroupIfUnused.execute();
  },

  __stmtSelectPrefs: null,
  get _stmtSelectPrefs() {
    if (!this.__stmtSelectPrefs)
      this.__stmtSelectPrefs = this._dbCreateStatement(`
        SELECT settings.name AS name, prefs.value AS value
        FROM prefs
        JOIN groups ON prefs.groupID = groups.id
        JOIN settings ON prefs.settingID = settings.id
        WHERE groups.name = :group
      `);

    return this.__stmtSelectPrefs;
  },

  _selectPrefs: function ContentPrefService__selectPrefs(aGroup) {
    var prefs = Cc["@mozilla.org/hash-property-bag;1"].
                createInstance(Ci.nsIWritablePropertyBag);

    try {
      this._stmtSelectPrefs.params.group = aGroup;

      while (this._stmtSelectPrefs.executeStep())
        prefs.setProperty(this._stmtSelectPrefs.row["name"],
                          this._stmtSelectPrefs.row["value"]);
    }
    finally {
      this._stmtSelectPrefs.reset();
    }

    return prefs;
  },

  __stmtSelectGlobalPrefs: null,
  get _stmtSelectGlobalPrefs() {
    if (!this.__stmtSelectGlobalPrefs)
      this.__stmtSelectGlobalPrefs = this._dbCreateStatement(`
        SELECT settings.name AS name, prefs.value AS value
        FROM prefs
        JOIN settings ON prefs.settingID = settings.id
        WHERE prefs.groupID IS NULL
      `);

    return this.__stmtSelectGlobalPrefs;
  },

  _selectGlobalPrefs: function ContentPrefService__selectGlobalPrefs() {
    var prefs = Cc["@mozilla.org/hash-property-bag;1"].
                createInstance(Ci.nsIWritablePropertyBag);

    try {
      while (this._stmtSelectGlobalPrefs.executeStep())
        prefs.setProperty(this._stmtSelectGlobalPrefs.row["name"],
                          this._stmtSelectGlobalPrefs.row["value"]);
    }
    finally {
      this._stmtSelectGlobalPrefs.reset();
    }

    return prefs;
  },

  __stmtSelectPrefsByName: null,
  get _stmtSelectPrefsByName() {
    if (!this.__stmtSelectPrefsByName)
      this.__stmtSelectPrefsByName = this._dbCreateStatement(`
        SELECT groups.name AS groupName, prefs.value AS value
        FROM prefs
        JOIN groups ON prefs.groupID = groups.id
        JOIN settings ON prefs.settingID = settings.id
        WHERE settings.name = :setting
      `);

    return this.__stmtSelectPrefsByName;
  },

  _selectPrefsByName: function ContentPrefService__selectPrefsByName(aName) {
    var prefs = Cc["@mozilla.org/hash-property-bag;1"].
                createInstance(Ci.nsIWritablePropertyBag);

    try {
      this._stmtSelectPrefsByName.params.setting = aName;

      while (this._stmtSelectPrefsByName.executeStep())
        prefs.setProperty(this._stmtSelectPrefsByName.row["groupName"],
                          this._stmtSelectPrefsByName.row["value"]);
    }
    finally {
      this._stmtSelectPrefsByName.reset();
    }

    var global = this._selectGlobalPref(aName);
    if (typeof global != "undefined") {
      prefs.setProperty(null, global);
    }

    return prefs;
  },


  //**************************************************************************//
  // Database Creation & Access

  _dbVersion: 4,

  _dbSchema: {
    tables: {
      groups:     "id           INTEGER PRIMARY KEY, \
                   name         TEXT NOT NULL",

      settings:   "id           INTEGER PRIMARY KEY, \
                   name         TEXT NOT NULL",

      prefs:      "id           INTEGER PRIMARY KEY, \
                   groupID      INTEGER REFERENCES groups(id), \
                   settingID    INTEGER NOT NULL REFERENCES settings(id), \
                   value        BLOB, \
                   timestamp    INTEGER NOT NULL DEFAULT 0" // Storage in seconds, API in ms. 0 for migrated values.
    },
    indices: {
      groups_idx: {
        table: "groups",
        columns: ["name"]
      },
      settings_idx: {
        table: "settings",
        columns: ["name"]
      },
      prefs_idx: {
        table: "prefs",
        columns: ["timestamp", "groupID", "settingID"]
      }
    }
  },

  _dbConnection: null,

  _dbCreateStatement: function ContentPrefService__dbCreateStatement(aSQLString) {
    try {
      var statement = this._dbConnection.createStatement(aSQLString);
    }
    catch(ex) {
      Cu.reportError("error creating statement " + aSQLString + ": " +
                     this._dbConnection.lastError + " - " +
                     this._dbConnection.lastErrorString);
      throw ex;
    }

    return statement;
  },

  // _dbInit and the methods it calls (_dbCreate, _dbMigrate, and version-
  // specific migration methods) must be careful not to call any method
  // of the service that assumes the database connection has already been
  // initialized, since it won't be initialized until at the end of _dbInit.

  _dbInit: function ContentPrefService__dbInit() {
    var dirService = Cc["@mozilla.org/file/directory_service;1"].
                     getService(Ci.nsIProperties);
    var dbFile = dirService.get("ProfD", Ci.nsIFile);
    dbFile.append("content-prefs.sqlite");

    var dbService = Cc["@mozilla.org/storage/service;1"].
                    getService(Ci.mozIStorageService);

    var dbConnection;

    if (!dbFile.exists())
      dbConnection = this._dbCreate(dbService, dbFile);
    else {
      try {
        dbConnection = dbService.openDatabase(dbFile);
      }
      // If the connection isn't ready after we open the database, that means
      // the database has been corrupted, so we back it up and then recreate it.
      catch (e) {
        if (e.result != Cr.NS_ERROR_FILE_CORRUPTED)
          throw e;
        dbConnection = this._dbBackUpAndRecreate(dbService, dbFile,
                                                 dbConnection);
      }

      // Get the version of the schema in the file.
      var version = dbConnection.schemaVersion;

      // Try to migrate the schema in the database to the current schema used by
      // the service.  If migration fails, back up the database and recreate it.
      if (version != this._dbVersion) {
        try {
          this._dbMigrate(dbConnection, version, this._dbVersion);
        }
        catch(ex) {
          Cu.reportError("error migrating DB: " + ex + "; backing up and recreating");
          dbConnection = this._dbBackUpAndRecreate(dbService, dbFile, dbConnection);
        }
      }
    }

    // Turn off disk synchronization checking to reduce disk churn and speed up
    // operations when prefs are changed rapidly (such as when a user repeatedly
    // changes the value of the browser zoom setting for a site).
    //
    // Note: this could cause database corruption if the OS crashes or machine
    // loses power before the data gets written to disk, but this is considered
    // a reasonable risk for the not-so-critical data stored in this database.
    //
    // If you really don't want to take this risk, however, just set the
    // toolkit.storage.synchronous pref to 1 (NORMAL synchronization) or 2
    // (FULL synchronization), in which case mozStorageConnection::Initialize
    // will use that value, and we won't override it here.
    if (!this._prefSvc.prefHasUserValue("toolkit.storage.synchronous"))
      dbConnection.executeSimpleSQL("PRAGMA synchronous = OFF");

    this._dbConnection = dbConnection;
  },

  _dbCreate: function ContentPrefService__dbCreate(aDBService, aDBFile) {
    var dbConnection = aDBService.openDatabase(aDBFile);

    try {
      this._dbCreateSchema(dbConnection);
      dbConnection.schemaVersion = this._dbVersion;
    }
    catch(ex) {
      // If we failed to create the database (perhaps because the disk ran out
      // of space), then remove the database file so we don't leave it in some
      // half-created state from which we won't know how to recover.
      dbConnection.close();
      aDBFile.remove(false);
      throw ex;
    }

    return dbConnection;
  },

  _dbCreateSchema: function ContentPrefService__dbCreateSchema(aDBConnection) {
    this._dbCreateTables(aDBConnection);
    this._dbCreateIndices(aDBConnection);
  },

  _dbCreateTables: function ContentPrefService__dbCreateTables(aDBConnection) {
    for (let name in this._dbSchema.tables)
      aDBConnection.createTable(name, this._dbSchema.tables[name]);
  },

  _dbCreateIndices: function ContentPrefService__dbCreateIndices(aDBConnection) {
    for (let name in this._dbSchema.indices) {
      let index = this._dbSchema.indices[name];
      let statement = `
        CREATE INDEX IF NOT EXISTS ${name} ON ${index.table}
        (${index.columns.join(", ")})
      `;
      aDBConnection.executeSimpleSQL(statement);
    }
  },

  _dbBackUpAndRecreate: function ContentPrefService__dbBackUpAndRecreate(aDBService,
                                                                         aDBFile,
                                                                         aDBConnection) {
    aDBService.backupDatabaseFile(aDBFile, "content-prefs.sqlite.corrupt");

    // Close the database, ignoring the "already closed" exception, if any.
    // It'll be open if we're here because of a migration failure but closed
    // if we're here because of database corruption.
    try { aDBConnection.close() } catch(ex) {}

    aDBFile.remove(false);

    let dbConnection = this._dbCreate(aDBService, aDBFile);

    return dbConnection;
  },

  _dbMigrate: function ContentPrefService__dbMigrate(aDBConnection, aOldVersion, aNewVersion) {
    /**
     * Migrations should follow the template rules in bug 1074817 comment 3 which are:
     * 1. Migration should be incremental and non-breaking.
     * 2. It should be idempotent because one can downgrade an upgrade again.
     * On downgrade:
     * 1. Decrement schema version so that upgrade runs the migrations again.
     */
    aDBConnection.beginTransaction();

    try {
       /**
       * If the schema version is 0, that means it was never set, which means
       * the database was somehow created without the schema being applied, perhaps
       * because the system ran out of disk space (although we check for this
       * in _createDB) or because some other code created the database file without
       * applying the schema.  In any case, recover by simply reapplying the schema.
       */
      if (aOldVersion == 0) {
        this._dbCreateSchema(aDBConnection);
      } else {
        for (let i = aOldVersion; i < aNewVersion; i++) {
          let migrationName = "_dbMigrate" + i + "To" + (i + 1);
          if (typeof this[migrationName] != 'function') {
            throw("no migrator function from version " + aOldVersion + " to version " + aNewVersion);
          }
          this[migrationName](aDBConnection);
        }
      }
      aDBConnection.schemaVersion = aNewVersion;
      aDBConnection.commitTransaction();
    } catch (ex) {
      aDBConnection.rollbackTransaction();
      throw ex;
    }
  },

  _dbMigrate1To2: function ContentPrefService___dbMigrate1To2(aDBConnection) {
    aDBConnection.executeSimpleSQL("ALTER TABLE groups RENAME TO groupsOld");
    aDBConnection.createTable("groups", this._dbSchema.tables.groups);
    aDBConnection.executeSimpleSQL(`
      INSERT INTO groups (id, name)
      SELECT id, name FROM groupsOld
    `);

    aDBConnection.executeSimpleSQL("DROP TABLE groupers");
    aDBConnection.executeSimpleSQL("DROP TABLE groupsOld");
  },

  _dbMigrate2To3: function ContentPrefService__dbMigrate2To3(aDBConnection) {
    this._dbCreateIndices(aDBConnection);
  },

  _dbMigrate3To4: function ContentPrefService__dbMigrate3To4(aDBConnection) {
    // Add timestamp column if it does not exist yet. This operation is idempotent.
    try {
      let stmt = aDBConnection.createStatement("SELECT timestamp FROM prefs");
      stmt.finalize();
    } catch (e) {
      aDBConnection.executeSimpleSQL("ALTER TABLE prefs ADD COLUMN timestamp INTEGER NOT NULL DEFAULT 0");
    }

    // To modify prefs_idx drop it and create again.
    aDBConnection.executeSimpleSQL("DROP INDEX IF EXISTS prefs_idx");
    this._dbCreateIndices(aDBConnection);
  },

  _parseGroupParam: function ContentPrefService__parseGroupParam(aGroup) {
    if (aGroup == null)
      return null;
    if (aGroup.constructor.name == "String")
      return aGroup.toString();
    if (aGroup instanceof Ci.nsIURI)
      return this.grouper.group(aGroup);

    throw Components.Exception("aGroup is not a string, nsIURI or null",
                               Cr.NS_ERROR_ILLEGAL_VALUE);
  },
};

function warnDeprecated() {
  let Deprecated = Cu.import("resource://gre/modules/Deprecated.jsm", {}).Deprecated;
  Deprecated.warning("nsIContentPrefService is deprecated. Please use nsIContentPrefService2 instead.",
                     "https://developer.mozilla.org/en-US/docs/XPCOM_Interface_Reference/nsIContentPrefService2",
                     Components.stack.caller);
}


function HostnameGrouper() {}

HostnameGrouper.prototype = {
  //**************************************************************************//
  // XPCOM Plumbing

  classID:          Components.ID("{8df290ae-dcaa-4c11-98a5-2429a4dc97bb}"),
  QueryInterface:   XPCOMUtils.generateQI([Ci.nsIContentURIGrouper]),

  //**************************************************************************//
  // nsIContentURIGrouper

  group: function HostnameGrouper_group(aURI) {
    var group;

    try {
      // Accessing the host property of the URI will throw an exception
      // if the URI is of a type that doesn't have a host property.
      // Otherwise, we manually throw an exception if the host is empty,
      // since the effect is the same (we can't derive a group from it).

      group = aURI.host;
      if (!group)
        throw("can't derive group from host; no host in URI");
    }
    catch(ex) {
      // If we don't have a host, then use the entire URI (minus the query,
      // reference, and hash, if possible) as the group.  This means that URIs
      // like about:mozilla and about:blank will be considered separate groups,
      // but at least they'll be grouped somehow.

      // This also means that each individual file: URL will be considered
      // its own group.  This seems suboptimal, but so does treating the entire
      // file: URL space as a single group (especially if folks start setting
      // group-specific capabilities prefs).

      // XXX Is there something better we can do here?

      try {
        var url = aURI.QueryInterface(Ci.nsIURL);
        group = aURI.prePath + url.filePath;
      }
      catch(ex) {
        group = aURI.spec;
      }
    }

    return group;
  }
};

function AsyncStatement(aStatement) {
  this.stmt = aStatement;
}

AsyncStatement.prototype = {
  execute: function AsyncStmt_execute(aCallback) {
    let stmt = this.stmt;
    stmt.executeAsync({
      _callback: aCallback,
      _hadResult: false,
      handleResult: function(aResult) {
        this._hadResult = true;
        if (this._callback) {
          let row = aResult.getNextRow();
          this._callback.onResult(row.getResultByName("value"));
        }
      },
      handleCompletion: function(aReason) {
        if (!this._hadResult && this._callback &&
            aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED)
          this._callback.onResult(undefined);
      },
      handleError: function(aError) {}
    });
  }
};

//****************************************************************************//
// XPCOM Plumbing

var components = [ContentPrefService, HostnameGrouper];
this.NSGetFactory = XPCOMUtils.generateNSGetFactory(components);
