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

import { IMServices } from "resource:///modules/IMServices.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import {
  executeSoon,
  ClassInfo,
  l10nHelper,
} from "resource:///modules/imXPCOMUtils.sys.mjs";

const lazy = {};

XPCOMUtils.defineLazyGetter(lazy, "_", () =>
  l10nHelper("chrome://chat/locale/contacts.properties")
);

var gDBConnection = null;

function executeAsyncThenFinalize(statement) {
  statement.executeAsync();
  statement.finalize();
}

function getDBConnection() {
  const NS_APP_USER_PROFILE_50_DIR = "ProfD";
  let dbFile = Services.dirsvc.get(NS_APP_USER_PROFILE_50_DIR, Ci.nsIFile);
  dbFile.append("blist.sqlite");

  let conn = Services.storage.openDatabase(dbFile);
  if (!conn.connectionReady) {
    throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
  }

  // Grow blist db in 512KB increments.
  try {
    conn.setGrowthIncrement(512 * 1024, "");
  } catch (e) {
    if (e.result == Cr.NS_ERROR_FILE_TOO_BIG) {
      Services.console.logStringMessage(
        "Not setting growth increment on " +
          "blist.sqlite because the available " +
          "disk space is limited"
      );
    } else {
      throw e;
    }
  }

  // Create tables and indexes.
  [
    "CREATE TABLE IF NOT EXISTS accounts (" +
      "id INTEGER PRIMARY KEY, " +
      "name VARCHAR, " +
      "prpl VARCHAR)",

    "CREATE TABLE IF NOT EXISTS contacts (" +
      "id INTEGER PRIMARY KEY, " +
      "firstname VARCHAR, " +
      "lastname VARCHAR, " +
      "alias VARCHAR)",

    "CREATE TABLE IF NOT EXISTS buddies (" +
      "id INTEGER PRIMARY KEY, " +
      "key VARCHAR NOT NULL, " +
      "name VARCHAR NOT NULL, " +
      "srv_alias VARCHAR, " +
      "position INTEGER, " +
      "icon BLOB, " +
      "contact_id INTEGER)",
    "CREATE INDEX IF NOT EXISTS buddies_contactindex " +
      "ON buddies (contact_id)",

    "CREATE TABLE IF NOT EXISTS tags (" +
      "id INTEGER PRIMARY KEY, " +
      "name VARCHAR UNIQUE NOT NULL, " +
      "position INTEGER)",

    "CREATE TABLE IF NOT EXISTS contact_tag (" +
      "contact_id INTEGER NOT NULL, " +
      "tag_id INTEGER NOT NULL)",
    "CREATE INDEX IF NOT EXISTS contact_tag_contactindex " +
      "ON contact_tag (contact_id)",
    "CREATE INDEX IF NOT EXISTS contact_tag_tagindex " +
      "ON contact_tag (tag_id)",

    "CREATE TABLE IF NOT EXISTS account_buddy (" +
      "account_id INTEGER NOT NULL, " +
      "buddy_id INTEGER NOT NULL, " +
      "status VARCHAR, " +
      "tag_id INTEGER)",
    "CREATE INDEX IF NOT EXISTS account_buddy_accountindex " +
      "ON account_buddy (account_id)",
    "CREATE INDEX IF NOT EXISTS account_buddy_buddyindex " +
      "ON account_buddy (buddy_id)",
  ].forEach(conn.executeSimpleSQL);

  return conn;
}

// Wrap all the usage of DBConn inside a transaction that will be
// committed automatically at the end of the event loop spin so that
// we flush buddy list data to disk only once per event loop spin.
var gDBConnWithPendingTransaction = null;
Object.defineProperty(lazy, "DBConn", {
  configurable: true,
  enumerable: true,

  get() {
    if (gDBConnWithPendingTransaction) {
      return gDBConnWithPendingTransaction;
    }

    if (!gDBConnection) {
      gDBConnection = getDBConnection();
      Services.obs.addObserver(function dbClose(aSubject, aTopic, aData) {
        Services.obs.removeObserver(dbClose, aTopic);
        if (gDBConnection) {
          gDBConnection.asyncClose();
          gDBConnection = null;
        }
      }, "profile-before-change");
    }
    gDBConnWithPendingTransaction = gDBConnection;
    gDBConnection.beginTransaction();
    executeSoon(function () {
      gDBConnWithPendingTransaction.commitTransaction();
      gDBConnWithPendingTransaction = null;
    });
    return gDBConnection;
  },
});

export function TagsService() {}
TagsService.prototype = {
  get wrappedJSObject() {
    return this;
  },
  get defaultTag() {
    return this.createTag(lazy._("defaultGroup"));
  },
  createTag(aName) {
    // If the tag already exists, we don't want to create a duplicate.
    let tag = this.getTagByName(aName);
    if (tag) {
      return tag;
    }

    let statement = lazy.DBConn.createStatement(
      "INSERT INTO tags (name, position) VALUES(:name, 0)"
    );
    try {
      statement.params.name = aName;
      statement.executeStep();
    } finally {
      statement.finalize();
    }

    tag = new Tag(lazy.DBConn.lastInsertRowID, aName);
    Tags.push(tag);
    return tag;
  },
  // Get an existing tag by (numeric) id. Returns null if not found.
  getTagById: aId => TagsById[aId],
  // Get an existing tag by name (will do an SQL query). Returns null
  // if not found.
  getTagByName(aName) {
    let statement = lazy.DBConn.createStatement(
      "SELECT id FROM tags where name = :name"
    );
    statement.params.name = aName;
    try {
      if (!statement.executeStep()) {
        return null;
      }
      return this.getTagById(statement.row.id);
    } finally {
      statement.finalize();
    }
  },
  // Get an array of all existing tags.
  getTags() {
    if (Tags.length) {
      Tags.sort((a, b) =>
        a.name.toLowerCase().localeCompare(b.name.toLowerCase())
      );
    } else {
      this.defaultTag;
    }

    return Tags;
  },

  isTagHidden: aTag => aTag.id in otherContactsTag._hiddenTags,
  hideTag(aTag) {
    otherContactsTag.hideTag(aTag);
  },
  showTag(aTag) {
    otherContactsTag.showTag(aTag);
  },
  get otherContactsTag() {
    otherContactsTag._initContacts();
    return otherContactsTag;
  },

  QueryInterface: ChromeUtils.generateQI(["imITagsService"]),
  classDescription: "Tags",
};

// TODO move into the tagsService
var Tags = [];
var TagsById = {};

function Tag(aId, aName) {
  this._id = aId;
  this._name = aName;
  this._contacts = [];
  this._observers = [];

  TagsById[this.id] = this;
}
Tag.prototype = {
  __proto__: ClassInfo("imITag", "Tag"),
  get id() {
    return this._id;
  },
  get name() {
    return this._name;
  },
  set name(aNewName) {
    let statement = lazy.DBConn.createStatement(
      "UPDATE tags SET name = :name WHERE id = :id"
    );
    try {
      statement.params.name = aNewName;
      statement.params.id = this._id;
      statement.execute();
    } finally {
      statement.finalize();
    }

    // FIXME move the account buddies if some use this tag as their group
  },
  getContacts() {
    return this._contacts.filter(c => !c._empty);
  },
  _addContact(aContact) {
    this._contacts.push(aContact);
  },
  _removeContact(aContact) {
    let index = this._contacts.indexOf(aContact);
    if (index != -1) {
      this._contacts.splice(index, 1);
    }
  },

  addObserver(aObserver) {
    if (!this._observers.includes(aObserver)) {
      this._observers.push(aObserver);
    }
  },
  removeObserver(aObserver) {
    this._observers = this._observers.filter(o => o !== aObserver);
  },
  notifyObservers(aSubject, aTopic, aData) {
    for (let observer of this._observers) {
      observer.observe(aSubject, aTopic, aData);
    }
  },
};

var otherContactsTag = {
  __proto__: ClassInfo(["nsIObserver", "imITag"], "Other Contacts Tag"),
  hiddenTagsPref: "messenger.buddies.hiddenTags",
  _hiddenTags: {},
  _contactsInitialized: false,
  _saveHiddenTagsPref() {
    Services.prefs.setCharPref(
      this.hiddenTagsPref,
      Object.keys(this._hiddenTags).join(",")
    );
  },
  showTag(aTag) {
    let id = aTag.id;
    delete this._hiddenTags[id];
    let contacts = Object.keys(this._contacts).map(id => this._contacts[id]);
    for (let contact of contacts) {
      if (contact.getTags().some(t => t.id == id)) {
        this._removeContact(contact);
      }
    }

    aTag.notifyObservers(aTag, "tag-shown");
    Services.obs.notifyObservers(aTag, "tag-shown");
    this._saveHiddenTagsPref();
  },
  hideTag(aTag) {
    if (aTag.id < 0 || aTag.id in otherContactsTag._hiddenTags) {
      return;
    }

    this._hiddenTags[aTag.id] = aTag;
    if (this._contactsInitialized) {
      this._hideTag(aTag);
    }

    aTag.notifyObservers(aTag, "tag-hidden");
    Services.obs.notifyObservers(aTag, "tag-hidden");
    this._saveHiddenTagsPref();
  },
  _hideTag(aTag) {
    for (let contact of aTag.getContacts()) {
      if (
        !(contact.id in this._contacts) &&
        contact.getTags().every(t => t.id in this._hiddenTags)
      ) {
        this._addContact(contact);
      }
    }
  },
  observe(aSubject, aTopic, aData) {
    aSubject.QueryInterface(Ci.imIContact);
    if (aTopic == "contact-tag-removed" || aTopic == "contact-added") {
      if (
        !(aSubject.id in this._contacts) &&
        !(parseInt(aData) in this._hiddenTags) &&
        aSubject.getTags().every(t => t.id in this._hiddenTags)
      ) {
        this._addContact(aSubject);
      }
    } else if (
      aSubject.id in this._contacts &&
      (aTopic == "contact-removed" ||
        (aTopic == "contact-tag-added" &&
          !(parseInt(aData) in this._hiddenTags)))
    ) {
      this._removeContact(aSubject);
    }
  },

  _initHiddenTags() {
    let pref = Services.prefs.getCharPref(this.hiddenTagsPref);
    if (!pref) {
      return;
    }
    for (let tagId of pref.split(",")) {
      this._hiddenTags[tagId] = TagsById[tagId];
    }
  },
  _initContacts() {
    if (this._contactsInitialized) {
      return;
    }
    this._observers = [];
    this._observer = {
      self: this,
      observe(aSubject, aTopic, aData) {
        if (aTopic == "contact-moved-in" && !(aSubject instanceof Contact)) {
          return;
        }

        this.self.notifyObservers(aSubject, aTopic, aData);
      },
    };
    this._contacts = {};
    this._contactsInitialized = true;
    for (let id in this._hiddenTags) {
      let tag = this._hiddenTags[id];
      this._hideTag(tag);
    }
    Services.obs.addObserver(this, "contact-tag-added");
    Services.obs.addObserver(this, "contact-tag-removed");
    Services.obs.addObserver(this, "contact-added");
    Services.obs.addObserver(this, "contact-removed");
  },

  // imITag implementation
  get id() {
    return -1;
  },
  get name() {
    return "__others__";
  },
  set name(aNewName) {
    throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE);
  },
  getContacts() {
    return Object.keys(this._contacts).map(id => this._contacts[id]);
  },
  _addContact(aContact) {
    this._contacts[aContact.id] = aContact;
    this.notifyObservers(aContact, "contact-moved-in");
    for (let observer of ContactsById[aContact.id]._observers) {
      observer.observe(this, "contact-moved-in", null);
    }
    aContact.addObserver(this._observer);
  },
  _removeContact(aContact) {
    delete this._contacts[aContact.id];
    aContact.removeObserver(this._observer);
    this.notifyObservers(aContact, "contact-moved-out");
    for (let observer of ContactsById[aContact.id]._observers) {
      observer.observe(this, "contact-moved-out", null);
    }
  },

  addObserver(aObserver) {
    if (!this._observers.includes(aObserver)) {
      this._observers.push(aObserver);
    }
  },
  removeObserver(aObserver) {
    this._observers = this._observers.filter(o => o !== aObserver);
  },
  notifyObservers(aSubject, aTopic, aData) {
    for (let observer of this._observers) {
      observer.observe(aSubject, aTopic, aData);
    }
  },
};

var ContactsById = {};
var LastDummyContactId = 0;
function Contact(aId, aAlias) {
  // Assign a negative id to dummy contacts that have a single buddy
  this._id = aId || --LastDummyContactId;
  this._alias = aAlias;
  this._tags = [];
  this._buddies = [];
  this._observers = [];

  ContactsById[this._id] = this;
}
Contact.prototype = {
  __proto__: ClassInfo("imIContact", "Contact"),
  _id: 0,
  get id() {
    return this._id;
  },
  get alias() {
    return this._alias;
  },
  set alias(aNewAlias) {
    this._ensureNotDummy();

    let statement = lazy.DBConn.createStatement(
      "UPDATE contacts SET alias = :alias WHERE id = :id"
    );
    statement.params.alias = aNewAlias;
    statement.params.id = this._id;
    executeAsyncThenFinalize(statement);

    let oldDisplayName = this.displayName;
    this._alias = aNewAlias;
    this._notifyObservers("display-name-changed", oldDisplayName);
    for (let buddy of this._buddies) {
      for (let accountBuddy of buddy._accounts) {
        accountBuddy.serverAlias = aNewAlias;
      }
    }
  },
  _ensureNotDummy() {
    if (this._id >= 0) {
      return;
    }

    // Create a real contact for this dummy contact
    let statement = lazy.DBConn.createStatement(
      "INSERT INTO contacts DEFAULT VALUES"
    );
    try {
      statement.execute();
    } finally {
      statement.finalize();
    }
    delete ContactsById[this._id];
    let oldId = this._id;
    this._id = lazy.DBConn.lastInsertRowID;
    ContactsById[this._id] = this;
    this._notifyObservers("no-longer-dummy", oldId.toString());
    // Update the contact_id for the single existing buddy of this contact
    statement = lazy.DBConn.createStatement(
      "UPDATE buddies SET contact_id = :id WHERE id = :buddy_id"
    );
    statement.params.id = this._id;
    statement.params.buddy_id = this._buddies[0].id;
    executeAsyncThenFinalize(statement);
  },

  getTags() {
    return this._tags;
  },
  addTag(aTag, aInherited) {
    if (this.hasTag(aTag)) {
      return;
    }

    if (!aInherited) {
      this._ensureNotDummy();
      let statement = lazy.DBConn.createStatement(
        "INSERT INTO contact_tag (contact_id, tag_id) " +
          "VALUES(:contactId, :tagId)"
      );
      statement.params.contactId = this.id;
      statement.params.tagId = aTag.id;
      executeAsyncThenFinalize(statement);
    }

    aTag = TagsById[aTag.id];
    this._tags.push(aTag);
    aTag._addContact(this);

    aTag.notifyObservers(this, "contact-moved-in");
    for (let observer of this._observers) {
      observer.observe(aTag, "contact-moved-in", null);
    }
    Services.obs.notifyObservers(this, "contact-tag-added", aTag.id);
  },
  /* Remove a tag from the local tags of the contact. */
  _removeTag(aTag) {
    if (!this.hasTag(aTag) || this._isTagInherited(aTag)) {
      return;
    }

    this._removeContactTagRow(aTag);

    this._tags = this._tags.filter(tag => tag.id != aTag.id);
    aTag = TagsById[aTag.id];
    aTag._removeContact(this);

    aTag.notifyObservers(this, "contact-moved-out");
    for (let observer of this._observers) {
      observer.observe(aTag, "contact-moved-out", null);
    }
    Services.obs.notifyObservers(this, "contact-tag-removed", aTag.id);
  },
  _removeContactTagRow(aTag) {
    let statement = lazy.DBConn.createStatement(
      "DELETE FROM contact_tag " +
        "WHERE contact_id = :contactId " +
        "AND tag_id = :tagId"
    );
    statement.params.contactId = this.id;
    statement.params.tagId = aTag.id;
    executeAsyncThenFinalize(statement);
  },
  hasTag(aTag) {
    return this._tags.some(t => t.id == aTag.id);
  },
  _massMove: false,
  removeTag(aTag) {
    if (!this.hasTag(aTag)) {
      throw new Error(
        "Attempting to remove a tag that the contact doesn't have"
      );
    }
    if (this._tags.length == 1) {
      throw new Error("Attempting to remove the last tag of a contact");
    }

    this._massMove = true;
    let hasTag = this.hasTag.bind(this);
    let newTag = this._tags[this._tags[0].id != aTag.id ? 0 : 1];
    let moved = false;
    this._buddies.forEach(function (aBuddy) {
      aBuddy._accounts.forEach(function (aAccountBuddy) {
        if (aAccountBuddy.tag.id == aTag.id) {
          if (
            aBuddy._accounts.some(
              ab =>
                ab.account.numericId == aAccountBuddy.account.numericId &&
                ab.tag.id != aTag.id &&
                hasTag(ab.tag)
            )
          ) {
            // A buddy that already has an accountBuddy of the same
            // account with another tag of the contact shouldn't be
            // moved to newTag, just remove the accountBuddy
            // associated to the tag we are removing.
            aAccountBuddy.remove();
            moved = true;
          } else {
            try {
              aAccountBuddy.tag = newTag;
              moved = true;
            } catch (e) {
              // Ignore failures. Some protocol plugins may not implement this.
            }
          }
        }
      });
    });
    this._massMove = false;
    if (moved) {
      this._moved(aTag, newTag);
    } else {
      // If we are here, the old tag is not inherited from a buddy, so
      // just remove the local tag.
      this._removeTag(aTag);
    }
  },
  _isTagInherited(aTag) {
    for (let buddy of this._buddies) {
      for (let accountBuddy of buddy._accounts) {
        if (accountBuddy.tag.id == aTag.id) {
          return true;
        }
      }
    }
    return false;
  },
  _moved(aOldTag, aNewTag) {
    if (this._massMove) {
      return;
    }

    // Avoid xpconnect wrappers.
    aNewTag = aNewTag && TagsById[aNewTag.id];
    aOldTag = aOldTag && TagsById[aOldTag.id];

    // Decide what we need to do. Return early if nothing to do.
    let shouldRemove =
      aOldTag && this.hasTag(aOldTag) && !this._isTagInherited(aOldTag);
    let shouldAdd =
      aNewTag && !this.hasTag(aNewTag) && this._isTagInherited(aNewTag);
    if (!shouldRemove && !shouldAdd) {
      return;
    }

    // Apply the changes.
    let tags = this._tags;
    if (shouldRemove) {
      tags = tags.filter(aTag => aTag.id != aOldTag.id);
      aOldTag._removeContact(this);
    }
    if (shouldAdd) {
      tags.push(aNewTag);
      aNewTag._addContact(this);
    }
    this._tags = tags;

    // Finally, notify of the changes.
    if (shouldRemove) {
      aOldTag.notifyObservers(this, "contact-moved-out");
      for (let observer of this._observers) {
        observer.observe(aOldTag, "contact-moved-out", null);
      }
      Services.obs.notifyObservers(this, "contact-tag-removed", aOldTag.id);
    }
    if (shouldAdd) {
      aNewTag.notifyObservers(this, "contact-moved-in");
      for (let observer of this._observers) {
        observer.observe(aNewTag, "contact-moved-in", null);
      }
      Services.obs.notifyObservers(this, "contact-tag-added", aNewTag.id);
    }
    Services.obs.notifyObservers(this, "contact-moved");
  },

  getBuddies() {
    return this._buddies;
  },
  get _empty() {
    return this._buddies.length == 0 || this._buddies.every(b => b._empty);
  },

  mergeContact(aContact) {
    // Avoid merging the contact with itself or merging into an
    // already removed contact.
    if (aContact.id == this.id || !(this.id in ContactsById)) {
      throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
    }

    this._ensureNotDummy();
    let contact = ContactsById[aContact.id]; // remove XPConnect wrapper

    // Copy all the contact-only tags first, otherwise they would be lost.
    for (let tag of contact.getTags()) {
      if (!contact._isTagInherited(tag)) {
        this.addTag(tag);
      }
    }

    // Adopt each buddy. Removing the last one will delete the contact.
    for (let buddy of contact.getBuddies()) {
      buddy.contact = this;
    }
    this._updatePreferredBuddy();
  },
  moveBuddyBefore(aBuddy, aBeforeBuddy) {
    let buddy = BuddiesById[aBuddy.id]; // remove XPConnect wrapper
    let oldPosition = this._buddies.indexOf(buddy);
    if (oldPosition == -1) {
      throw new Error("aBuddy isn't attached to this contact");
    }

    let newPosition = -1;
    if (aBeforeBuddy) {
      newPosition = this._buddies.indexOf(BuddiesById[aBeforeBuddy.id]);
    }
    if (newPosition == -1) {
      newPosition = this._buddies.length - 1;
    }

    if (oldPosition == newPosition) {
      return;
    }

    this._buddies.splice(oldPosition, 1);
    this._buddies.splice(newPosition, 0, buddy);
    this._updatePositions(
      Math.min(oldPosition, newPosition),
      Math.max(oldPosition, newPosition)
    );
    buddy._notifyObservers("position-changed", String(newPosition));
    this._updatePreferredBuddy(buddy);
  },
  adoptBuddy(aBuddy) {
    if (aBuddy.contact.id == this.id) {
      throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
    }

    let buddy = BuddiesById[aBuddy.id]; // remove XPConnect wrapper
    buddy.contact = this;
    this._updatePreferredBuddy(buddy);
  },
  _massRemove: false,
  _removeBuddy(aBuddy) {
    if (this._buddies.length == 1) {
      if (this._id > 0) {
        let statement = lazy.DBConn.createStatement(
          "DELETE FROM contacts WHERE id = :id"
        );
        statement.params.id = this._id;
        executeAsyncThenFinalize(statement);
      }
      this._notifyObservers("removed");
      delete ContactsById[this._id];

      for (let tag of this._tags) {
        tag._removeContact(this);
      }
      let statement = lazy.DBConn.createStatement(
        "DELETE FROM contact_tag WHERE contact_id = :id"
      );
      statement.params.id = this._id;
      executeAsyncThenFinalize(statement);

      delete this._tags;
      delete this._buddies;
      delete this._observers;
    } else {
      let index = this._buddies.indexOf(aBuddy);
      if (index == -1) {
        throw new Error("Removing an unknown buddy from contact " + this._id);
      }

      this._buddies = this._buddies.filter(b => b !== aBuddy);

      // If we are actually removing the whole contact, don't bother updating
      // the positions or the preferred buddy.
      if (this._massRemove) {
        return;
      }

      // No position to update if the removed buddy is at the last position.
      if (index < this._buddies.length) {
        this._updatePositions(index);
      }

      if (this._preferredBuddy.id == aBuddy.id) {
        this._updatePreferredBuddy();
      }
    }
  },
  _updatePositions(aIndexBegin, aIndexEnd) {
    if (aIndexEnd === undefined) {
      aIndexEnd = this._buddies.length - 1;
    }
    if (aIndexBegin > aIndexEnd) {
      throw new Error("_updatePositions: Invalid indexes");
    }

    let statement = lazy.DBConn.createStatement(
      "UPDATE buddies SET position = :position WHERE id = :buddyId"
    );
    for (let i = aIndexBegin; i <= aIndexEnd; ++i) {
      statement.params.position = i;
      statement.params.buddyId = this._buddies[i].id;
      statement.executeAsync();
    }
    statement.finalize();
  },

  detachBuddy(aBuddy) {
    // Should return a new contact with the same list of tags.
    let buddy = BuddiesById[aBuddy.id];
    if (buddy.contact.id != this.id) {
      throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
    }
    if (buddy.contact._buddies.length == 1) {
      throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
    }

    // Save the list of tags, it may be destroyed if the buddy was the last one.
    let tags = buddy.contact.getTags();

    // Create a new dummy contact and use it for the detached buddy.
    buddy.contact = new Contact();
    buddy.contact._notifyObservers("added");

    // The first tag was inherited during the contact setter.
    // This will copy the remaining tags.
    for (let tag of tags) {
      buddy.contact.addTag(tag);
    }

    return buddy.contact;
  },
  remove() {
    this._massRemove = true;
    for (let buddy of this._buddies) {
      buddy.remove();
    }
  },

  // imIStatusInfo implementation
  _preferredBuddy: null,
  get preferredBuddy() {
    if (!this._preferredBuddy) {
      this._updatePreferredBuddy();
    }
    return this._preferredBuddy;
  },
  set preferredBuddy(aBuddy) {
    let shouldNotify = this._preferredBuddy != null;
    let oldDisplayName =
      this._preferredBuddy && this._preferredBuddy.displayName;
    this._preferredBuddy = aBuddy;
    if (shouldNotify) {
      this._notifyObservers("preferred-buddy-changed");
    }
    if (oldDisplayName && this._preferredBuddy.displayName != oldDisplayName) {
      this._notifyObservers("display-name-changed", oldDisplayName);
    }
    this._updateStatus();
  },
  // aBuddy indicate which buddy's availability has changed.
  _updatePreferredBuddy(aBuddy) {
    if (aBuddy) {
      aBuddy = BuddiesById[aBuddy.id]; // remove potential XPConnect wrapper

      if (!this._preferredBuddy) {
        this.preferredBuddy = aBuddy;
        return;
      }

      if (aBuddy.id == this._preferredBuddy.id) {
        // The suggested buddy is already preferred, check if its
        // availability has changed.
        if (
          aBuddy.statusType > this._statusType ||
          (aBuddy.statusType == this._statusType &&
            aBuddy.availabilityDetails >= this._availabilityDetails)
        ) {
          // keep the currently preferred buddy, only update the status.
          this._updateStatus();
          return;
        }
        // We aren't sure that the currently preferred buddy should
        // still be preferred. Let's go through the list!
      } else {
        // The suggested buddy is not currently preferred. If it is
        // more available or at a better position, prefer it!
        if (
          aBuddy.statusType > this._statusType ||
          (aBuddy.statusType == this._statusType &&
            (aBuddy.availabilityDetails > this._availabilityDetails ||
              (aBuddy.availabilityDetails == this._availabilityDetails &&
                this._buddies.indexOf(aBuddy) <
                  this._buddies.indexOf(this.preferredBuddy))))
        ) {
          this.preferredBuddy = aBuddy;
        }
        return;
      }
    }

    let preferred;
    // |this._buddies| is ordered by user preference, so in case of
    // equal availability, keep the current value of |preferred|.
    for (let buddy of this._buddies) {
      if (
        !preferred ||
        preferred.statusType < buddy.statusType ||
        (preferred.statusType == buddy.statusType &&
          preferred.availabilityDetails < buddy.availabilityDetails)
      ) {
        preferred = buddy;
      }
    }
    if (
      preferred &&
      (!this._preferredBuddy || preferred.id != this._preferredBuddy.id)
    ) {
      this.preferredBuddy = preferred;
    }
  },
  _updateStatus() {
    let buddy = this._preferredBuddy; // for convenience

    // Decide which notifications should be fired.
    let notifications = [];
    if (
      this._statusType != buddy.statusType ||
      this._availabilityDetails != buddy.availabilityDetails
    ) {
      notifications.push("availability-changed");
    }
    if (
      this._statusType != buddy.statusType ||
      this._statusText != buddy.statusText
    ) {
      notifications.push("status-changed");
      if (this.online && buddy.statusType <= Ci.imIStatusInfo.STATUS_OFFLINE) {
        notifications.push("signed-off");
      }
      if (!this.online && buddy.statusType > Ci.imIStatusInfo.STATUS_OFFLINE) {
        notifications.push("signed-on");
      }
    }

    // Actually change the stored status.
    [this._statusType, this._statusText, this._availabilityDetails] = [
      buddy.statusType,
      buddy.statusText,
      buddy.availabilityDetails,
    ];

    // Fire the notifications.
    notifications.forEach(function (aTopic) {
      this._notifyObservers(aTopic);
    }, this);
  },
  get displayName() {
    return this._alias || this.preferredBuddy.displayName;
  },
  get buddyIconFilename() {
    return this.preferredBuddy.buddyIconFilename;
  },
  _statusType: 0,
  get statusType() {
    return this._statusType;
  },
  get online() {
    return this.statusType > Ci.imIStatusInfo.STATUS_OFFLINE;
  },
  get available() {
    return this.statusType == Ci.imIStatusInfo.STATUS_AVAILABLE;
  },
  get idle() {
    return this.statusType == Ci.imIStatusInfo.STATUS_IDLE;
  },
  get mobile() {
    return this.statusType == Ci.imIStatusInfo.STATUS_MOBILE;
  },
  _statusText: "",
  get statusText() {
    return this._statusText;
  },
  _availabilityDetails: 0,
  get availabilityDetails() {
    return this._availabilityDetails;
  },
  get canSendMessage() {
    return this.preferredBuddy.canSendMessage;
  },
  // XXX should we list the buddies in the tooltip?
  getTooltipInfo() {
    return this.preferredBuddy.getTooltipInfo();
  },
  createConversation() {
    let uiConv = IMServices.conversations.getUIConversationByContactId(this.id);
    if (uiConv) {
      return uiConv.target;
    }
    return this.preferredBuddy.createConversation();
  },

  addObserver(aObserver) {
    if (!this._observers.includes(aObserver)) {
      this._observers.push(aObserver);
    }
  },
  removeObserver(aObserver) {
    if (!this.hasOwnProperty("_observers")) {
      return;
    }

    this._observers = this._observers.filter(o => o !== aObserver);
  },
  // internal calls + calls from add-ons
  notifyObservers(aSubject, aTopic, aData) {
    for (let observer of this._observers) {
      if ("observe" in observer) {
        // avoid failing on destructed XBL bindings...
        observer.observe(aSubject, aTopic, aData);
      }
    }
    for (let tag of this._tags) {
      tag.notifyObservers(aSubject, aTopic, aData);
    }
    Services.obs.notifyObservers(aSubject, aTopic, aData);
  },
  _notifyObservers(aTopic, aData) {
    this.notifyObservers(this, "contact-" + aTopic, aData);
  },

  // This is called by the imIBuddy implementations.
  _observe(aSubject, aTopic, aData) {
    // Forward the notification.
    this.notifyObservers(aSubject, aTopic, aData);

    let isPreferredBuddy =
      aSubject instanceof Buddy && aSubject.id == this.preferredBuddy.id;
    switch (aTopic) {
      case "buddy-availability-changed":
        this._updatePreferredBuddy(aSubject);
        break;
      case "buddy-status-changed":
        if (isPreferredBuddy) {
          this._updateStatus();
        }
        break;
      case "buddy-display-name-changed":
        if (isPreferredBuddy && !this._alias) {
          this._notifyObservers("display-name-changed", aData);
        }
        break;
      case "buddy-icon-changed":
        if (isPreferredBuddy) {
          this._notifyObservers("icon-changed");
        }
        break;
      case "buddy-added":
        // Currently buddies are always added in dummy empty contacts,
        // later we may want to check this._buddies.length == 1.
        this._notifyObservers("added");
        break;
      case "buddy-removed":
        this._removeBuddy(aSubject);
    }
  },
};

var BuddiesById = {};
function Buddy(aId, aKey, aName, aSrvAlias, aContactId) {
  this._id = aId;
  this._key = aKey;
  this._name = aName;
  if (aSrvAlias) {
    this._srvAlias = aSrvAlias;
  }
  this._accounts = [];
  this._observers = [];

  if (aContactId) {
    this._contact = ContactsById[aContactId];
  }
  // Avoid failure if aContactId was invalid.
  if (!this._contact) {
    this._contact = new Contact(null, null);
  }

  this._contact._buddies.push(this);

  BuddiesById[this._id] = this;
}
Buddy.prototype = {
  __proto__: ClassInfo("imIBuddy", "Buddy"),
  get id() {
    return this._id;
  },
  destroy() {
    for (let ab of this._accounts) {
      ab.unInit();
    }
    delete this._accounts;
    delete this._observers;
    delete this._preferredAccount;
  },
  get protocol() {
    return this._accounts[0].account.protocol;
  },
  get userName() {
    return this._name;
  },
  get normalizedName() {
    return this._key;
  },
  _srvAlias: "",
  _contact: null,
  get contact() {
    return this._contact;
  },
  set contact(aContact) /* not in imIBuddy */ {
    if (aContact.id == this._contact.id) {
      throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
    }

    this._notifyObservers("moved-out-of-contact");
    this._contact._removeBuddy(this);

    this._contact = aContact;
    this._contact._buddies.push(this);

    // Ensure all the inherited tags are in the new contact.
    for (let accountBuddy of this._accounts) {
      this._contact.addTag(TagsById[accountBuddy.tag.id], true);
    }

    let statement = lazy.DBConn.createStatement(
      "UPDATE buddies SET contact_id = :contactId, " +
        "position = :position " +
        "WHERE id = :buddyId"
    );
    statement.params.contactId = aContact.id > 0 ? aContact.id : 0;
    statement.params.position = aContact._buddies.length - 1;
    statement.params.buddyId = this.id;
    executeAsyncThenFinalize(statement);

    this._notifyObservers("moved-into-contact");
  },
  _hasAccountBuddy(aAccountId, aTagId) {
    for (let ab of this._accounts) {
      if (ab.account.numericId == aAccountId && ab.tag.id == aTagId) {
        return true;
      }
    }
    return false;
  },
  getAccountBuddies() {
    return this._accounts;
  },

  _addAccount(aAccountBuddy, aTag) {
    this._accounts.push(aAccountBuddy);
    let contact = this._contact;
    if (!this._contact._tags.includes(aTag)) {
      this._contact._tags.push(aTag);
      aTag._addContact(contact);
    }

    if (!this._preferredAccount) {
      this._preferredAccount = aAccountBuddy;
    }
  },
  get _empty() {
    return this._accounts.length == 0;
  },

  remove() {
    for (let account of this._accounts) {
      account.remove();
    }
  },

  // imIStatusInfo implementation
  _preferredAccount: null,
  get preferredAccountBuddy() {
    return this._preferredAccount;
  },
  _isPreferredAccount(aAccountBuddy) {
    if (
      aAccountBuddy.account.numericId !=
      this._preferredAccount.account.numericId
    ) {
      return false;
    }

    // In case we have more than one accountBuddy for the same buddy
    // and account (possible if the buddy is in several groups on the
    // server), the protocol plugin may be broken and not update all
    // instances, so ensure we handle the notifications on the instance
    // that is currently being notified of a change:
    this._preferredAccount = aAccountBuddy;

    return true;
  },
  set preferredAccount(aAccount) {
    let oldDisplayName =
      this._preferredAccount && this._preferredAccount.displayName;
    this._preferredAccount = aAccount;
    this._notifyObservers("preferred-account-changed");
    if (
      oldDisplayName &&
      this._preferredAccount.displayName != oldDisplayName
    ) {
      this._notifyObservers("display-name-changed", oldDisplayName);
    }
    this._updateStatus();
  },
  // aAccount indicate which account's availability has changed.
  _updatePreferredAccount(aAccount) {
    if (aAccount) {
      if (
        aAccount.account.numericId == this._preferredAccount.account.numericId
      ) {
        // The suggested account is already preferred, check if its
        // availability has changed.
        if (
          aAccount.statusType > this._statusType ||
          (aAccount.statusType == this._statusType &&
            aAccount.availabilityDetails >= this._availabilityDetails)
        ) {
          // keep the currently preferred account, only update the status.
          this._updateStatus();
          return;
        }
        // We aren't sure that the currently preferred account should
        // still be preferred. Let's go through the list!
      } else {
        // The suggested account is not currently preferred. If it is
        // more available, prefer it!
        if (
          aAccount.statusType > this._statusType ||
          (aAccount.statusType == this._statusType &&
            aAccount.availabilityDetails > this._availabilityDetails)
        ) {
          this.preferredAccount = aAccount;
        }
        return;
      }
    }

    let preferred;
    // TODO take into account the order of the account-manager list.
    for (let account of this._accounts) {
      if (
        !preferred ||
        preferred.statusType < account.statusType ||
        (preferred.statusType == account.statusType &&
          preferred.availabilityDetails < account.availabilityDetails)
      ) {
        preferred = account;
      }
    }
    if (!this._preferredAccount) {
      if (preferred) {
        this.preferredAccount = preferred;
      }
      return;
    }
    if (
      preferred.account.numericId != this._preferredAccount.account.numericId
    ) {
      this.preferredAccount = preferred;
    } else {
      this._updateStatus();
    }
  },
  _updateStatus() {
    let account = this._preferredAccount; // for convenience

    // Decide which notifications should be fired.
    let notifications = [];
    if (
      this._statusType != account.statusType ||
      this._availabilityDetails != account.availabilityDetails
    ) {
      notifications.push("availability-changed");
    }
    if (
      this._statusType != account.statusType ||
      this._statusText != account.statusText
    ) {
      notifications.push("status-changed");
      if (
        this.online &&
        account.statusType <= Ci.imIStatusInfo.STATUS_OFFLINE
      ) {
        notifications.push("signed-off");
      }
      if (
        !this.online &&
        account.statusType > Ci.imIStatusInfo.STATUS_OFFLINE
      ) {
        notifications.push("signed-on");
      }
    }

    // Actually change the stored status.
    [this._statusType, this._statusText, this._availabilityDetails] = [
      account.statusType,
      account.statusText,
      account.availabilityDetails,
    ];

    // Fire the notifications.
    notifications.forEach(function (aTopic) {
      this._notifyObservers(aTopic);
    }, this);
  },
  get displayName() {
    return (
      (this._preferredAccount && this._preferredAccount.displayName) ||
      this._srvAlias ||
      this._name
    );
  },
  get buddyIconFilename() {
    return this._preferredAccount.buddyIconFilename;
  },
  _statusType: 0,
  get statusType() {
    return this._statusType;
  },
  get online() {
    return this.statusType > Ci.imIStatusInfo.STATUS_OFFLINE;
  },
  get available() {
    return this.statusType == Ci.imIStatusInfo.STATUS_AVAILABLE;
  },
  get idle() {
    return this.statusType == Ci.imIStatusInfo.STATUS_IDLE;
  },
  get mobile() {
    return this.statusType == Ci.imIStatusInfo.STATUS_MOBILE;
  },
  _statusText: "",
  get statusText() {
    return this._statusText;
  },
  _availabilityDetails: 0,
  get availabilityDetails() {
    return this._availabilityDetails;
  },
  get canSendMessage() {
    return this._preferredAccount.canSendMessage;
  },
  // XXX should we list the accounts in the tooltip?
  getTooltipInfo() {
    return this._preferredAccount.getTooltipInfo();
  },
  createConversation() {
    return this._preferredAccount.createConversation();
  },

  addObserver(aObserver) {
    if (!this._observers.includes(aObserver)) {
      this._observers.push(aObserver);
    }
  },
  removeObserver(aObserver) {
    if (!this._observers) {
      return;
    }
    this._observers = this._observers.filter(o => o !== aObserver);
  },
  // internal calls + calls from add-ons
  notifyObservers(aSubject, aTopic, aData) {
    try {
      for (let observer of this._observers) {
        observer.observe(aSubject, aTopic, aData);
      }
      this._contact._observe(aSubject, aTopic, aData);
    } catch (e) {
      console.error(e);
    }
  },
  _notifyObservers(aTopic, aData) {
    this.notifyObservers(this, "buddy-" + aTopic, aData);
  },

  // This is called by the prplIAccountBuddy implementations.
  observe(aSubject, aTopic, aData) {
    // Forward the notification.
    this.notifyObservers(aSubject, aTopic, aData);

    switch (aTopic) {
      case "account-buddy-availability-changed":
        this._updatePreferredAccount(aSubject);
        break;
      case "account-buddy-status-changed":
        if (this._isPreferredAccount(aSubject)) {
          this._updateStatus();
        }
        break;
      case "account-buddy-display-name-changed":
        if (this._isPreferredAccount(aSubject)) {
          this._srvAlias =
            this.displayName != this.userName ? this.displayName : "";
          let statement = lazy.DBConn.createStatement(
            "UPDATE buddies SET srv_alias = :srvAlias WHERE id = :buddyId"
          );
          statement.params.buddyId = this.id;
          statement.params.srvAlias = this._srvAlias;
          executeAsyncThenFinalize(statement);
          this._notifyObservers("display-name-changed", aData);
        }
        break;
      case "account-buddy-icon-changed":
        if (this._isPreferredAccount(aSubject)) {
          this._notifyObservers("icon-changed");
        }
        break;
      case "account-buddy-added":
        if (this._accounts.length == 0) {
          // Add the new account in the empty buddy instance.
          // The TagsById hack is to bypass the xpconnect wrapper.
          this._addAccount(aSubject, TagsById[aSubject.tag.id]);
          this._updateStatus();
          this._notifyObservers("added");
        } else {
          this._accounts.push(aSubject);
          this.contact._moved(null, aSubject.tag);
          this._updatePreferredAccount(aSubject);
        }
        break;
      case "account-buddy-removed":
        if (this._accounts.length == 1) {
          let statement = lazy.DBConn.createStatement(
            "DELETE FROM buddies WHERE id = :id"
          );
          try {
            statement.params.id = this.id;
            statement.execute();
          } finally {
            statement.finalize();
          }
          this._notifyObservers("removed");

          delete BuddiesById[this._id];
          this.destroy();
        } else {
          this._accounts = this._accounts.filter(function (ab) {
            return (
              ab.account.numericId != aSubject.account.numericId ||
              ab.tag.id != aSubject.tag.id
            );
          });
          if (
            this._preferredAccount.account.numericId ==
              aSubject.account.numericId &&
            this._preferredAccount.tag.id == aSubject.tag.id
          ) {
            this._preferredAccount = null;
            this._updatePreferredAccount();
          }
          this.contact._moved(aSubject.tag);
        }
        break;
    }
  },
};

export function ContactsService() {}
ContactsService.prototype = {
  initContacts() {
    let statement = lazy.DBConn.createStatement("SELECT id, name FROM tags");
    try {
      while (statement.executeStep()) {
        Tags.push(new Tag(statement.getInt32(0), statement.getUTF8String(1)));
      }
    } finally {
      statement.finalize();
    }

    statement = lazy.DBConn.createStatement("SELECT id, alias FROM contacts");
    try {
      while (statement.executeStep()) {
        new Contact(statement.getInt32(0), statement.getUTF8String(1));
      }
    } finally {
      statement.finalize();
    }

    statement = lazy.DBConn.createStatement(
      "SELECT contact_id, tag_id FROM contact_tag"
    );
    try {
      while (statement.executeStep()) {
        let contact = ContactsById[statement.getInt32(0)];
        let tag = TagsById[statement.getInt32(1)];
        contact._tags.push(tag);
        tag._addContact(contact);
      }
    } finally {
      statement.finalize();
    }

    statement = lazy.DBConn.createStatement(
      "SELECT id, key, name, srv_alias, contact_id FROM buddies ORDER BY position"
    );
    try {
      while (statement.executeStep()) {
        new Buddy(
          statement.getInt32(0),
          statement.getUTF8String(1),
          statement.getUTF8String(2),
          statement.getUTF8String(3),
          statement.getInt32(4)
        );
        // FIXME is there a way to enforce that all AccountBuddies of a Buddy have the same protocol?
      }
    } finally {
      statement.finalize();
    }

    statement = lazy.DBConn.createStatement(
      "SELECT account_id, buddy_id, tag_id FROM account_buddy"
    );
    try {
      while (statement.executeStep()) {
        let accountId = statement.getInt32(0);
        let buddyId = statement.getInt32(1);
        let tagId = statement.getInt32(2);

        let account = IMServices.accounts.getAccountByNumericId(accountId);
        // If the account was deleted without properly cleaning up the
        // account_buddy, skip loading this account buddy.
        if (!account) {
          continue;
        }

        if (!BuddiesById.hasOwnProperty(buddyId)) {
          console.error(
            "Corrupted database: account_buddy entry for account " +
              accountId +
              " and tag " +
              tagId +
              " references unknown buddy with id " +
              buddyId
          );
          continue;
        }

        let buddy = BuddiesById[buddyId];
        if (buddy._hasAccountBuddy(accountId, tagId)) {
          console.error(
            "Corrupted database: duplicated account_buddy entry: " +
              "account_id = " +
              accountId +
              ", buddy_id = " +
              buddyId +
              ", tag_id = " +
              tagId
          );
          continue;
        }

        let tag = TagsById[tagId];
        try {
          buddy._addAccount(account.loadBuddy(buddy, tag), tag);
        } catch (e) {
          console.error(e);
          dump(e + "\n");
        }
      }
    } finally {
      statement.finalize();
    }
    otherContactsTag._initHiddenTags();
  },
  unInitContacts() {
    Tags = [];
    TagsById = {};
    // Avoid shutdown leaks caused by references to native components
    // implementing prplIAccountBuddy.
    for (let buddyId in BuddiesById) {
      let buddy = BuddiesById[buddyId];
      buddy.destroy();
    }
    BuddiesById = {};
    ContactsById = {};
  },

  getContactById: aId => ContactsById[aId],
  // Get an array of all existing contacts.
  getContacts() {
    return Object.keys(ContactsById)
      .filter(id => !ContactsById[id]._empty)
      .map(id => ContactsById[id]);
  },
  getBuddyById: aId => BuddiesById[aId],
  getBuddyByNameAndProtocol(aNormalizedName, aPrpl) {
    let statement = lazy.DBConn.createStatement(
      "SELECT b.id FROM buddies b " +
        "JOIN account_buddy ab ON buddy_id = b.id " +
        "JOIN accounts a ON account_id = a.id " +
        "WHERE b.key = :buddyName and a.prpl = :prplId"
    );
    statement.params.buddyName = aNormalizedName;
    statement.params.prplId = aPrpl.id;
    try {
      if (!statement.executeStep()) {
        return null;
      }
      return BuddiesById[statement.row.id];
    } finally {
      statement.finalize();
    }
  },
  getAccountBuddyByNameAndAccount(aNormalizedName, aAccount) {
    let buddy = this.getBuddyByNameAndProtocol(
      aNormalizedName,
      aAccount.protocol
    );
    if (buddy) {
      let id = aAccount.id;
      for (let accountBuddy of buddy.getAccountBuddies()) {
        if (accountBuddy.account.id == id) {
          return accountBuddy;
        }
      }
    }
    return null;
  },

  accountBuddyAdded(aAccountBuddy) {
    let account = aAccountBuddy.account;
    let normalizedName = aAccountBuddy.normalizedName;
    let buddy = this.getBuddyByNameAndProtocol(
      normalizedName,
      account.protocol
    );
    if (!buddy) {
      let statement = lazy.DBConn.createStatement(
        "INSERT INTO buddies " +
          "(key, name, srv_alias, position) " +
          "VALUES(:key, :name, :srvAlias, 0)"
      );
      try {
        let name = aAccountBuddy.userName;
        let srvAlias = aAccountBuddy.serverAlias;
        statement.params.key = normalizedName;
        statement.params.name = name;
        statement.params.srvAlias = srvAlias;
        statement.execute();
        buddy = new Buddy(
          lazy.DBConn.lastInsertRowID,
          normalizedName,
          name,
          srvAlias,
          0
        );
      } finally {
        statement.finalize();
      }
    }

    // Initialize the 'buddy' field of the prplIAccountBuddy instance.
    aAccountBuddy.buddy = buddy;

    // Ensure we aren't storing a duplicate entry.
    let accountId = account.numericId;
    let tagId = aAccountBuddy.tag.id;
    if (buddy._hasAccountBuddy(accountId, tagId)) {
      console.error(
        "Attempting to store a duplicate account buddy " +
          normalizedName +
          ", account id = " +
          accountId +
          ", tag id = " +
          tagId
      );
      return;
    }

    // Store the new account buddy.
    let statement = lazy.DBConn.createStatement(
      "INSERT INTO account_buddy " +
        "(account_id, buddy_id, tag_id) " +
        "VALUES(:accountId, :buddyId, :tagId)"
    );
    try {
      statement.params.accountId = accountId;
      statement.params.buddyId = buddy.id;
      statement.params.tagId = tagId;
      statement.execute();
    } finally {
      statement.finalize();
    }

    // Fire the notifications.
    buddy.observe(aAccountBuddy, "account-buddy-added");
  },
  accountBuddyRemoved(aAccountBuddy) {
    let buddy = aAccountBuddy.buddy;
    let statement = lazy.DBConn.createStatement(
      "DELETE FROM account_buddy " +
        "WHERE account_id = :accountId AND " +
        "buddy_id = :buddyId AND " +
        "tag_id = :tagId"
    );
    try {
      statement.params.accountId = aAccountBuddy.account.numericId;
      statement.params.buddyId = buddy.id;
      statement.params.tagId = aAccountBuddy.tag.id;
      statement.execute();
    } finally {
      statement.finalize();
    }

    buddy.observe(aAccountBuddy, "account-buddy-removed");
  },

  accountBuddyMoved(aAccountBuddy, aOldTag, aNewTag) {
    let buddy = aAccountBuddy.buddy;
    let statement = lazy.DBConn.createStatement(
      "UPDATE account_buddy " +
        "SET tag_id = :newTagId " +
        "WHERE account_id = :accountId AND " +
        "buddy_id = :buddyId AND " +
        "tag_id = :oldTagId"
    );
    try {
      statement.params.accountId = aAccountBuddy.account.numericId;
      statement.params.buddyId = buddy.id;
      statement.params.oldTagId = aOldTag.id;
      statement.params.newTagId = aNewTag.id;
      statement.execute();
    } finally {
      statement.finalize();
    }

    let contact = ContactsById[buddy.contact.id];

    // aNewTag is now inherited by the contact from an account buddy, so avoid
    // keeping direct tag <-> contact links in the contact_tag table.
    contact._removeContactTagRow(aNewTag);

    buddy.observe(aAccountBuddy, "account-buddy-moved");
    contact._moved(aOldTag, aNewTag);
  },

  storeAccount(aId, aUserName, aPrplId) {
    let statement = lazy.DBConn.createStatement(
      "SELECT name, prpl FROM accounts WHERE id = :id"
    );
    statement.params.id = aId;
    try {
      if (statement.executeStep()) {
        if (
          statement.getUTF8String(0) == aUserName &&
          statement.getUTF8String(1) == aPrplId
        ) {
          // The account is already stored correctly.
          return;
        }
        throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED); // Corrupted database?!?
      }
    } finally {
      statement.finalize();
    }

    // Actually store the account.
    statement = lazy.DBConn.createStatement(
      "INSERT INTO accounts (id, name, prpl) " +
        "VALUES(:id, :userName, :prplId)"
    );
    try {
      statement.params.id = aId;
      statement.params.userName = aUserName;
      statement.params.prplId = aPrplId;
      statement.execute();
    } finally {
      statement.finalize();
    }
  },
  accountIdExists(aId) {
    let statement = lazy.DBConn.createStatement(
      "SELECT id FROM accounts WHERE id = :id"
    );
    try {
      statement.params.id = aId;
      return statement.executeStep();
    } finally {
      statement.finalize();
    }
  },
  forgetAccount(aId) {
    let statement = lazy.DBConn.createStatement(
      "DELETE FROM accounts WHERE id = :accountId"
    );
    try {
      statement.params.accountId = aId;
      statement.execute();
    } finally {
      statement.finalize();
    }

    // removing the account from the accounts table is not enough,
    // we need to remove all the associated account_buddy entries too
    statement = lazy.DBConn.createStatement(
      "DELETE FROM account_buddy WHERE account_id = :accountId"
    );
    try {
      statement.params.accountId = aId;
      statement.execute();
    } finally {
      statement.finalize();
    }
  },

  QueryInterface: ChromeUtils.generateQI(["imIContactsService"]),
  classDescription: "Contacts",
};
