/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import {
  initLogModule,
  nsSimpleEnumerator,
  l10nHelper,
  ClassInfo,
} from "resource:///modules/imXPCOMUtils.sys.mjs";
import { IMServices } from "resource:///modules/IMServices.sys.mjs";

const lazy = {};

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

XPCOMUtils.defineLazyGetter(lazy, "TXTToHTML", function () {
  let cs = Cc["@mozilla.org/txttohtmlconv;1"].getService(Ci.mozITXTToHTMLConv);
  return aTXT => cs.scanTXT(aTXT, cs.kEntities);
});

function OutgoingMessage(aMsg, aConversation) {
  this.message = aMsg;
  this.conversation = aConversation;
}
OutgoingMessage.prototype = {
  __proto__: ClassInfo("imIOutgoingMessage", "Outgoing Message"),
  cancelled: false,
  action: false,
  notification: false,
};

export var GenericAccountPrototype = {
  __proto__: ClassInfo("prplIAccount", "generic account object"),
  get wrappedJSObject() {
    return this;
  },
  _init(aProtocol, aImAccount) {
    this.protocol = aProtocol;
    this.imAccount = aImAccount;
    initLogModule(aProtocol.id, this);
  },
  observe(aSubject, aTopic, aData) {},
  remove() {
    throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
  },
  unInit() {
    throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
  },
  connect() {
    throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
  },
  disconnect() {
    throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
  },
  createConversation(aName) {
    throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
  },
  joinChat(aComponents) {
    throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
  },
  setBool(aName, aVal) {},
  setInt(aName, aVal) {},
  setString(aName, aVal) {},

  get name() {
    return this.imAccount.name;
  },
  get connected() {
    return this.imAccount.connected;
  },
  get connecting() {
    return this.imAccount.connecting;
  },
  get disconnected() {
    return this.imAccount.disconnected;
  },
  get disconnecting() {
    return this.imAccount.disconnecting;
  },
  _connectionErrorReason: Ci.prplIAccount.NO_ERROR,
  get connectionErrorReason() {
    return this._connectionErrorReason;
  },

  /**
   * Convert a socket's nsITransportSecurityInfo into a prplIAccount connection error. Store
   * the nsITransportSecurityInfo and the connection location on the account so the
   * certificate exception dialog can access the information.
   *
   * @param {Socket} aSocket - Socket where the connection error occurred.
   * @returns {number} The prplIAccount error constant describing the problem.
   */
  handleConnectionSecurityError(aSocket) {
    // Stash away the connectionTarget and securityInfo.
    this._connectionTarget = aSocket.host + ":" + aSocket.port;
    let securityInfo = (this._securityInfo = aSocket.securityInfo);

    if (!securityInfo) {
      return Ci.prplIAccount.ERROR_CERT_NOT_PROVIDED;
    }

    if (securityInfo.isUntrusted) {
      if (securityInfo.serverCert && securityInfo.serverCert.isSelfSigned) {
        return Ci.prplIAccount.ERROR_CERT_SELF_SIGNED;
      }
      return Ci.prplIAccount.ERROR_CERT_UNTRUSTED;
    }

    if (securityInfo.isNotValidAtThisTime) {
      if (
        securityInfo.serverCert &&
        securityInfo.serverCert.validity.notBefore < Date.now() * 1000
      ) {
        return Ci.prplIAccount.ERROR_CERT_NOT_ACTIVATED;
      }
      return Ci.prplIAccount.ERROR_CERT_EXPIRED;
    }

    if (securityInfo.isDomainMismatch) {
      return Ci.prplIAccount.ERROR_CERT_HOSTNAME_MISMATCH;
    }

    // XXX ERROR_CERT_FINGERPRINT_MISMATCH

    return Ci.prplIAccount.ERROR_CERT_OTHER_ERROR;
  },
  _connectionTarget: "",
  get connectionTarget() {
    return this._connectionTarget;
  },
  _securityInfo: null,
  get securityInfo() {
    return this._securityInfo;
  },

  reportConnected() {
    this.imAccount.observe(this, "account-connected", null);
  },
  reportConnecting(aConnectionStateMsg) {
    // Delete any leftover errors from the previous connection.
    delete this._connectionTarget;
    delete this._securityInfo;

    if (!this.connecting) {
      this.imAccount.observe(this, "account-connecting", null);
    }
    if (aConnectionStateMsg) {
      this.imAccount.observe(
        this,
        "account-connect-progress",
        aConnectionStateMsg
      );
    }
  },
  reportDisconnected() {
    this.imAccount.observe(this, "account-disconnected", null);
  },
  reportDisconnecting(aConnectionErrorReason, aConnectionErrorMessage) {
    this._connectionErrorReason = aConnectionErrorReason;
    this.imAccount.observe(
      this,
      "account-disconnecting",
      aConnectionErrorMessage
    );
    this.cancelPendingBuddyRequests();
    this.cancelPendingChatRequests();
    this.cancelPendingVerificationRequests();
  },

  // Called when the user adds a new buddy from the UI.
  addBuddy(aTag, aName) {
    IMServices.contacts.accountBuddyAdded(
      new AccountBuddy(this, null, aTag, aName)
    );
  },
  // Called during startup for each of the buddies in the local buddy list.
  loadBuddy(aBuddy, aTag) {
    try {
      return new AccountBuddy(this, aBuddy, aTag);
    } catch (x) {
      dump(x + "\n");
      return null;
    }
  },

  _pendingBuddyRequests: null,
  addBuddyRequest(aUserName, aGrantCallback, aDenyCallback) {
    if (!this._pendingBuddyRequests) {
      this._pendingBuddyRequests = [];
    }
    let buddyRequest = {
      get account() {
        return this._account.imAccount;
      },
      get userName() {
        return aUserName;
      },
      _account: this,
      // Grant and deny callbacks both receive the auth request object as an
      // argument for further use.
      grant() {
        aGrantCallback(this);
        this._remove();
      },
      deny() {
        aDenyCallback(this);
        this._remove();
      },
      cancel() {
        Services.obs.notifyObservers(
          this,
          "buddy-authorization-request-canceled"
        );
        this._remove();
      },
      _remove() {
        this._account.removeBuddyRequest(this);
      },
      QueryInterface: ChromeUtils.generateQI(["prplIBuddyRequest"]),
    };
    this._pendingBuddyRequests.push(buddyRequest);
    Services.obs.notifyObservers(buddyRequest, "buddy-authorization-request");
  },
  removeBuddyRequest(aRequest) {
    if (!this._pendingBuddyRequests) {
      return;
    }

    this._pendingBuddyRequests = this._pendingBuddyRequests.filter(
      r => r !== aRequest
    );
  },
  /**
   * Cancel a pending buddy request.
   *
   * @param {string} aUserName - The username the request is for.
   */
  cancelBuddyRequest(aUserName) {
    if (!this._pendingBuddyRequests) {
      return;
    }

    for (let request of this._pendingBuddyRequests) {
      if (request.userName == aUserName) {
        request.cancel();
        break;
      }
    }
  },
  cancelPendingBuddyRequests() {
    if (!this._pendingBuddyRequests) {
      return;
    }

    for (let request of this._pendingBuddyRequests) {
      request.cancel();
    }
    delete this._pendingBuddyRequests;
  },

  _pendingChatRequests: null,
  /**
   * Inform the user about a new conversation invitation.
   *
   * @param {string} conversationName - Name of the conversation the user is
   *   invited to.
   * @param {(prplIChatRequest) => void} grantCallback - Function to be called
   *   when the invite is accepted.
   * @param {(prplIChatRequest?, boolean) => void} [denyCallback] - Function to
   *   be called when the invite is rejected. If omitted, |canDeny| will be
   *   |false|. Callback is passed a boolean indicating whether the rejection should be
   *   sent to the other party. It being false is equivalent to ignoring the invite, in
   *   which case the callback should try to apply the ignore on the protocol level.
   */
  addChatRequest(conversationName, grantCallback, denyCallback) {
    if (!this._pendingChatRequests) {
      this._pendingChatRequests = new Set();
    }
    let inviteHandling = Services.prefs.getIntPref(
      "messenger.conversations.autoAcceptChatInvitations"
    );
    // Only auto-reject invites that can be denied.
    if (inviteHandling <= 0 && denyCallback) {
      const shouldReject = inviteHandling == -1;
      denyCallback(null, shouldReject);
      return;
    }
    let resolvePromise;
    let rejectPromise;
    let completePromise = new Promise((resolve, reject) => {
      resolvePromise = resolve;
      rejectPromise = reject;
    });
    /** @implements {prplIChatRequest} */
    let chatRequest = {
      get account() {
        return this._account.imAccount;
      },
      get conversationName() {
        return conversationName;
      },
      get canDeny() {
        return Boolean(denyCallback);
      },
      _account: this,
      // Grant and deny callbacks both receive the auth request object as an
      // argument for further use.
      grant() {
        resolvePromise(true);
        grantCallback(this);
        this._remove();
      },
      deny() {
        if (!denyCallback) {
          throw new Error("Can not deny this invitation.");
        }
        resolvePromise(false);
        denyCallback(this, true);
        this._remove();
      },
      cancel() {
        rejectPromise(new Error("Cancelled"));
        this._remove();
      },
      completePromise,
      _remove() {
        this._account.removeChatRequest(this);
      },
      QueryInterface: ChromeUtils.generateQI(["prplIChatRequest"]),
    };
    this._pendingChatRequests.add(chatRequest);
    Services.obs.notifyObservers(chatRequest, "conv-authorization-request");
  },
  removeChatRequest(aRequest) {
    if (!this._pendingChatRequests) {
      return;
    }

    this._pendingChatRequests.delete(aRequest);
  },
  /**
   * Cancel a pending chat request.
   *
   * @param {string} conversationName - The conversation the request is for.
   */
  cancelChatRequest(conversationName) {
    if (!this._pendingChatRequests) {
      return;
    }

    for (let request of this._pendingChatRequests) {
      if (request.conversationName == conversationName) {
        request.cancel();
        break;
      }
    }
  },
  cancelPendingChatRequests() {
    if (!this._pendingChatRequests) {
      return;
    }

    for (let request of this._pendingChatRequests) {
      request.cancel();
    }
    this._pendingChatRequests = null;
  },

  requestBuddyInfo(aBuddyName) {},

  get canJoinChat() {
    return false;
  },
  getChatRoomFields() {
    if (!this.chatRoomFields) {
      return [];
    }
    let fieldNames = Object.keys(this.chatRoomFields);
    return fieldNames.map(
      fieldName => new ChatRoomField(fieldName, this.chatRoomFields[fieldName])
    );
  },
  getChatRoomDefaultFieldValues(aDefaultChatName) {
    if (!this.chatRoomFields) {
      return new ChatRoomFieldValues({});
    }

    let defaultFieldValues = {};
    for (let fieldName in this.chatRoomFields) {
      defaultFieldValues[fieldName] = this.chatRoomFields[fieldName].default;
    }

    if (aDefaultChatName && "parseDefaultChatName" in this) {
      let parsedDefaultChatName = this.parseDefaultChatName(aDefaultChatName);
      for (let field in parsedDefaultChatName) {
        defaultFieldValues[field] = parsedDefaultChatName[field];
      }
    }

    return new ChatRoomFieldValues(defaultFieldValues);
  },
  requestRoomInfo(aCallback) {
    throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
  },
  getRoomInfo(aName) {
    throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
  },
  get isRoomInfoStale() {
    return false;
  },

  getPref(aName, aType) {
    return this.prefs.prefHasUserValue(aName)
      ? this.prefs["get" + aType + "Pref"](aName)
      : this.protocol._getOptionDefault(aName);
  },
  getInt(aName) {
    return this.getPref(aName, "Int");
  },
  getBool(aName) {
    return this.getPref(aName, "Bool");
  },
  getString(aName) {
    return this.prefs.prefHasUserValue(aName)
      ? this.prefs.getStringPref(aName)
      : this.protocol._getOptionDefault(aName);
  },

  get prefs() {
    return (
      this._prefs ||
      (this._prefs = Services.prefs.getBranch(
        "messenger.account." + this.imAccount.id + ".options."
      ))
    );
  },

  get normalizedName() {
    return this.normalize(this.name);
  },
  normalize(aName) {
    return aName.toLowerCase();
  },

  getSessions() {
    return [];
  },
  reportSessionsChanged() {
    Services.obs.notifyObservers(this.imAccount, "account-sessions-changed");
  },

  _pendingVerificationRequests: null,
  /**
   *
   * @param {string} aDisplayName - Display name the request is from.
   * @param {() => Promise<{challenge: string, challengeDescription: string?}>} aGetChallenge - Accept request and generate
   *   the challenge.
   * @param {AbortSignal} [aAbortSignal] - Abort signal to indicate the request
   *   was cancelled.
   * @returns {Promise<boolean>} Completion promise for the verification.
   *   Boolean indicates the result of the verification, rejection is a cancel.
   */
  addVerificationRequest(aDisplayName, aGetChallenge, aAbortSignal) {
    if (!this._pendingVerificationRequests) {
      this._pendingVerificationRequests = [];
    }
    let verificationRequest = {
      _account: this,
      get account() {
        return this._account.imAccount;
      },
      get subject() {
        return aDisplayName;
      },
      get challengeType() {
        return Ci.imISessionVerification.CHALLENGE_TEXT;
      },
      get challenge() {
        return this._challenge;
      },
      get challengeDescription() {
        return this._challengeDescription;
      },
      _challenge: "",
      _challengeDescription: "",
      _canceled: false,
      completePromise: null,
      async verify() {
        const { challenge, challengeDescription = "" } = await aGetChallenge();
        this._challenge = challenge;
        this._challengeDescription = challengeDescription;
      },
      submitResponse(challengeMatches) {
        this._accept(challengeMatches);
        this._remove();
      },
      cancel() {
        if (this._canceled) {
          return;
        }
        this._canceled = true;
        Services.obs.notifyObservers(
          this,
          "buddy-verification-request-canceled"
        );
        this._deny();
        this._remove();
      },
      _remove() {
        this._account.removeVerificationRequest(this);
      },
      QueryInterface: ChromeUtils.generateQI([
        "imIIncomingSessionVerification",
      ]),
    };
    verificationRequest.completePromise = new Promise((resolve, reject) => {
      verificationRequest._accept = resolve;
      verificationRequest._deny = reject;
    });
    this._pendingVerificationRequests.push(verificationRequest);
    Services.obs.notifyObservers(
      verificationRequest,
      "buddy-verification-request"
    );
    if (aAbortSignal) {
      aAbortSignal.addEventListener(
        "abort",
        () => {
          verificationRequest.cancel();
        },
        { once: true }
      );
      if (aAbortSignal.aborted) {
        verificationRequest.cancel();
      }
    }
    return verificationRequest.completePromise;
  },
  /**
   * Remove a verification request for this account.
   *
   * @param {imIIncomingSessionVerification} aRequest
   */
  removeVerificationRequest(aRequest) {
    if (!this._pendingVerificationRequests) {
      return;
    }
    this._pendingVerificationRequests =
      this._pendingVerificationRequests.filter(r => r !== aRequest);
  },
  cancelPendingVerificationRequests() {
    if (!this._pendingVerificationRequests) {
      return;
    }
    for (let request of this._pendingVerificationRequests) {
      request.cancel();
    }
    this._pendingVerificationRequests = null;
  },

  _encryptionStatus: [],
  get encryptionStatus() {
    return this._encryptionStatus;
  },
  set encryptionStatus(newStatus) {
    this._encryptionStatus = newStatus;
    Services.obs.notifyObservers(
      this.imAccount,
      "account-encryption-status-changed",
      newStatus
    );
  },
};

export var GenericAccountBuddyPrototype = {
  __proto__: ClassInfo("prplIAccountBuddy", "generic account buddy object"),
  get DEBUG() {
    return this._account.DEBUG;
  },
  get LOG() {
    return this._account.LOG;
  },
  get WARN() {
    return this._account.WARN;
  },
  get ERROR() {
    return this._account.ERROR;
  },

  _init(aAccount, aBuddy, aTag, aUserName) {
    if (!aBuddy && !aUserName) {
      throw new Error("aUserName is required when aBuddy is null");
    }

    this._tag = aTag;
    this._account = aAccount;
    this._buddy = aBuddy;
    if (aBuddy) {
      let displayName = aBuddy.displayName;
      if (displayName != aUserName) {
        this._serverAlias = displayName;
      }
    }
    this._userName = aUserName;
  },
  unInit() {
    delete this._tag;
    delete this._account;
    delete this._buddy;
  },

  get account() {
    return this._account.imAccount;
  },
  set buddy(aBuddy) {
    if (this._buddy) {
      throw Components.Exception("", Cr.NS_ERROR_ALREADY_INITIALIZED);
    }
    this._buddy = aBuddy;
  },
  get buddy() {
    return this._buddy;
  },
  get tag() {
    return this._tag;
  },
  set tag(aNewTag) {
    let oldTag = this._tag;
    this._tag = aNewTag;
    IMServices.contacts.accountBuddyMoved(this, oldTag, aNewTag);
  },

  _notifyObservers(aTopic, aData) {
    try {
      this._buddy.observe(this, "account-buddy-" + aTopic, aData);
    } catch (e) {
      this.ERROR(e);
    }
  },

  _userName: "",
  get userName() {
    return this._userName || this._buddy.userName;
  },
  get normalizedName() {
    return this._account.normalize(this.userName);
  },
  _serverAlias: "",
  get serverAlias() {
    return this._serverAlias;
  },
  set serverAlias(aNewAlias) {
    let old = this.displayName;
    this._serverAlias = aNewAlias;
    if (old != this.displayName) {
      this._notifyObservers("display-name-changed", old);
    }
  },

  /**
   * Method called to start verification of the buddy. Same signature as
   * _startVerification of GenericSessionPrototype. If the property is not a
   * function, |canVerifyIdentity| is false.
   *
   * @type {() => {challenge: string, challengeDescription: string?, handleResult: (boolean) => void, cancel: () => void, cancelPromise: Promise}?}
   */
  _startVerification: null,
  get canVerifyIdentity() {
    return typeof this._startVerification === "function";
  },
  _identityVerified: false,
  get identityVerified() {
    return this.canVerifyIdentity && this._identityVerified;
  },
  verifyIdentity() {
    if (!this.canVerifyIdentity) {
      throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
    }
    if (this.identityVerified) {
      return Promise.resolve();
    }
    return this._startVerification().then(
      ({
        challenge,
        challengeDescription,
        handleResult,
        cancel,
        cancelPromise,
      }) => {
        const verifier = new SessionVerification(
          challenge,
          this.userName,
          challengeDescription
        );
        verifier.completePromise.then(
          result => handleResult(result),
          () => cancel()
        );
        cancelPromise.then(() => verifier.cancel());
        return verifier;
      }
    );
  },

  remove() {
    IMServices.contacts.accountBuddyRemoved(this);
  },

  // imIStatusInfo implementation
  get displayName() {
    return this.serverAlias || this.userName;
  },
  _buddyIconFilename: "",
  get buddyIconFilename() {
    return this._buddyIconFilename;
  },
  set buddyIconFilename(aNewFileName) {
    this._buddyIconFilename = aNewFileName;
    this._notifyObservers("icon-changed");
  },
  _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;
  },

  // This is for use by the protocol plugin, it's not exposed in the
  // imIStatusInfo interface.
  // All parameters are optional and will be ignored if they are null
  // or undefined.
  setStatus(aStatusType, aStatusText, aAvailabilityDetails) {
    // Ignore omitted parameters.
    if (aStatusType === undefined || aStatusType === null) {
      aStatusType = this._statusType;
    }
    if (aStatusText === undefined || aStatusText === null) {
      aStatusText = this._statusText;
    }
    if (aAvailabilityDetails === undefined || aAvailabilityDetails === null) {
      aAvailabilityDetails = this._availabilityDetails;
    }

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

    // Actually change the stored status.
    [this._statusType, this._statusText, this._availabilityDetails] = [
      aStatusType,
      aStatusText,
      aAvailabilityDetails,
    ];

    // Fire the notifications.
    notifications.forEach(function (aTopic) {
      this._notifyObservers(aTopic);
    }, this);
  },

  _availabilityDetails: 0,
  get availabilityDetails() {
    return this._availabilityDetails;
  },

  get canSendMessage() {
    return this.online;
  },

  getTooltipInfo: () => [],
  createConversation() {
    throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
  },
};

// aUserName is required only if aBuddy is null, i.e., we are adding a buddy.
function AccountBuddy(aAccount, aBuddy, aTag, aUserName) {
  this._init(aAccount, aBuddy, aTag, aUserName);
}
AccountBuddy.prototype = GenericAccountBuddyPrototype;

export var GenericMessagePrototype = {
  __proto__: ClassInfo("prplIMessage", "generic message object"),

  _lastId: 0,
  _init(aWho, aMessage, aObject, aConversation) {
    this.id = ++GenericMessagePrototype._lastId;
    this.time = Math.floor(new Date() / 1000);
    this.who = aWho;
    this.message = aMessage;
    this.originalMessage = aMessage;
    this.conversation = aConversation;

    if (aObject) {
      for (let i in aObject) {
        this[i] = aObject[i];
      }
    }
  },
  _alias: "",
  get alias() {
    return this._alias || this.who;
  },
  _iconURL: "",
  get iconURL() {
    // If the protocol plugin has explicitly set an icon for the message, use it.
    if (this._iconURL) {
      return this._iconURL;
    }

    // Otherwise, attempt to find a buddy for incoming messages, and forward the call.
    if (this.incoming && this.conversation && !this.conversation.isChat) {
      let buddy = this.conversation.buddy;
      if (buddy) {
        return buddy.buddyIconFilename;
      }
    }
    return "";
  },
  conversation: null,
  remoteId: "",

  outgoing: false,
  incoming: false,
  system: false,
  autoResponse: false,
  containsNick: false,
  noLog: false,
  error: false,
  delayed: false,
  noFormat: false,
  containsImages: false,
  notification: false,
  noLinkification: false,
  noCollapse: false,
  isEncrypted: false,
  action: false,
  deleted: false,

  getActions() {
    return [];
  },

  whenDisplayed() {},
  whenRead() {},
};

export function Message(aWho, aMessage, aObject, aConversation) {
  this._init(aWho, aMessage, aObject, aConversation);
}

Message.prototype = GenericMessagePrototype;

export var GenericConversationPrototype = {
  __proto__: ClassInfo("prplIConversation", "generic conversation object"),
  get wrappedJSObject() {
    return this;
  },

  get DEBUG() {
    return this._account.DEBUG;
  },
  get LOG() {
    return this._account.LOG;
  },
  get WARN() {
    return this._account.WARN;
  },
  get ERROR() {
    return this._account.ERROR;
  },

  _init(aAccount, aName) {
    this._account = aAccount;
    this._name = aName;
    this._observers = [];
    this._date = new Date() * 1000;
    IMServices.conversations.addConversation(this);
  },

  _id: 0,
  get id() {
    return this._id;
  },
  set id(aId) {
    if (this._id) {
      throw Components.Exception("", Cr.NS_ERROR_ALREADY_INITIALIZED);
    }
    this._id = aId;
  },

  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) {
      try {
        observer.observe(aSubject, aTopic, aData);
      } catch (e) {
        this.ERROR(e);
      }
    }
  },

  prepareForSending: aOutgoingMessage => [aOutgoingMessage.message],
  prepareForDisplaying(aImMessage) {
    if (aImMessage.displayMessage !== aImMessage.message) {
      this.DEBUG(
        "Preparing:\n" +
          aImMessage.message +
          "\nDisplaying:\n" +
          aImMessage.displayMessage
      );
    }
  },
  sendMsg(aMsg, aAction = false, aNotification = false) {
    // Add-ons (eg. pastebin) have an opportunity to cancel the message at this
    // point, or change the text content of the message.
    // If an add-on wants to split a message, it should truncate the first
    // message, and insert new messages using the conversation's sendMsg method.
    let om = new OutgoingMessage(aMsg, this);
    om.action = aAction;
    om.notification = aNotification;
    this.notifyObservers(om, "preparing-message");
    if (om.cancelled) {
      return;
    }

    // Protocols have an opportunity here to preprocess messages before they are
    // sent (eg. split long messages). If a message is split here, the split
    // will be visible in the UI.
    let messages = this.prepareForSending(om);
    let isAction = om.action;
    let isNotification = om.notification;

    for (let msg of messages) {
      // Add-ons (eg. OTR) have an opportunity to tweak or cancel the message
      // at this point.
      om = new OutgoingMessage(msg, this);
      om.action = isAction;
      om.notification = isNotification;
      this.notifyObservers(om, "sending-message");
      if (om.cancelled) {
        continue;
      }
      this.dispatchMessage(om.message, om.action, om.notification);
    }
  },
  dispatchMessage(message, action, notification) {
    throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
  },
  sendTyping: aString => Ci.prplIConversation.NO_TYPING_LIMIT,

  close() {
    Services.obs.notifyObservers(this, "closing-conversation");
    IMServices.conversations.removeConversation(this);
  },
  unInit() {
    delete this._account;
    delete this._observers;
  },

  /**
   * Create a prplIMessage instance from params.
   *
   * @param {string} who - Nick of the participant who sent the message.
   * @param {string} text - Raw message contents.
   * @param {object} properties - Additional properties of the message.
   * @returns {prplIMessage}
   */
  createMessage(who, text, properties) {
    return new Message(who, text, properties, this);
  },

  writeMessage(aWho, aText, aProperties) {
    const message = this.createMessage(aWho, aText, aProperties);
    this.notifyObservers(message, "new-text");
  },

  /**
   * Update the contents of a message.
   *
   * @param {string} who - Nick of the participant who sent the message.
   * @param {string} text - Raw contents of the message.
   * @param {object} properties - Additional properties of the message. Should
   *   specify a |remoteId| to find the previous version of this message.
   */
  updateMessage(who, text, properties) {
    const message = this.createMessage(who, text, properties);
    this.notifyObservers(message, "update-text");
  },

  /**
   * Remove a message from the conversation. Does not affect logs, use
   * updateMessage with a deleted property to remove from logs.
   *
   * @param {string} remoteId - Remote ID of the event to remove.
   */
  removeMessage(remoteId) {
    this.notifyObservers(null, "remove-text", remoteId);
  },

  get account() {
    return this._account.imAccount;
  },
  get name() {
    return this._name;
  },
  get normalizedName() {
    return this._account.normalize(this.name);
  },
  get title() {
    return this.name;
  },
  get startDate() {
    return this._date;
  },
  _convIconFilename: "",
  get convIconFilename() {
    return this._convIconFilename;
  },
  set convIconFilename(aNewFilename) {
    this._convIconFilename = aNewFilename;
    this.notifyObservers(this, "update-conv-icon");
  },

  get encryptionState() {
    return Ci.prplIConversation.ENCRYPTION_NOT_SUPPORTED;
  },
  initializeEncryption() {
    throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
  },
};

export var GenericConvIMPrototype = {
  __proto__: GenericConversationPrototype,
  _interfaces: [Ci.prplIConversation, Ci.prplIConvIM],
  classDescription: "generic ConvIM object",

  updateTyping(aState, aName) {
    if (aState == this.typingState) {
      return;
    }

    if (aState == Ci.prplIConvIM.NOT_TYPING) {
      delete this.typingState;
    } else {
      this.typingState = aState;
    }
    this.notifyObservers(null, "update-typing", aName);
  },

  get isChat() {
    return false;
  },
  buddy: null,
  typingState: Ci.prplIConvIM.NOT_TYPING,
  get convIconFilename() {
    // By default, pass through information from the buddy for IM conversations
    // that don't have their own icon.
    const convIconFilename = this._convIconFilename;
    if (convIconFilename) {
      return convIconFilename;
    }
    return this.buddy?.buddyIconFilename;
  },
};

export var GenericConvChatPrototype = {
  __proto__: GenericConversationPrototype,
  _interfaces: [Ci.prplIConversation, Ci.prplIConvChat],
  classDescription: "generic ConvChat object",

  _init(aAccount, aName, aNick) {
    // _participants holds prplIConvChatBuddy objects.
    this._participants = new Map();
    this.nick = aNick;
    GenericConversationPrototype._init.call(this, aAccount, aName);
  },

  get isChat() {
    return true;
  },

  // Stores the prplIChatRoomFieldValues required to join this channel
  // to enable later reconnections. If null, the MUC will not be reconnected
  // automatically after disconnections.
  chatRoomFields: null,

  _topic: "",
  _topicSetter: null,
  get topic() {
    return this._topic;
  },
  get topicSettable() {
    return false;
  },
  get topicSetter() {
    return this._topicSetter;
  },
  /**
   * Set the topic of a conversation.
   *
   * @param {string} aTopic - The new topic. If an update message is sent to
   *   the conversation, this will be HTML escaped before being sent.
   * @param {string} aTopicSetter - The user who last modified the topic.
   * @param {string} aQuiet - If false, a message notifying about the topic
   *   change will be sent to the conversation.
   */
  setTopic(aTopic, aTopicSetter, aQuiet) {
    // Only change the topic if the topic and/or topic setter has changed.
    if (
      this._topic == aTopic &&
      (!this._topicSetter || this._topicSetter == aTopicSetter)
    ) {
      return;
    }

    this._topic = aTopic;
    this._topicSetter = aTopicSetter;

    this.notifyObservers(null, "chat-update-topic");

    if (aQuiet) {
      return;
    }

    // Send the topic as a message.
    let message;
    if (aTopicSetter) {
      if (aTopic) {
        message = lazy._("topicChanged", aTopicSetter, lazy.TXTToHTML(aTopic));
      } else {
        message = lazy._("topicCleared", aTopicSetter);
      }
    } else {
      aTopicSetter = null;
      if (aTopic) {
        message = lazy._("topicSet", this.name, lazy.TXTToHTML(aTopic));
      } else {
        message = lazy._("topicNotSet", this.name);
      }
    }
    this.writeMessage(aTopicSetter, message, { system: true });
  },

  get nick() {
    return this._nick;
  },
  set nick(aNick) {
    this._nick = aNick;
    let escapedNick = this._nick.replace(/[[\]{}()*+?.\\^$|]/g, "\\$&");
    this._pingRegexp = new RegExp("(?:^|\\W)" + escapedNick + "(?:\\W|$)", "i");
  },

  _left: false,
  get left() {
    return this._left;
  },
  set left(aLeft) {
    if (aLeft == this._left) {
      return;
    }
    this._left = aLeft;
    this.notifyObservers(null, "update-conv-chatleft");
  },

  _joining: false,
  get joining() {
    return this._joining;
  },
  set joining(aJoining) {
    if (aJoining == this._joining) {
      return;
    }
    this._joining = aJoining;
    this.notifyObservers(null, "update-conv-chatjoining");
  },

  getParticipant(aName) {
    return this._participants.has(aName) ? this._participants.get(aName) : null;
  },
  getParticipants() {
    // Convert the values of the Map into an array.
    return Array.from(this._participants.values());
  },
  getNormalizedChatBuddyName: aChatBuddyName => aChatBuddyName,

  // Updates the nick of a participant in conversation to a new one.
  updateNick(aOldNick, aNewNick, isOwnNick) {
    let message;
    let isParticipant = this._participants.has(aOldNick);
    if (isOwnNick) {
      // If this is the user's nick, change it.
      this.nick = aNewNick;
      message = lazy._("nickSet.you", aNewNick);

      // If the account was disconnected, it's OK the user is not a participant.
      if (!isParticipant) {
        return;
      }
    } else if (!isParticipant) {
      this.ERROR(
        "Trying to rename nick that doesn't exist! " +
          aOldNick +
          " to " +
          aNewNick
      );
      return;
    } else {
      message = lazy._("nickSet", aOldNick, aNewNick);
    }

    // Get the original participant and then remove it.
    let participant = this._participants.get(aOldNick);
    this._participants.delete(aOldNick);

    // Update the nickname and add it under the new nick.
    participant.name = aNewNick;
    this._participants.set(aNewNick, participant);

    this.notifyObservers(participant, "chat-buddy-update", aOldNick);
    this.writeMessage(aOldNick, message, { system: true });
  },

  // Removes a participant from conversation.
  removeParticipant(aNick) {
    if (!this._participants.has(aNick)) {
      return;
    }

    let stringNickname = Cc["@mozilla.org/supports-string;1"].createInstance(
      Ci.nsISupportsString
    );
    stringNickname.data = aNick;
    this.notifyObservers(
      new nsSimpleEnumerator([stringNickname]),
      "chat-buddy-remove"
    );
    this._participants.delete(aNick);
  },

  // Removes all participant in conversation.
  removeAllParticipants() {
    let stringNicknames = [];
    this._participants.forEach(function (aParticipant) {
      let stringNickname = Cc["@mozilla.org/supports-string;1"].createInstance(
        Ci.nsISupportsString
      );
      stringNickname.data = aParticipant.name;
      stringNicknames.push(stringNickname);
    });
    this.notifyObservers(
      new nsSimpleEnumerator(stringNicknames),
      "chat-buddy-remove"
    );
    this._participants.clear();
  },

  createMessage(who, text, properties) {
    properties.containsNick =
      "incoming" in properties && this._pingRegexp.test(text);
    return GenericConversationPrototype.createMessage.apply(this, arguments);
  },
};

export var GenericConvChatBuddyPrototype = {
  __proto__: ClassInfo("prplIConvChatBuddy", "generic ConvChatBuddy object"),

  _name: "",
  get name() {
    return this._name;
  },
  set name(aName) {
    this._name = aName;
  },
  alias: "",
  buddy: false,
  buddyIconFilename: "",

  voiced: false,
  moderator: false,
  admin: false,
  founder: false,
  typing: false,

  /**
   * Method called to start verification of the buddy. Same signature as
   * _startVerification of GenericSessionPrototype. If the property is not a
   * function, |canVerifyIdentity| is false.
   *
   * @type {() => {challenge: string, challengeDescription: string?, handleResult: (boolean) => void, cancel: () => void, cancelPromise: Promise}?}
   */
  _startVerification: null,
  get canVerifyIdentity() {
    return typeof this._startVerification === "function";
  },
  _identityVerified: false,
  get identityVerified() {
    return this.canVerifyIdentity && this._identityVerified;
  },
  verifyIdentity() {
    if (!this.canVerifyIdentity) {
      throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
    }
    if (this.identityVerified) {
      return Promise.resolve();
    }
    return this._startVerification().then(
      ({
        challenge,
        challengeDescription,
        handleResult,
        cancel,
        cancelPromise,
      }) => {
        const verifier = new SessionVerification(
          challenge,
          this.name,
          challengeDescription
        );
        verifier.completePromise.then(
          result => handleResult(result),
          () => cancel()
        );
        cancelPromise.then(() => verifier.cancel());
        return verifier;
      }
    );
  },
};

export function TooltipInfo(aLabel, aValue, aType = Ci.prplITooltipInfo.pair) {
  this.type = aType;
  if (aType == Ci.prplITooltipInfo.status) {
    this.label = aLabel.toString();
    this.value = aValue || "";
  } else if (aType == Ci.prplITooltipInfo.icon) {
    this.value = aValue;
  } else if (
    aLabel === undefined ||
    aType == Ci.prplITooltipInfo.sectionBreak
  ) {
    this.type = Ci.prplITooltipInfo.sectionBreak;
  } else {
    this.label = aLabel;
    if (aValue === undefined) {
      this.type = Ci.prplITooltipInfo.sectionHeader;
    } else {
      this.value = aValue;
    }
  }
}

TooltipInfo.prototype = ClassInfo("prplITooltipInfo", "generic tooltip info");

/* aOption is an object containing:
 *  - label: localized text to display (recommended: use a getter with _)
 *  - default: the default value for this option. The type of the
 *      option will be determined based on the type of the default value.
 *      If the default value is a string, the option will be of type
 *      list if listValues has been provided. In that case the default
 *      value should be one of the listed values.
 *  - [optional] listValues: only if this option can only take a list of
 *      predefined values. This is an object of the form:
 *        {value1: localizedLabel, value2: ...}.
 *  - [optional] masked: boolean, if true the UI shouldn't display the value.
 *      This could typically be used for password field.
 *      Warning: The UI currently doesn't support this.
 */
function purplePref(aName, aOption) {
  this.name = aName; // Preference name
  this.label = aOption.label; // Text to display

  if (aOption.default === undefined || aOption.default === null) {
    throw new Error(
      "A default value for the option is required to determine its type."
    );
  }
  this._defaultValue = aOption.default;

  const kTypes = { boolean: "Bool", string: "String", number: "Int" };
  let type = kTypes[typeof aOption.default];
  if (!type) {
    throw new Error("Invalid option type");
  }

  if (type == "String" && "listValues" in aOption) {
    type = "List";
    this._listValues = aOption.listValues;
  }
  this.type = Ci.prplIPref["type" + type];

  if ("masked" in aOption && aOption.masked) {
    this.masked = true;
  }
}
purplePref.prototype = {
  __proto__: ClassInfo("prplIPref", "generic account option preference"),

  masked: false,

  // Default value
  getBool() {
    return this._defaultValue;
  },
  getInt() {
    return this._defaultValue;
  },
  getString() {
    return this._defaultValue;
  },
  getList() {
    // Convert a JavaScript object map {"value 1": "label 1", ...}
    let keys = Object.keys(this._listValues);
    return keys.map(key => new purpleKeyValuePair(this._listValues[key], key));
  },
  getListDefault() {
    return this._defaultValue;
  },
};

function purpleKeyValuePair(aName, aValue) {
  this.name = aName;
  this.value = aValue;
}
purpleKeyValuePair.prototype = ClassInfo(
  "prplIKeyValuePair",
  "generic Key Value Pair"
);

function UsernameSplit(aValues) {
  this._values = aValues;
}
UsernameSplit.prototype = {
  __proto__: ClassInfo("prplIUsernameSplit", "username split object"),

  get label() {
    return this._values.label;
  },
  get separator() {
    return this._values.separator;
  },
  get defaultValue() {
    return this._values.defaultValue;
  },
};

function ChatRoomField(aIdentifier, aField) {
  this.identifier = aIdentifier;
  this.label = aField.label;
  this.required = !!aField.required;

  let type = "TEXT";
  if (typeof aField.default == "number") {
    type = "INT";
    this.min = aField.min;
    this.max = aField.max;
  } else if (aField.isPassword) {
    type = "PASSWORD";
  }
  this.type = Ci.prplIChatRoomField["TYPE_" + type];
}
ChatRoomField.prototype = ClassInfo(
  "prplIChatRoomField",
  "ChatRoomField object"
);

function ChatRoomFieldValues(aMap) {
  this.values = aMap;
}
ChatRoomFieldValues.prototype = {
  __proto__: ClassInfo("prplIChatRoomFieldValues", "ChatRoomFieldValues"),

  getValue(aIdentifier) {
    return this.values.hasOwnProperty(aIdentifier)
      ? this.values[aIdentifier]
      : null;
  },
  setValue(aIdentifier, aValue) {
    this.values[aIdentifier] = aValue;
  },
};

// the name getter and the getAccount method need to be implemented by
// protocol plugins.
export var GenericProtocolPrototype = {
  __proto__: ClassInfo("prplIProtocol", "Generic protocol object"),

  init(aId) {
    if (aId != this.id) {
      throw new Error(
        "Creating an instance of " +
          aId +
          " but this object implements " +
          this.id
      );
    }
  },
  get id() {
    return "prpl-" + this.normalizedName;
  },
  get iconBaseURI() {
    return "chrome://chat/skin/prpl-generic/";
  },

  getAccount(aImAccount) {
    throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
  },

  _getOptionDefault(aName) {
    if (this.options && this.options.hasOwnProperty(aName)) {
      return this.options[aName].default;
    }
    throw new Error(aName + " has no default value in " + this.id + ".");
  },
  getOptions() {
    if (!this.options) {
      return [];
    }

    let purplePrefs = [];
    for (let [name, option] of Object.entries(this.options)) {
      purplePrefs.push(new purplePref(name, option));
    }
    return purplePrefs;
  },
  usernamePrefix: "",
  getUsernameSplit() {
    if (!this.usernameSplits || !this.usernameSplits.length) {
      return [];
    }
    return this.usernameSplits.map(split => new UsernameSplit(split));
  },

  /**
   * Protocol agnostic implementation that splits the username by the pattern
   * defined with |usernamePrefix| and |usernameSplits| on the protocol.
   * Prefers the first occurrence of a separator.
   *
   * @param {string} aName - Username to split.
   * @returns {string[]} Parts of the username or empty array if the username
   *   doesn't match the splitting format.
   */
  splitUsername(aName) {
    let remainingName = aName;
    if (this.usernamePrefix) {
      if (!remainingName.startsWith(this.usernamePrefix)) {
        return [];
      }
      remainingName = remainingName.slice(this.usernamePrefix.length);
    }
    if (!this.usernameSplits || !this.usernameSplits.length) {
      return [remainingName];
    }
    const parts = [];
    for (const split of this.usernameSplits) {
      if (!remainingName.includes(split.separator)) {
        return [];
      }
      const separatorIndex = remainingName.indexOf(split.separator);
      parts.push(remainingName.slice(0, separatorIndex));
      remainingName = remainingName.slice(
        separatorIndex + split.separator.length
      );
    }
    parts.push(remainingName);
    return parts;
  },

  registerCommands() {
    if (!this.commands) {
      return;
    }

    this.commands.forEach(function (command) {
      if (!command.hasOwnProperty("name") || !command.hasOwnProperty("run")) {
        throw new Error("Every command must have a name and a run function.");
      }
      if (!("QueryInterface" in command)) {
        command.QueryInterface = ChromeUtils.generateQI(["imICommand"]);
      }
      if (!command.hasOwnProperty("usageContext")) {
        command.usageContext = Ci.imICommand.CMD_CONTEXT_ALL;
      }
      if (!command.hasOwnProperty("priority")) {
        command.priority = Ci.imICommand.CMD_PRIORITY_PRPL;
      }
      IMServices.cmd.registerCommand(command, this.id);
    }, this);
  },

  // NS_ERROR_XPC_JSOBJECT_HAS_NO_FUNCTION_NAMED errors are too noisy
  get usernameEmptyText() {
    return "";
  },
  accountExists: () => false, // FIXME

  get chatHasTopic() {
    return false;
  },
  get noPassword() {
    return false;
  },
  get passwordOptional() {
    return false;
  },
  get slashCommandsNative() {
    return false;
  },
  get canEncrypt() {
    return false;
  },

  get classDescription() {
    return this.name + " Protocol";
  },
  get contractID() {
    return "@mozilla.org/chat/" + this.normalizedName + ";1";
  },
};

/**
 * Text challenge session verification flow. Starts the UI flow.
 *
 * @param {string} challenge - String the challenge should display.
 * @param {string} subject - Human readable identifier of the other side of the
 *  challenge.
 * @param {string} [challengeDescription] - Description of the challenge
 *  contents.
 */
function SessionVerification(challenge, subject, challengeDescription) {
  this._challenge = challenge;
  this._subject = subject;
  if (challengeDescription) {
    this._description = challengeDescription;
  }
  this._responsePromise = new Promise((resolve, reject) => {
    this._submit = resolve;
    this._cancel = reject;
  });
}
SessionVerification.prototype = {
  __proto__: ClassInfo(
    "imISessionVerification",
    "generic session verification object"
  ),
  _challengeType: Ci.imISessionVerification.CHALLENGE_TEXT,
  _challenge: "",
  _description: "",
  _responsePromise: null,
  _submit: null,
  _cancel: null,
  _cancelled: false,
  get challengeType() {
    return this._challengeType;
  },
  get challenge() {
    return this._challenge;
  },
  get challengeDescription() {
    return this._description;
  },
  get subject() {
    return this._subject;
  },
  get completePromise() {
    return this._responsePromise;
  },
  submitResponse(challengeMatches) {
    this._submit(challengeMatches);
  },
  cancel() {
    if (this._cancelled) {
      return;
    }
    this._cancelled = true;
    this._cancel();
  },
};

export var GenericSessionPrototype = {
  __proto__: ClassInfo("prplISession", "generic session object"),
  /**
   * Initialize the session.
   *
   * @param {prplIAccount} account - Account the session is related to.
   * @param {string} id - ID of the session.
   * @param {boolean} [trusted=false] - If the session is trusted.
   * @param {boolean} [currentSession=false] - If the session represents the.
   *  session we're connected as.
   */
  _init(account, id, trusted = false, currentSession = false) {
    this._account = account;
    this._id = id;
    this._trusted = trusted;
    this._currentSession = currentSession;
  },
  _account: null,
  _id: "",
  _trusted: false,
  _currentSession: false,
  get id() {
    return this._id;
  },
  get trusted() {
    return this._trusted;
  },
  set trusted(newTrust) {
    this._trusted = newTrust;
    this._account.reportSessionsChanged();
  },
  get currentSession() {
    return this._currentSession;
  },
  /**
   * Handle the start of the session verification process. The protocol is
   * expected to update the trusted property on the session if it becomes
   * trusted after verification.
   *
   * @returns {Promise<{challenge: string, challengeDescription: string?, handleResult: (boolean) => void, cancel: () => void, cancelPromise: Promise<void>}>}
   *  Promise resolves to an object holding the challenge string, as well as a
   *  callback that handles the result of the verification flow. The cancel
   *  callback is called when the verification is cancelled and the cancelPromise
   *  is used for the protocol to report when the other side cancels.
   *  The cancel callback will be called when the cancel promise resolves.
   */
  _startVerification() {
    return Promise.reject(
      Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED)
    );
  },
  verify() {
    if (this.trusted) {
      return Promise.resolve();
    }
    return this._startVerification().then(
      ({
        challenge,
        challengeDescription,
        handleResult,
        cancel,
        cancelPromise,
      }) => {
        const verifier = new SessionVerification(
          challenge,
          this.id,
          challengeDescription
        );
        verifier.completePromise.then(
          result => handleResult(result),
          () => cancel()
        );
        cancelPromise.then(() => verifier.cancel());
        return verifier;
      }
    );
  },
};
