/*
 * NOTICE:  This file is owned by the chat and messaging team (#chat-and-messaging channel on slack)
 * If you make changes to this file or any of its chat-effecting dependencies, you must do the following two things:
 * 1)  Test chat and whispers in a staging environment
 * 2)  Get a PR approved by a member of the chat and messaging team
 *
 * Thanks!
 */

/* globals TMI, i18n, Twitch, _, window */

import EObject from 'ember-object';
import BadgeSetModel from 'web-client/models/badge-set';
import ChannelModel from 'web-client/models/deprecated-channel';
import RoomPropertiesModel from 'web-client/models/room-properties';
import StoreModel from 'web-client/models/store';
import nonce from 'web-client/utilities/nonce';
import TicketProductModel from 'web-client/models/ticket-product';
import computed from 'ember-computed';
import { wrap } from 'web-client/utilities/promises';
import tmiEmotes from 'web-client/utilities/tmi-emotes';
import { extractFirstClipInfo } from 'web-client/utilities/clips/clips-url';
import { assign } from 'ember-platform';
import injectService from 'ember-service/inject';
import sendHostTargetNotification from 'web-client/utilities/send-host-target-notification';
import { sleep } from 'web-client/helpers/chat/chat-line-helpers';
import { A as emberA } from 'ember-array/utils';
import RSVP from 'rsvp';
import observer from 'ember-metal/observer';
import on from 'ember-evented/on';
import run from 'ember-runloop';
import get from 'ember-metal/get';
import set from 'ember-metal/set';
import { capitalize, mentionizeMessage } from 'web-client/helpers/chat/chat-line-helpers';
import { formatDisplayName } from 'web-client/helpers/format-display-name';

export const WHISPER_NOTICES = {
  whisper_banned: i18n('You have been banned from sending whispers.'),
  whisper_banned_recipient: i18n('That user has been banned from receiving whispers.'),
  whisper_invalid_args: i18n('Usage: "/w <login> <message>"'),
  whisper_invalid_login: i18n('No user matching that login.'),
  whisper_invalid_self: i18n('You cannot whisper to yourself.'),
  whisper_limit_per_min: i18n('You are sending whispers too fast. Try again in 1 minute.'),
  whisper_limit_per_sec: i18n('You are sending whispers too fast. Try again in a second.'),
  whisper_restricted: i18n('Your settings prevent you from sending this whisper.'),
  whisper_restricted_recipient: i18n('That user\'s settings prevent them from receiving this whisper.')
};

const CHAT_RULES_EXPERIMENT = 'CHAT_RULES_EXPERIMENT';

WHISPER_NOTICES.whisper_sender_banned = WHISPER_NOTICES.whisper_banned;
WHISPER_NOTICES.whisper_target_banned = WHISPER_NOTICES.whisper_banned_recipient;

const WHISPERS_REGEX = /^\/w(\s|$)/;
const KAPPA_WHISPER = i18n('Psst… Now you can privately message a user without leaving chat. ' +
                      'Type /w <username> to send a whisper and start a private chat without ' +
                      'leaving your favorite channels.');
const GOLDEN_KAPPA_EMOTE = 80393;
const MAX_CHATTERS_LENGTH = 200;
const LATENCY_SAMPLE_RATE = 10000;

const BUFFER_FLUSH_DELAY_MS = 200;

const CHAT_RULES_SHOWN_KEY = 'chat_rules_shown';

const MESSAGE_HISTORY_COUNT = 50;

let userTimeouts = {};

function convertToTmiSchema(imMessage) {
  let tmiMessage = {
    style: "whisper",
    from: imMessage.tags.login,
    to: imMessage.recipient.username,
    message: imMessage.body,
    color: imMessage.tags.color,
    date: new Date(imMessage.sent_ts * 1000),
    tags: {
      "badges": imMessage.tags.badges,
      "display-name": imMessage.tags.display_name,
      "message-id": imMessage.id.toString(),
      "thread-id": imMessage.thread_id,
      "user-id": imMessage.from_id.toString(),
      "user-type": imMessage.tags.user_type,
      "turbo": imMessage.tags.turbo
    },
    nonce: imMessage.nonce
  };

  if (imMessage.tags.emotes) {
    tmiMessage.tags.emotes = tmiEmotes(imMessage.tags.emotes);
  }

  return tmiMessage;
}

let roomModel = StoreModel.defineModel({
  bitsTags: injectService(),
  experiments: injectService(),
  globals: injectService(),
  layout: injectService(),
  pubsub: injectService('chat-pubsub'),
  session: injectService(),
  store: injectService(),
  tmi: injectService(),
  tracking: injectService(),
  conversations: injectService('twitch-conversations/conversations'),
  userEmotes: injectService(),
  whispersShim: injectService(),
  friends: injectService('twitch-friends-list/friends'),

  suggestionsDisabled: false,

  isGroupRoom: computed('id', function () {
    let id = this.get('id');
    return id && id.charAt(0) === '_';
  }),

  initializeRoom: on('init', function () {
    let id = this.get('id'),
        isGroupRoom = this.get('isGroupRoom'),
        isLoggedIn = Twitch.user.isLoggedIn();

    if (!this.get('isGroupRoom')) {
      this._banTimeout = setInterval(function(){
        for (let key in userTimeouts) {
          if ((Math.floor(Date.now() / 1000) - userTimeouts[key]['ban-time']) > 1) {
            delete userTimeouts[key];
          }
        }
      }, 1000);
    }

    this.set('inChatRulesExperiment', "control");
    this.set('inHistoryStyleExperiment', false);
    this.set('messageToSend', '');
    this._queuedRawMessages = [];
    this._flushRawMessageTimeout = null;
    this._queuedMessages = [];
    this._requestedFlushMessages = false;
    this._requestedFlushRawMessages = false;
    this._clearedUsers = [];
    this.messages = [];
    this.set('delayedMessages', []);
    this.set('unreadCount', 0);
    this.messageBufferSize = 150;
    this.chatterList = [];
    this.set('fixedList', []);
    this.set('isLoading', true);
    this.set('viewers', roomModel.Viewers.create({id: id}));
    this.set('flashTimedOut', false);
    this.set('shouldDisplayChatRules', false);
    this.set('sentFriendsWatchingMessage', false);
    this.set('trackFriendsInChannel', {});

    if (isLoggedIn) {
      this.set('messageToSend', this.get('savedInput'));
    }

    /* If it isn't group rooms, fetch additional info. */
    if (!isGroupRoom) {
      let channel = ChannelModel.find({id: id});
      channel.load().then(currentChannel => {
        if (this.isDestroyed) {
          return;
        }
        this.set('fixedList', [{
          id: this.get('roomProperties.id'),
          whispered: false,
          displayName: currentChannel.display_name
        }]);
      });
      this.set('roomProperties', RoomPropertiesModel.findOne(id).load());
      this.set('badgeSet', BadgeSetModel.findOne(id).load());
      this.set('channel', channel);
      this.set('product', TicketProductModel.findOne(id).load());
    }

    this.get('tmi.tmiSession').then(tmiSession => {
      this.set('tmiSession', tmiSession);
      if (!isLoggedIn || !isGroupRoom) {
        /** If you aren't logged in, then listRooms will fail.
            If you aren't trying to join a group room, you don't need to call listRooms. */
        this.setupTmiRoom();
      } else {
        tmiSession.listRooms().always(() => { this.setupTmiRoom(); });
      }
    });
  }),

  setupTmiRoom() {
    let id = this.get('id'),
        tmiSession = this.get('tmiSession');

    tmiSession.getRoom(id).done(run.bind(this, this.attachCallbacks));
    this.get('experiments').getExperimentValue(CHAT_RULES_EXPERIMENT).then((value) => {
      if (this.isDestroyed) {
        return;
      }
      if (value === 'rules_v1') {
        this.set('inChatRulesExperiment', "rules_v1");
      } else if (value === 'rules_v2') {
        this.set('inChatRulesExperiment', "rules_v2");
      } else {
        this.set('inChatRulesExperiment', "control");
      }
    });
  },

  attachCallbacks(tmiRoom) {
    if (this.isDestroyed) { return; }

    let id = this.get('id'),
        isGroupRoom = this.get('isGroupRoom'),
        tmiSession = this.get('tmiSession'),
        userName = Twitch.user.login();

    this.set('tmiRoom', tmiRoom);
    this.set('viewers.tmiRoom', tmiRoom);
    this._callbacks = [];

    let bindCallback = (emitter, eventName, callback) => {
      this._callbacks.push({emitter: emitter, eventName: eventName, callback: callback});
      emitter.on(eventName, callback);
    };

    let attachCallback = (emitter, eventName, callback) => {
      callback = run.bind(this, callback);
      bindCallback(emitter, eventName, callback);
    };

    attachCallback(tmiRoom, 'entered', (trackingData) => {
      this.set('isLoading', false);
      this.trackRoomEvent('chat_room_join', trackingData);
      this.checkFriendsWatching();
      this.addExampleWhisperMessage();
    });

    attachCallback(tmiRoom, 'connection:retry', (trackingData) => {
      this.trackRoomEvent('chat_retry_connection', trackingData);
    });

    attachCallback(tmiRoom, 'enter:failed', (trackingData) => { this.trackRoomEvent('chat_room_join_failure', trackingData); });
    attachCallback(tmiRoom, 'deleted', () => { this.set('deleted', true); });

    this._numMessagesReceived = 0;
    let isHistorical = (msg) => {
      return msg.tags && msg.tags.historical;
    };

    let messageShown = (msg) => {
      if (isHistorical(msg)) {
        // If this is a historical message, don't add it if we've already seen
        // it on the live connection. Prevents message duplication.
        // These messages arrive in reverse-chronological order, so we can look
        // for the first non-historical message we have with the same sender and
        // body which hasn't previously been marked, mark it, and skip. Working
        // middle-out essentially.
        let allMessages = this.get('messages').concat(this._queuedMessages).concat(this._queuedRawMessages);

        let existing = allMessages.filter((m) => {
          return (m.from === msg.from) && (m.message === msg.message) && !m.deduplicated && !isHistorical(m);
        });

        if (existing.length > 0) {
          existing[0].deduplicated = true;
          return true;
        }
      }
      return false;
    };

    let messageHandler = (msg) => {
      if (!messageShown(msg)) {
        this._numMessagesReceived++;
        this.onMessage(msg);
      }
    };

    bindCallback(tmiRoom, 'message', messageHandler);

    this.get('experiments').getExperimentValue('MESSAGE_HISTORY').then((value) => {
      if (!isGroupRoom && value !== "off") {
        let requestedMessageCount = MESSAGE_HISTORY_COUNT;
        // Old experiment values were "off"/"on", use defaults if we still have
        // one of those.
        // The new values will look like "10-styled" and "50-normal", the
        // first part of which is the count of messages to retrieve, and
        // the second part is whether or not to apply the style experiment.
        if (value && value.indexOf('-') !== -1) {
          let parts = value.split('-');
          requestedMessageCount = parseInt(parts[0]);
          this.set('inHistoryStyleExperiment', parts[1] === "styled");
        }
        bindCallback(tmiRoom, 'historical-message', messageHandler);

        tmiRoom.fetchHistory(requestedMessageCount).then((sentMessageCount) => {
          this.trackHistoryFilled(sentMessageCount);
        }).fail(() => {
          // TODO: Something?
        });
      }
    });

    let roomProperties = this.get('roomProperties');
    if (roomProperties) {
      let tagService = this.get('bitsTags');
      if (this.get('bitsTags').isChannelTagEnabled(roomProperties.get('_id'))) {
        let bitsHandler = (msg) => {
          if(msg.tags && msg.tags.bits && !messageShown(msg)) {
            tagService.parseHashtags(msg);
          }
        };
        bindCallback(tmiRoom, 'message', bitsHandler);
      }
    }

    attachCallback(tmiRoom, 'usernotice', (msg) => { this.addMessage(msg); });
    attachCallback(tmiRoom, 'message-sent', (msg) => { this.trackMessage(msg); });
    attachCallback(tmiRoom, 'notice', (notice) => { this.addNotification(notice); });
    attachCallback(tmiRoom, 'clearchat', (clearedUser, tags) => { this.clearMessages(clearedUser, tags); });
    attachCallback(tmiRoom, 'slow', () => { this.set('slowMode', true); });
    attachCallback(tmiRoom, 'slowoff', () => { this.set('slowMode', false); });
    attachCallback(tmiRoom, 'flashtimedout', () => { this.set('flashTimedOut', true); });
    attachCallback(tmiRoom, 'host_target', (obj) => { this.setHostMode(obj); });
    attachCallback(tmiRoom, 'roomstate', (obj) => { this.updateRoomState(obj); });

    if (this.get('isEmbedChat')) {
      let whispersShim = this.get('whispersShim');
      whispersShim.setupService().then(() => {
        if (this.isDestroyed) { return; }
        whispersShim.on('whisper', (rawMessage) => {
          let tmiMessage = convertToTmiSchema(rawMessage);
          this.addMessage(tmiMessage);
        });
      }, (error) => {
        if (error && error.status === 401) {
          // This is an known error state meaning the user is not logged in.
          return;
        }
        throw error;
      });
    }

    if (!isGroupRoom) {
      let pubsub = this.get('pubsub');
      pubsub.setupService(tmiRoom.ownerId, () => {
        if (this.isDestroyed) { return; }
        pubsub.on('message_risk', (rawMessage) => {
          this.addRiskUI(rawMessage);
        });
        pubsub.on('chat_login_moderation', (rawMessage) => {
          this.addLoginModerationMessage(rawMessage);
        });
        pubsub.on('chat_channel_moderation', (rawMessage) => {
          this.addChannelModerationMessage(rawMessage);
        });
        pubsub.on('twitchbot_message_approval', (rawMessage) => {
          this.addTwitchBotApproval(rawMessage);
        });
        pubsub.on('twitchbot_message_denial', (rawMessage) => {
          this.addTwitchBotDenial(rawMessage);
        });
      });
    }

    attachCallback(tmiSession, 'notice', (notice) => { this.addNotification(notice); });
    this.checkForHostMode();

    if (Twitch.user.isLoggedIn()) {
      attachCallback(tmiSession, 'colorchanged', (changedUser) => {
        if (changedUser === userName) {
          this.set('chatColor', tmiSession.getColor(changedUser));
        }
      });
      attachCallback(tmiRoom, 'badgeschanged', (changedUser) => {
        if (changedUser === userName) {
          // should be: this.set('chatBadges', tmiRoom.getBadges(changedUser));
          // but that returns the old badges format for compatibility, directly access the store to get the new format
          this.set('chatBadges', tmiRoom._roomUserBadges[changedUser]);
        }
      });
      attachCallback(tmiRoom, 'labelschanged', (changedUser) => {
        if (changedUser === userName) {
          this.set('chatLabels', tmiRoom.getLabels(changedUser));
        }
      });
      attachCallback(tmiSession, 'labelschanged', (changedUser) => {
        if (changedUser === userName) {
          // tmiRoom always contains a superset of tmiSession's labels,
          // so we'll always get the label set from tmiRoom to stay in sync.
          this.set('chatLabels', tmiRoom.getLabels(changedUser));
        }
      });
      attachCallback(tmiRoom, 'changed', () => {
        tmiSession.getRoom(id).done(function () {
          this.notifyPropertyChange('tmiRoom');
        });
      });
    }

    if (!isGroupRoom || tmiRoom.isOwner || tmiRoom.acceptedInvite) {
      tmiRoom.enter();
    }
  },

  addChannelModerationMessage(rawMessage) {
    if ((rawMessage.created_by === this.get('session.userData.login')) || this.get('isGroupRoom')) {
      return;
    }
    if (rawMessage.args) {
      this.addTmiModerationMessage(`${rawMessage.created_by} ${i18n('used')} ${rawMessage.moderation_action} ${rawMessage.args}`);
    } else {
      this.addTmiModerationMessage(`${rawMessage.created_by} ${i18n('used')} ${rawMessage.moderation_action}`);
    }
  },

  addTwitchBotApproval(rawMessage) {
    this.addTwitchBotMessageModAction(rawMessage.msg_id, "automod", `${i18n('Mods have allowed your message. Happy chatting')}! VoHiYo`);
  },

  addTwitchBotDenial(rawMessage) {
    this.addTwitchBotMessageModAction(rawMessage.msg_id, "automod", `${i18n('Mods have removed your message')}.`);
    this.removeMessageWithID(rawMessage.msg_id);
  },

  addLoginModerationMessage(rawMessage) {
    let targetLogin = "";
    if (rawMessage.args) {
      targetLogin = rawMessage.args[0];
    }

    if (!this.get('isModeratorOrBroadcaster')) {
      return;
    }

    if (rawMessage.moderation_action === "twitchbot_rejected") {
      this.addTwitchBotRejectedMessage(rawMessage.msg_id, targetLogin, rawMessage.args[1]);
      return;
    }

    if (rawMessage.moderation_action === "approved_twitchbot_message" && rawMessage.created_by !== Twitch.user.login()) {
      this.addTmiModerationMessage(`${rawMessage.created_by} ${i18n('allowed message')} from ${targetLogin}.`);
      this.removeMessageWithID(rawMessage.msg_id);
      return;
    }

    if (rawMessage.moderation_action === "denied_twitchbot_message" && rawMessage.created_by !== Twitch.user.login()) {
      this.addTmiModerationMessage(`${rawMessage.created_by} ${i18n('denied message')} from ${targetLogin}.`);
      this.removeMessageWithID(rawMessage.msg_id);
      return;
    }

    let banDuration = "";
    if (rawMessage.moderation_action === "timeout" && !isNaN(rawMessage.args[1])) {
      banDuration = rawMessage.args[1];
    } else if (rawMessage.moderation_action === "timeout") {
      banDuration = "600";
    }

    let banReason = "";
    if (banDuration && rawMessage.args.length > 2) {
      banReason = rawMessage.args.slice(2, rawMessage.args.length);
    } else if (!banDuration && rawMessage.args.length > 1) {
      banReason = rawMessage.args.slice(1, rawMessage.args.length);
    }

    let alreadyMessagedReason = false;
    if (userTimeouts[targetLogin]) {
      alreadyMessagedReason = true;
    }

    if (!this.get('isGroupRoom')) {
      userTimeouts[targetLogin] = {'ban-time': Math.floor(Date.now() / 1000)};
    }

    if (alreadyMessagedReason || this.get('isGroupRoom') || (rawMessage.created_by === Twitch.user.login())) {
      return;
    }

    if (rawMessage.moderation_action === "timeout" && banReason) {
      this.addTmiModerationMessage(`${rawMessage.created_by} ${i18n('timed out')} ${targetLogin} ${i18n('for')} ${banDuration} ${i18n('seconds')}. ${i18n('Reason')}: ${banReason}.`);
    } else if (rawMessage.moderation_action === "timeout" && !banReason) {
      this.addTmiModerationMessage(`${rawMessage.created_by} ${i18n('timed out')} ${targetLogin} ${i18n('for')} ${banDuration} ${i18n('seconds')}.`);
    } else  if (rawMessage.moderation_action === "mod") {
      this.addTmiModerationMessage(`${rawMessage.created_by} ${i18n('modded')} ${targetLogin}.`);
    } else if (rawMessage.moderation_action === "unmod") {
      this.addTmiModerationMessage(`${rawMessage.created_by} ${i18n('unmodded')} ${targetLogin}.`);
    } else if (rawMessage.moderation_action === "unban") {
      this.addTmiModerationMessage(`${rawMessage.created_by} ${i18n('unbanned')} ${targetLogin}.`);
    } else if (rawMessage.moderation_action === "ban" && banReason) {
      this.addTmiModerationMessage(`${rawMessage.created_by} ${i18n('banned')} ${targetLogin}. ${i18n('Reason')}: ${banReason}`);
    } else if (rawMessage.moderation_action === "ban" && !banReason) {
      this.addTmiModerationMessage(`${rawMessage.created_by} ${i18n('banned')} ${targetLogin}.`);
    } else if (rawMessage.moderation_action === "recent_cheer_dismissal") {
      this.addTmiModerationMessage(`${rawMessage.created_by} ${i18n('dismissed a recent cheer by')} ${targetLogin}`);
    } else if (rawMessage.moderation_action === "top_cheer_reset") {
      this.addTmiModerationMessage(`${rawMessage.created_by} ${i18n('reset the top cheer by')} ${targetLogin}`);
    }

  },

  addFriendsWatchingMessage(message) {
    let msgObject = {
      style: 'admin',
      message: message,
      tags: {
        emotes: tmiEmotes(this.get("userEmotes").tryParseEmotes(message))
      }
    };
    this.addMessage(msgObject);
  },

  checkFriendsWatching() {
    let friends = this.friendsInSameRoom(this.get('friends.watchingFriendsOnline'))
      .map(friend => {
        return formatDisplayName(friend.id, friend.displayName, true);
      });
    let friendLoginsForTracking = this.friendsInSameRoom(this.get('friends.watchingFriendsOnline'))
      .map(friend => {
        return friend.id;
      }).slice(0, 5);

    let trackingObj = {
      login: Twitch.user.login(),
      platform: 'web',
      channel: this.get('id'),
      game: this.get('roomProperties.game'),
      alert_type: 'is_joining',
      num_friends_already_present: friendLoginsForTracking.length,
      friend_logins: friendLoginsForTracking
    };

    if (friends.length === 1) { // One friend watching
      let message = i18n('Your friend ${friend} is also watching! VoHiYo')
        .replace('${friend}', friends);
      this.addFriendsWatchingMessage(message);
    } else if (friends.length === 2) { // Two friends watching
      let lastFriend = friends.pop();
      let firstFriend = friends;
      let message = i18n('Your friends ${friend1} and ${friend2} are also watching! VoHiYo')
        .replace('${friend1}', firstFriend)
        .replace('${friend2}', lastFriend);
      this.addFriendsWatchingMessage(message);

    } else if (friends.length >= 3) { // Three or more friends watching
      let firstFriend = friends.shift();
      let lastFriend = friends.pop();
      let joinedFriends = friends.join(', ');
      let message = i18n('Your friends ${friend1}, [...], and ${friendN} are also watching! VoHiYo')
        .replace('${friend1}', firstFriend)
        .replace('[...]', joinedFriends)
        .replace('${friendN}', lastFriend);
      this.addFriendsWatchingMessage(message);

    }

    if (friends.length > 0) {
      let getNumViewers = this._getNumberOfViewers();
      getNumViewers.then((numViewers) => {
        if (this.isDestroyed) { return; }
        trackingObj.num_total_present = numViewers;
        this.get('tracking').trackEvent({
          event: 'chat_copresent_friends_alert',
          data: trackingObj
        });
      });
    }
    this.set('sentFriendsWatchingMessage', true);
  },

  friendsInSameRoom(friendsList) {
    let results = [];

    friendsList.forEach(friend => {
      if (friend.get('login') && friend.get('activities')[0].channel_login === this.get('roomProperties.id')) {
        results.push({
          id: friend.get('login'),
          displayName: friend.get('displayName'),
          whispered: false
        });

        let trackFriendsInChannel = this.get('trackFriendsInChannel');
        if (!trackFriendsInChannel[friend.get('login')] && this.get('sentFriendsWatchingMessage')) {

          let friendFormatted = formatDisplayName(friend.get('login'), friend.get('displayName'), true);
          let message = i18n('Your friend ${friend} has just started watching! HeyGuys')
            .replace('${friend}', friendFormatted);
          this.set('loginNeedsTracking', friend.get('login')); // Used for tracking the count so we can use results.length-1
          this.addFriendsWatchingMessage(message);
        }
        trackFriendsInChannel[friend.get('login')] = true;
      }
    });

    if (this.get('loginNeedsTracking')) {
      let trackingObj = {
        login: Twitch.user.login(),
        platform: 'web',
        channel: this.get('id'),
        game: this.get('roomProperties.game'),
        alert_type: 'is_joined',
        num_friends_already_present: results.length-1 > -1 ? results.length : 0,
        friend_login: this.get('loginNeedsTracking')
      };

      let getNumViewers = this._getNumberOfViewers();
      getNumViewers.then((numViewers) => {
        if (this.isDestroyed) { return; }
        trackingObj.num_total_present = numViewers;
        this.get('tracking').trackEvent({
          event: 'chat_copresent_friends_alert',
          data: trackingObj
        });
      });
      this.set('needsTracking', null);
    }
    return results;
  },

  _throttleFlushRawMessages() {
    this._requestedFlushRawMessages = true;
    run.schedule('afterRender', this, () => {
      if (this._requestedFlushRawMessages) {
        this._requestedFlushRawMessages = false;
        this._flushRawMessageTimeout = run.later(this, this.flushRawMessages, BUFFER_FLUSH_DELAY_MS);
      }
    });
  },

  lazyFlushRawMessages() {
    if (!this._flushRawMessageTimeout && !this._requestedFlushRawMessages) {
      this.flushRawMessages();
    }
  },

  flushRawMessages() {
    if (this._flushRawMessageTimeout) {
      run.cancel(this._flushRawMessageTimeout);
      this._flushRawMessageTimeout = null;
    }

    run.join(() => {
      let queuedMessages = this._queuedRawMessages;

      if (queuedMessages.length !== 0) {
        for (let i=0;i<queuedMessages.length;i++) {
          this.addMessage(queuedMessages[i]);
        }
        queuedMessages.length = 0;
        this._throttleFlushRawMessages();
      }
    });
  },

  onMessage(msg) {
    if (this.get('inHistoryStyleExperiment') === true) {
      msg.inHistoryStyleExperiment = true;
    }

    this._queuedRawMessages.push(msg);
    if (this._numMessagesReceived === 1) {
      this.flushRawMessages();
    } else {
      this.lazyFlushRawMessages();
    }
  },

  willDestroy() {
    this._super();
    let tmiRoom = this.get('tmiRoom');

    _.each(this._callbacks, function (obj) {
      obj.emitter.off(obj.eventName, obj.callback);
    });

    if (this._flushRawMessageTimeout) {
      clearTimeout(this._flushRawMessageTimeout);
    }

    if (this._banTimeout) {
      clearTimeout(this._banTimeout);
    }

    this.get('pubsub').tearDownService();

    if (tmiRoom) {
      tmiRoom.exit();
    }

    roomModel.destroy(this.get('id'));
    if (this.pendingFetchHostModeTarget) {
      run.cancel(this.pendingFetchHostModeTarget);
    }
  },

  /** View Bindings */
  savedInput: computed('id', {
    get() {
      let savedInput = Twitch.storage.getObject('savedInput');
      if (savedInput && savedInput.roomId === this.get('id')) {
        return savedInput.message;
      }
      return '';
    },
    set(key, value) {
      if (value) {
        Twitch.storage.setObject('savedInput', {
          roomId: this.get('id'),
          message: value
        });
      } else {
        Twitch.storage.del('savedInput');
      }
    }
  }),

  isWhisperMessage: computed('messageToSend', function () {
    return WHISPERS_REGEX.test(this.get('messageToSend'));
  }),

  suggestionSortProperties: computed('isWhisperMessage', function () {
    return this.get('isWhisperMessage') ? ['whispered:desc', 'timestamp:desc'] : ['id'];
  }),

  _watchingFriends: observer('friends.watchingFriendsOnline.[]', function() {
    this.friendsInSameRoom(this.get('friends.watchingFriendsOnline'));
  }),

  friendsList: computed('friends.watchingFriendsOnline.[]', function() {
    return this.friendsInSameRoom(this.get('friends.watchingFriendsOnline'));
  }),

  chatSuggestions: computed('chatterList.[]', 'friendsList.[]', function() {
    let result = [];
    let namesObject = {};
    this.get('chatterList')
        .concat(this.get('fixedList'), this.get('friendsList'))
        .forEach(user => {
          if (!namesObject[user.id]) {
            namesObject[user.id] = true;
            result.push(user);
          }
        });
    return result;
  }),

  chatHashtagSuggestions: computed('bitsTags.allTagNames', function() {
    let allTagNames = this.get('bitsTags.allTagNames');
    return allTagNames.map((name) => {
      return {
        displayName: name,
        id: name,
        whispered: false
      };
    });
  }),

  name: computed('tmiRoom', function () {
    let tmiRoom = this.get('tmiRoom');
    if (tmiRoom) {
      return tmiRoom.displayName || tmiRoom.name;
    }
  }),

  inviter: computed('tmiRoom', function () {
    let tmiRoom = this.get('tmiRoom');
    if (tmiRoom) {
      return tmiRoom.inviter;
    }
  }),

  publicInvitesEnabled: computed('tmiRoom', function () {
    let tmiRoom = this.get('tmiRoom');
    if (tmiRoom) {
      return tmiRoom.publicInvitesEnabled;
    }
  }),

  _watchPublicInvitesEnabled: observer('publicInvitesEnabled', function () {
    let tmiRoom = this.get('tmiRoom');
    if (tmiRoom) {
      return tmiRoom.setPublicInvitesEnabled(this.get('publicInvitesEnabled'));
    }
  }),

  invitationAccepted: computed('tmiRoom', function () {
    let tmiRoom = this.get('tmiRoom');
    if (tmiRoom) {
      return tmiRoom.isGroupRoom && (tmiRoom.isOwner || tmiRoom.acceptedInvite);
    }
  }),

  invitationPending: computed('tmiRoom', function () {
    let tmiRoom = this.get('tmiRoom');
    if (tmiRoom) {
      return tmiRoom.isGroupRoom && (!tmiRoom.isOwner && !tmiRoom.acceptedInvite);
    }
  }),

  isOwner: computed('tmiRoom', function () {
    let tmiRoom = this.get('tmiRoom');
    if (tmiRoom) {
      return tmiRoom.isOwner;
    }
  }),

  canInvite: computed('tmiRoom', 'isModerator', function () {
    let tmiRoom = this.get('tmiRoom'),
        isModerator = this.get('isModerator');
    if (tmiRoom) {
      return tmiRoom.isGroupRoom && (tmiRoom.isOwner || isModerator || tmiRoom.publicInvitesEnabled);
    }
  }),

  isTwitchBotEnabled: computed(function () {
    return this.get('roomProperties.twitchbot_rule_id') > 0;
  }),

  isBroadcaster: computed('id', function () {
    return this.get('id') === Twitch.user.login();
  }),

  isStaff: computed('chatLabels.[]', function () {
    return (this.get('chatLabels') || []).indexOf('staff') >= 0;
  }),

  isAdmin: computed('chatLabels.[]', function () {
    return (this.get('chatLabels') || []).indexOf('admin') >= 0;
  }),

  isSubscriber: computed('chatLabels.[]', function () {
    return (this.get('chatLabels') || []).indexOf('subscriber') >= 0;
  }),

  isModerator: computed('chatLabels.[]', function () {
    return (this.get('chatLabels') || []).indexOf('mod') >= 0;
  }),

  isGlobalMod: computed('chatLabels.[]', function () {
    return (this.get('chatLabels') || []).indexOf('global_mod') >= 0;
  }),

  isModeratorOrHigher: computed('isBroadcaster', 'isStaff', 'isAdmin', 'isModerator', 'isOwner', 'isGlobalMod', function () {
    return !!(this.get('isBroadcaster') || this.get('isStaff') || this.get('isAdmin') ||
      this.get('isModerator') || this.get('isOwner') || this.get('isGlobalMod'));
  }),

  isModeratorOrBroadcaster: computed('isBroadcaster', 'isModerator', function () {
    return (this.get('isBroadcaster') || this.get('isModerator'));
  }),

  isTwitchPrivilegedUser: computed('isStaff', 'isAdmin', 'isGlobalMod', function () {
    return !!(this.get('isStaff') || this.get('isAdmin') || this.get('isGlobalMod'));
  }),

  userStatus: computed('isBroadcaster', 'isStaff', 'isAdmin', 'isModerator', 'isGlobalMod', function () {
    if (this.get('isBroadcaster')) {
      return "broadcaster";
    } else if (this.get('isStaff')) {
      return "staff";
    } else if (this.get('isAdmin')) {
      return "admin";
    } else if (this.get('isModerator')) {
      return "moderator";
    } else if (this.get('isGlobalMod')) {
      return "global_mod";
    }
    return null;
  }),

  /** Public Functions */
  checkForHostMode() {
    let tmiRoom = this.get('tmiRoom');
    if (!this.get('isGroupRoom') && tmiRoom) {
      tmiRoom.hostTarget({useDeprecatedResponseFormat:true}).then((obj) => {
        /* The object we pass into setHostMode is the same as the one we would get from TMI on event host_target. */
        this.setHostMode({hostTarget: obj.host_target, numViewers: null, recentlyJoined: true});
      });
    }
  },

  isFromOtherUser(name) {
    return name &&
      name !== 'jtv' &&
      name !== 'twitchnotify' &&
      name !== this.get('tmiSession.nickname') &&
      name !== this.get('session.userData.login');
  },

  resetUnreadCount() {
    this.set('unreadCount', 0);
  },

  del() {
    let tmiRoom = this.get('tmiRoom');
    if (tmiRoom) {
      if (tmiRoom.isOwner) {
        return wrap(() => tmiRoom.del());
      }
      return tmiRoom.rejectInvite();
    }
  },

  invite(user) {
    let tmiRoom = this.get('tmiRoom');

    if (tmiRoom) {
      return wrap(() => tmiRoom.invite(user).done(() => {
        this.trackInviteSent();
      }));
    }
  },

  enter() {
    let tmiRoom = this.get('tmiRoom');
    if (tmiRoom) {
      return tmiRoom.enter();
    }
  },

  exit() {
    let tmiRoom = this.get('tmiRoom');
    if (tmiRoom) {
      return tmiRoom.exit();
    }
  },

  acceptInvite() {
    let tmiRoom = this.get('tmiRoom');
    if (tmiRoom) {
      return wrap(() => tmiRoom.acceptInvite()).then(() => {
        tmiRoom.enter();
        this.notifyPropertyChange('tmiRoom'); // recompute invitationAccepted and invitationPending
      });
    }
  },

  rejectInvite() {
    let tmiRoom = this.get('tmiRoom');
    if (tmiRoom) {
      return wrap(() => tmiRoom.rejectInvite());
    }
  },

  send(msg) {
    if (WHISPERS_REGEX.test(msg)) {
      let [, to, ...messageParts] = msg.split(' ');

      // Prevent blank messages
      let message = messageParts.join(' ');
      if (/^\s*$/.test(message)) {
        return;
      }

      let handleError = (error) => {
        let errorMessage = WHISPER_NOTICES[error];
        if (errorMessage) {
          this.addTmiMessage(errorMessage);
        }
      };

      if (to && to.toLowerCase() === Twitch.user.login()) {
        this.addTmiMessage(WHISPER_NOTICES.whisper_invalid_self);
        this._resetInputs();
        return;
      }

      let options = {
        toLogin: to,
        nonce: nonce(),
        // BTTV overwrites our templates with its own template so the render
        // metric will never succeed :(
        disableRenderMetric: typeof BetterTTV === "object" && this.get('isEmbedChat'),
        isEmbedChat: this.get('isEmbedChat')
      };

      let whispersShim = this.get("whispersShim");
      whispersShim.publishMessage(message, options).catch((err) => {
        if (this.isDestroyed) { return; }
        let { jqXHR } = err;
        if (jqXHR && jqXHR.responseJSON && !jqXHR.responseJSON.authorized) {
          handleError(jqXHR.responseJSON.error);
        } else {
          throw err;
        }
      });

      if (this.get('isEmbedChat')) {
        // Local render in embedded chat
        let msgObject = {
          style: "whisper",
          from: Twitch.user.login(),
          to: to,
          message: message,
          tags: {
            emotes: tmiEmotes(this.get("userEmotes").tryParseEmotes(message))
          },
          date: new Date(),
          nonce: options.nonce
        };

        this.addMessage(msgObject);
      } else {
        // Trigger an event on the whispersShim instead of calling the conversations
        // service directly. We don't want to include the conversations service in
        // the chat popout build
        whispersShim.triggerChatWhisper({
          body: message,
          toLogin: to,
          nonce: options.nonce
        });
      }
    } else {
      let tmiRoom = this.get('tmiRoom');
      if (msg && tmiRoom) {
        tmiRoom.sendMessage(msg);

        // Track each one of the mentions in the sent message
        let tokens = [msg],
            user = Twitch.user.login(),
            userDisplayName = Twitch.user.displayName(),
            isOwnMessage = true,
            room = this;
        tokens = mentionizeMessage(tokens, user, isOwnMessage);
        tokens = mentionizeMessage(tokens, userDisplayName, isOwnMessage);
        tokens.forEach(function(token) {
          if (token.type === 'mention') {
            let mentionedUser = token.user.split("@").pop();
            if (user !== mentionedUser) {
              room.trackMention(user, mentionedUser);
            }
          }
        });

        // Track host mode
        let matches,
            target,
            channel = this.get('channel'),
            HOST_REGEX = /host +(\w+)/g;
        if (channel && msg.match(HOST_REGEX)) {
          matches = HOST_REGEX.exec(msg) || [];
          target = matches[1];
          window.setTimeout(() => {
            run(() => {
              this.trackHostModeStart(target);
            });
          });
        }
      }
    }

    this._resetInputs();
  },

  _resetInputs() {
    this.set('messageToSend', '');
    this.set('savedInput', '');
  },

  /** Private Helper Functions */
  trackMention(user, mentionedUser) {
    this.get('tracking').trackEvent({
      event: 'chat_mention_used',
      services: ['spade'],
      data: assign({
        channel: this.get('id'),
        device_id: Twitch.idsForMixpanel.getOrCreateUniqueId(),
        user: user,
        mentioned_user_display_name: mentionedUser
      }, this.getTrackingData())
    });
  },

  trackHostModeStart(target) {
    let isGroupRoom = this.get('isGroupRoom');
    if (!isGroupRoom) {
      let getNumViewers = this._getNumberOfViewers();
      getNumViewers.then((numViewers) => {
        if (this.isDestroyed) { return; }
        this.get('tracking').trackEvent({
          event: 'channel_host_mode_start',
          data: assign({
            target_channel: target,
            num_viewers: numViewers
          }, this.getTrackingData())
        });
      });
    }
  },

  _getNumberOfViewers() {
    return new RSVP.Promise((resolve) => {
      this.tmiRoom.list()
      .done(function (response) {
        if (response && response.data && response.data.chatter_count !== undefined) {
          run(null, resolve, response.data.chatter_count);
        } else {
          run(null, resolve);
        }
      })
      .fail(function () {
        run(null, resolve);
      });
    });
  },

  trackChat() {
    let isGroupRoom = this.get('isGroupRoom');
    let sendMixpanelEvent = () => {
      let { isAuthenticated, userData } = this.get('session');
      if (isAuthenticated) {
        this.get('tracking').trackEvent({
          event: 'chat',
          data: assign({
            /** 'b' refers to adblock, intentionally obfuscated */
            b: Twitch.storage.get('b', {storage: 'sessionStorage'}) === 'true',
            player: this.get('isEmbedChat') ? 'embed' : 'web',
            user_account_is_verified: userData.account_verified,
            /** the keys below are undefined for group chat */
            account_verification_required: this.get('roomProperties.require_verified_account'),
            game: this.get('roomProperties.game'),
            sub_only_mode: this.get('roomProperties.subsonly'),
            preferred_language: Twitch.preferredLanguage,
            received_language: Twitch.receivedLanguage
          }, this.getTrackingData())
        });
      }
    };
    if (isGroupRoom) {
      sendMixpanelEvent();
    } else {
      this.waitForRoomProperties().then(sendMixpanelEvent);
    }
  },

  trackGoldenKappa(msgObject) {
    if (msgObject.labels.indexOf('golden-kappa') > -1) {
      let isGroupRoom = this.get('isGroupRoom');
      let sendMixpanelEvent = () => {
        let { isAuthenticated, userData } = this.get('session');
        if (isAuthenticated) {
          this.get('tracking').trackEvent({
            event: 'golden_kappa_chat',
            data: assign({
              /** 'b' refers to adblock, intentionally obfuscated */
              b: Twitch.storage.get('b', {storage: 'sessionStorage'}) === 'true',
              user_account_is_verified: userData.account_verified,
              /** the keys below are undefined for group chat */
              account_verification_required: this.get('roomProperties.require_verified_account'),
              game: this.get('roomProperties.game'),
              sub_only_mode: this.get('roomProperties.subsonly'),
              preferred_language: Twitch.preferredLanguage,
              received_language: Twitch.receivedLanguage,
              golden_kappa_used: GOLDEN_KAPPA_EMOTE in msgObject.tags.emotes
            }, this.getTrackingData())
          });
        }
      };
      if (isGroupRoom) {
        sendMixpanelEvent();
      } else {
        this.waitForRoomProperties().then(sendMixpanelEvent);
      }
    }
  },

  trackWhisper(msgObject) {
    if (msgObject.style !== 'whisper' || msgObject.doNotTrack) {
      return;
    }

    this.waitForRoomProperties().then(() => {
      if (this.isDestroyed) { return; }
      let { isAuthenticated, userData } = this.get('session');
      if (isAuthenticated) {
        let eventName = (msgObject.from === userData.login) ? 'whisper' : 'whisper_received';
        this.get('tracking').trackEvent({
          event: eventName,
          data: assign({
            to: msgObject.to,
            from: msgObject.from,
            player: this.get('isEmbedChat') ? 'embed' : 'web',
            game: this.get('roomProperties.game'),
            is_turbo: !!userData.has_turbo,
            is_subscriber: this.get('isSubscriber'),
            is_channel_partner: userData.login === this.get('id') && userData.is_partner
          }, this.getTrackingData())
        });
      }
    });
  },

  trackInviteSent() {
    let { isAuthenticated, userData } = this.get('session');
    if (isAuthenticated) {
      this.get('tracking').trackEvent({
        event: 'chat-invite-sent',
        data: {
          channel: this.get('id'),
          turbo: !!userData.has_turbo,
          user_account_is_verified: userData.account_verified
        }
      });
    }
  },

  trackSubOnly(msgObject) {
    this.waitForRoomProperties().then(() => {
      if (this.isDestroyed) { return; }
      if (this.get('session').isAuthenticated && msgObject.message.match(/^\/subscribers(off)?/i)) {
        let evt = msgObject.message.match(/^\/subscribersoff/i) ? "sub-only-chat-disable" : "sub-only-chat-enable";
        this.get('tracking').trackEvent({
          event: evt,
          services: ['mixpanel'],
          data: {
            channel: this.get('id'),
            game: this.get('roomProperties.game'),
            disabled_via: 'chat_command',
            user_status: this.get('userStatus'),
            sub_only_mode: this.get('roomProperties.subsonly')
          }
        });
      }
    });
  },

  trackRoomEvent(event, data) {
    data = assign({}, data);
    data = assign(data, this.getTrackingData());
    this.get('tracking').trackEvent({event, data});
  },

  trackHistoryFilled: function (messageCount) {
    let event = 'chat_history_filled';
    let data = {
      channel:      this.get('id'),
      device_id:    Twitch.idsForMixpanel.getOrCreateUniqueId(),
      login:        this.get('session.userData.login'),
      logged_in:    this.get('session.isAuthenticated'),
      time:         new Date().getTime(),
      num_messages: messageCount
    };

    this.get('tracking').trackEvent({event, data});
  },

  getTrackingData() {
    return {
      channel: this.get('id'),
      is_using_web_sockets: TMI.usingWebSockets(),
      room_type: this.get('isGroupRoom') ? 'group' : 'public',
      is_host_mode: this.get('isHostMode')
    };
  },

  waitForRoomProperties() {
    return new RSVP.Promise((resolve) => {
      if (this.get('roomProperties.isLoaded') || this.get('isGroupRoom')) {
        resolve();
      } else {
        this.addObserver('roomProperties.isLoading', () => {
          if (this.get('roomProperties.isLoaded')) {
            resolve();
          }
        });
      }
    });
  },

  addRiskUI(riskMessage) {
    if (!this.get('isStaff') && !this.get('isGlobalMod') && !this.get('isAdmin')) {
      return;
    }
    this.flushMessages();
    this.get('messages').forEach(message => {
       if (message.tags) {
         if (message.tags.id === riskMessage["msg_id"] && riskMessage["risk"] === "high") {
           set(message, 'flaggedForReview', true);
         }
       }
    });
  },

  removeMessageWithID(msgID) {
    this.lazyFlushMessages();

    this.get('delayedMessages').forEach((message, i) => {
      if (message.tags && message.tags.id === msgID) {
        this.get('delayedMessages').removeAt(i);
        return;
      }
    });

    this.get('messages').forEach((message, i) => {
      if (message.tags && message.tags.id === msgID) {
        this.get('messages').removeAt(i);
        return;
      }
    });
  },

  clearMessages(username,tags) {
    if (!username) {
      this.flushMessages();
      this.set('messages', []);
      this.set('delayedMessages', []);
      this.addTmiMessage(i18n('Chat was cleared by a moderator'));
      return;
    }

    let alreadyMessagedReason = false;
    if (userTimeouts[username.toString()]) {
      alreadyMessagedReason = true;
    }

    if (!this.get('isGroupRoom')) {
      userTimeouts[username.toString()] = tags;
      userTimeouts[username.toString()]['ban-time'] = Math.floor(Date.now() / 1000);
    }

    // pushing to this queue will result in messages from this user being removed in the next flushMessage() call
    this._clearedUsers.push(username);
    this.lazyFlushMessages();

    if (!this.get('isModeratorOrHigher') && this.get('roomProperties.chat_delay_duration') > 0) {
      this.get('delayedMessages').forEach((message, i) => {
        if (message.from === username) {
          this.get('delayedMessages').removeAt(i);
        }
      });
    }

    if (!alreadyMessagedReason && !this.get('isGroupRoom') && !this.get('isModeratorOrBroadcaster')) {
      if (username === this.get('tmiSession.nickname') || this.get('isModeratorOrHigher') === true) {
        if (tags['ban-duration']) {
          if (tags['ban-reason']) {
            this.addTmiMessage(`${username} ${i18n('has been timed out for')} ${tags['ban-duration']} ${i18n('seconds')}. ${i18n('Reason')}: ${tags['ban-reason']}.`);
          } else {
            this.addTmiMessage(`${username} ${i18n('has been timed out for')} ${tags['ban-duration']} ${i18n('seconds')}.`);
          }
        } else {
          if (tags['ban-reason']) {
            this.addTmiMessage(`${username} ${i18n('is now banned from this room')}. ${i18n('Reason')}: ${tags['ban-reason']}`);
          } else {
            this.addTmiMessage(`${username} ${i18n('is now banned from this room')}.`);
          }
        }
      }
    }
  },

  setHostMode(obj) {
    let globals = this.get('globals'),
        enableHostMode = globals.get('enableHostMode');

    if (enableHostMode !== true) { return; }

    const TARGET_TRANSITIONS_PER_MILLISECOND = 0.5;
    const USHER_PREPARE_TIME = 3000;
    const MINIMUM_JITTER_TIME = 4000;
    let channel = this.get('channel');
    let jitterInterval = 0;
    let delay = 0;

    if (channel) {
      if (!obj.recentlyJoined) {
        jitterInterval = Math.max((obj.numViewers || 0) / TARGET_TRANSITIONS_PER_MILLISECOND, MINIMUM_JITTER_TIME);
        delay = USHER_PREPARE_TIME + Math.floor((jitterInterval || 0) * Math.random());
      }

      // Update dashboard notification with hosting status
      sendHostTargetNotification(obj.hostTarget);

      let currentChannel = this.get('store').peekRecord('channel', channel.get('name'));

      if (!currentChannel) {
        console.error('Unable to find current channel in store when setting host mode.');
        return;
      }

      if (obj.hostTarget) {
        let targetName = obj.hostTarget.toLowerCase();
        this.pendingFetchHostModeTarget = run.debounce(this, 'fetchHostModeTarget', {currentChannel, targetName}, delay);
      } else {
        currentChannel.set('hostModeTarget', null);
      }
    }
  },

  fetchHostModeTarget({ currentChannel, targetName }) {
    if (this.isDestroyed) { return; }

    this.get('store').findRecord('channel', targetName).then(targetModel => {
      if (this.isDestroyed) { return; }
      currentChannel.set('hostModeTarget', targetModel);
    });
  },

  isHostMode: computed('channel.isHostMode', function () {
    return !!this.get('channel.isHostMode');
  }),

  addChatter(msgObject) {
    let name = msgObject.from;
    if (
      msgObject.doNotTrack ||
      !this.isFromOtherUser(name) ||
      name === this.get('id') ||
      this.get('suggestionsDisabled')
    ) {
      return;
    }

    let loweredName = name.toLowerCase();
    let oldChatterList = this.get('chatterList');

    let newChatter = {
      id: loweredName,
      displayName: msgObject.tags['display-name'] || capitalize(name),
      whispered: msgObject.style === 'whisper',
      timestamp: new Date()
    };

    let newChatterList = [newChatter];

    /*
     * chatterList is maintained as newest to oldest entries. Add items to the
     * new list filtering out any chatter record matching the new one the
     * list was initialized with.
     *
     * If we don't remove a matching record, then remove the last (oldest)
     * chatter record.
     */
    let foundOldRecord = false;
    for (let i=0;i<oldChatterList.length;i++) {
      let chatter = oldChatterList[i];
      if (chatter.id === loweredName) {
        foundOldRecord = true;
      } else if (i < MAX_CHATTERS_LENGTH) {
        newChatterList.push(chatter);
      } else {
        if (foundOldRecord) {
          newChatterList.push(chatter);
        }
        break;
      }
    }

    this.set('chatterList', newChatterList);
  },

  pushMessage(msgObject) {
    this._queuedMessages.push(msgObject);
    this.lazyFlushMessages();
  },

  lazyFlushMessages() {
    this._requestedFlushMessages = true;
    run.schedule('actions', () => {
      if (this._requestedFlushMessages) {
        this._requestedFlushMessages = false;
        this.flushMessages();
      }
    });
  },

  flushMessages() {
    let queuedMessages = this._queuedMessages;
    let unreadIncrease = 0;
    let messagesToAdd = [];

    // if room delay is non-zero, and a user is not a message, entirely remove a message. otherwise they will be shown as <message deleted>
    let shouldRemoveUponModeration = (!this.get('isModeratorOrHigher') && this.get('roomProperties.chat_delay_duration') > 0);

    if (queuedMessages.length) {
      for (let i=0; i<queuedMessages.length; i++) {
        let msgObject = queuedMessages[i];
        if (this.shouldShowMessage(msgObject)) {
          if (this._clearedUsers.indexOf(msgObject.from) !== -1 && shouldRemoveUponModeration) {
            msgObject.deleted = true;
          } else if (this._clearedUsers.indexOf(msgObject.from) !== -1 && !shouldRemoveUponModeration) {
            continue;
          }
          messagesToAdd.push(msgObject);
        }
        if (msgObject.style !== 'admin' && !(this.get('isGroupRoom') && msgObject.style === 'whisper')) {
          unreadIncrease++;
        }
      }
      queuedMessages.length = 0;
    }

    let oldMessages = this.get('messages');
    let messages = [];

    let index = (oldMessages.length + messagesToAdd.length > this.messageBufferSize) ? messagesToAdd.length : 0;

    for (index;index<oldMessages.length;index++) {
      let message = oldMessages[index];
      if (this._clearedUsers.indexOf(message.from) === -1) {
        messages.push(message);
      } else if (!shouldRemoveUponModeration) {
        set(message, 'deleted', true);
        messages.push(message);
      }
    }

    for (let i=0; i<messagesToAdd.length; i++) {
      if (messagesToAdd[i].tags && messagesToAdd[i].tags.historical) {
        messages.unshift(messagesToAdd[i]);
      } else {
        messages.push(messagesToAdd[i]);
      }
    }

    this.set('messages', messages);
    this._clearedUsers.length = 0;

    if (unreadIncrease) {
      this.set('unreadCount', this.get('unreadCount') + unreadIncrease);
    }

  },

  pushMessageDelayed(msgObject) {
    let limit = this.messageBufferSize;
    let count, overflowCount, i;

    if (this.shouldShowMessage(msgObject)) {
      this.get('delayedMessages').pushObject(msgObject);

      // trim the message array such that it's no bigger than messageBufferSize
      // this loop is efficient because ember knows to remove specific dom elements
      count = this.get('delayedMessages.length');
      overflowCount = count - limit;
      for (i = 0; i < overflowCount; i++) {
        this.get('delayedMessages').removeAt(0);
      }
    }
  },

  addMessage(msgObject) {
    /* For native broadcast streams sticky experiment */
    let fromNBSticky = window.location.search.substring(1) === 'ref=click_nb_sticky';
    if (fromNBSticky && msgObject.from === Twitch.user.login()) {
      this.get('tracking').trackEvent({
        event: 'send_message_from_nb_sticky',
        data: {
          channel: this.get('id'),
          device_id: Twitch.idsForMixpanel.getOrCreateUniqueId(),
          login: Twitch.user.login(),
          user_language: Twitch.receivedLanguage,
          message: msgObject.message
        }
      });
    }

    if (msgObject) {
      // use the new array format badges tag instead of the compatibility tag
      if (msgObject.tags && msgObject.tags._badges) {
        msgObject.tags.badges = msgObject.tags._badges;
      }

      if (this.tmiRoom && msgObject.from) {
        if (!msgObject.color) {
          if (msgObject.tags && msgObject.tags.color) {
            msgObject.color = msgObject.tags.color;
          } else {
            msgObject.color = this.get('tmiSession').getColor(msgObject.from.toLowerCase());
          }
        }
        msgObject.labels = this.tmiRoom.getLabels(msgObject.from);
      }

      if (msgObject.style === 'whisper') {
        this.addWhisper(msgObject);
        return;
      }

      if (msgObject.style === 'admin') {
        msgObject.message = i18n(msgObject.message);
      } else {
        // Construct content tag for Clips. This process will likely live in TMI in the future
        let clipContent = extractFirstClipInfo(msgObject.message);
        if (clipContent.slug) {
          msgObject.tags.content = msgObject.tags.content || {};
          msgObject.tags.content.clips = msgObject.tags.content.clips || [];

          msgObject.tags.content.clips.push({
            index: clipContent.index,
            removeOriginal: true,
            data: {
              slug: clipContent.slug
            }
          });
        }
      }

      if (msgObject.tags && msgObject.tags.risk === 'high') {
        msgObject.flaggedForReview = true;
      }


      let sleepDuration = this.get('roomProperties.chat_delay_duration') || 0;

      if (msgObject.from === 'jtv' || !msgObject.tags) {
        this.pushMessage(msgObject);
      } else if (msgObject.from === this.get('tmiSession.nickname')) {
        this.pushMessage(msgObject);
      } else if (!this.get('isModeratorOrHigher') && sleepDuration > 0) {
        this.pushMessageDelayed(msgObject);
        sleep(sleepDuration * 1000).then(() => {
          if (this.isDestroyed) { return; }
          for (let i = 0; i < this.get('delayedMessages').length; i++) {
            let msg = this.get('delayedMessages')[i];
            if (msg.tags && msgObject.tags && (msg.tags.id === msgObject.tags.id)) {
              this.pushMessage(msgObject);
              this.get('delayedMessages').shift();
              break;
            }
          }
        });
      } else {
        this.pushMessage(msgObject);
      }

      this.addChatter(msgObject);
      if (this.get('conversations.activeConversation') && this.isFromOtherUser(msgObject.from)) {
        this.set('conversations.hasMissedChat', true);
      }
      this.trackLatency(msgObject);
    }
  },

  trackMessage(msgObject) {
    if (msgObject) {
      this.trackChat();
      this.trackSubOnly(msgObject);
      this.trackGoldenKappa(msgObject);
    }
  },

  // trackLatency calculates some time differences in order to look at how long it takes from client to server and vice versa.
  trackLatency(msgObject) {
    if (!msgObject) {
      return;
    }

    // sample at high rate to prevent spamming spade/mp
    if (Math.random() * LATENCY_SAMPLE_RATE > 1) {
      return;
    }

    let clientSentTime = get(msgObject, 'tags.sent-ts'),
        serverSentTime = get(msgObject, 'tags.tmi-sent-ts'),
        serverToClient, clientToServer;

    if (!clientSentTime || !serverSentTime) {
      return;
    }

    // client to server measures milliseconds difference between when we sent it, and when TMI tagged it.
    // server to client measures milliseconds difference between when TMI tagged it, and the current time.
    clientToServer = serverSentTime - clientSentTime;
    serverToClient = Date.now() - serverSentTime;

    this.get('tracking').trackEvent({
      event: 'chat_end_to_end_latency',
      data: assign({
        client_to_server: clientToServer,
        server_to_client: serverToClient
      }, this.getTrackingData())
    });
  },


  addWhisper(msgObject) {
    if (!this.get('isEmbedChat') || !msgObject.to) { return; } // TODO: Remove this if tmi.js is updated to eat bad messages

    msgObject.toColor = this.get('tmiSession').getColor(msgObject.to.toLowerCase());
    msgObject.toLabels = this.tmiRoom.getLabels(msgObject.to.toLowerCase());

    let { isAuthenticated, userData } = this.get('session');
    if (isAuthenticated) {
      if (msgObject.from !== userData.login) {
        msgObject.tags = msgObject.tags || {};
        msgObject.tags['recipient-display-name'] = userData.name;
        this.pushMessage(msgObject);
        this.addChatter(msgObject);
      } else {
        // To avoid rendering logins, attempt to fetch display name before pushing the message object to the array.
        let promises = RSVP.race([
          new RSVP.Promise((resolve, reject) => {
            run.later(null, reject, 500);
          }),
          this.get('tmiSession').fetchDisplayName(msgObject.to)
        ], 'Room#addWhisper');

        promises.then((resolvedValue) => {
          if (this.isDestroyed) { return; }
          // Only add whisper target to suggestions if displayname found, else we might add invalid users
          this.addChatter(msgObject);
          msgObject.tags['recipient-display-name'] = resolvedValue;
        }, () => {
          msgObject.tags['recipient-display-name'] = '';
        }).finally(() => {
          if (this.isDestroyed) { return; }
          this.pushMessage(msgObject);
        });
      }
    }

    window.setTimeout(() => {
      run(() => {
        this.trackWhisper(msgObject);
      });
    });
  },

  addNotification(noticeObject) {
    if (!noticeObject) { return; }
    let whisperMessage = WHISPER_NOTICES[noticeObject.msgId];

    if (whisperMessage) {
      this.addTmiMessage(whisperMessage);
    } else if (noticeObject.msgId === "timeout_success" && !this.get('isGroupRoom') && !this.get('isModeratorOrBroadcaster')) {
      return;
    } else if (noticeObject.msgId === "ban_success" && !this.get('isGroupRoom') && !this.get('isModeratorOrBroadcaster')) {
      return;
    } else if (noticeObject.msgId === "msg_rejected") {
      this.addTwitchBotMessage(noticeObject.message);
      return;
    } else if (!whisperMessage && noticeObject.message) {
      this.addTmiMessage(noticeObject.message);
    }
  },

  shouldShowMessage(msgObject) {
    return msgObject.message.substr(0, 5) !== ":act ";
  },

  addExampleWhisperMessage() {
    let user = Twitch.user.login(),
        isLoggedIn = Twitch.user.isLoggedIn(),
        isGroupRoom = this.get('isGroupRoom');

    if (isLoggedIn && !Twitch.storage.get('example_whisper_sent') && !isGroupRoom) {
      this.addMessage({
        color: '#6441A5',
        from: 'Kappa',
        message: KAPPA_WHISPER,
        style: 'whisper',
        date: new Date(),
        labels: [],
        to: user,
        toColor: this.get('tmiSession').getColor(user),
        tags: {},
        doNotTrack: true
      });
      Twitch.storage.set('example_whisper_sent', true);
    }
  },

  showChatRules() {
    this.waitForRoomProperties().then(() => {
      let chatRules = [];
      if (this.get('roomProperties.chat_rules')) {
        chatRules = this.get('roomProperties.chat_rules');
      }

      if (this.get('isBroadcaster') || !(this.get('inChatRulesExperiment') === "rules_v2") || chatRules.length <= 0) {
        return;
      }

      this.set('chatRules', chatRules);

      let defaultChatRulesShown = {};
      defaultChatRulesShown[this.get('id')] = true;

      let shownChatRules = Twitch.storage.getObject(CHAT_RULES_SHOWN_KEY);
      if (shownChatRules && shownChatRules[this.get('id')]) {
        return;
      }

      if (!shownChatRules) {
        shownChatRules = defaultChatRulesShown;
      } else {
        shownChatRules[this.get('id')] = true;
      }

      Twitch.storage.setObject(CHAT_RULES_SHOWN_KEY, shownChatRules);

      this.set('shouldDisplayChatRules', true);

      this.get('tracking').trackEvent({
        event: 'enter_with_chat_rules',
        services: ['spade'],
        data: assign({
          rules_type : this.get('inChatRulesExperiment'),
          is_right_column_closed: this.get('layout.isRightColumnClosed')
        }, this.getTrackingData())
      });
    });
  },

  addTmiMessage(msg) {
    return this.addMessage({
      style: 'admin',
      message: msg
    });
  },

  addTmiModerationMessage(msg) {
    return this.addMessage({
      style: 'admin',
      message: msg,
      isModerationMessage: true
    });
  },

  addTwitchBotMessage(msg) {
    return this.addMessage({
      color: '#6441A5',
      from: 'AutoMod',
      style: 'special-message special-message--alert',
      message: msg,
      tags: {
        badges: [
          {
            "id" : "twitchbot",
            "version": "1"
          }
        ]
      }
    });
  },

  addBoldTmiMessage(msg) {
    return this.addMessage({
      style: 'admin strong',
      message: msg
    });
  },

  addTwitchBotMessageModAction(msgID, login, msg) {
    return this.addMessage({
      color: '#6441A5',
      from: 'AutoMod',
      style: 'special-message special-message--alert',
      message: msg,
      tags: {
        id: msgID,
        emotes: tmiEmotes(this.get("userEmotes").tryParseEmotes(msg)),
        badges: [
          {
            "id" : "twitchbot",
            "version": "1"
          }
        ]
      }
    });
  },

  addTwitchBotRejectedMessage(msgID, login, msg) {
    return this.addMessage({
      from: login,
      style: 'special-message special-message--alert special-message--automod',
      message: msg,
      tags: {id: msgID},
      twitchBotRejected: true
    });
  },

  updateRoomState(msg) {
    this.set('slowMode', msg.tags.slow > 0);

    for (let tag in msg.tags) {
      if (msg.tags.hasOwnProperty(tag)) {
        this.set(tag.camelize(), msg.tags[tag]);
      }
    }
  },
  restrictedChatLanguage: computed.alias('broadcasterLang')
});

roomModel.Viewers = EObject.extend({
  model: emberA(),
  isLoading: false,

  load() {
    if (this.get('isLoading') || !this.tmiRoom) {
      return;
    }

    this.set('errorOccurred', false);
    this.set('isLoading', true);

    this.tmiRoom.list()
    .done((response) => {
      let error = response.status === 503 || response.data === '';
      this.set('errorOccurred', error);
      this.set('isLoading', false);
      if (!error) {
        this.set('chatters', response && response.data && response.data.chatters);
      }
    })
    .fail(() => {
      this.set('errorOccurred', true);
      this.set('isLoading', false);
    });

  }
});

roomModel.createNewTmiRoom = function roomModel_createNewTmiRoom(tmiSessionPromise, session, options) { // eslint-disable-line camelcase
  options.publicInvitesEnabled = !!options.publicInvitesEnabled;
  return new RSVP.Promise(function (resolve, reject) {
    if (!Twitch.user.isLoggedIn()) {
      return reject();
    }
    tmiSessionPromise.then((tmiSession) => {
      let { isAuthenticated, userData } = session;
      if (isAuthenticated) {
        let time = new Date().getTime();
        let ircChannel = `_${userData.login}_${time}`;
        tmiSession.createRoom({
          private: true,
          name: ircChannel, /** name is going away */
          ircChannel: ircChannel,
          displayName: options.name
        })
        .done(function (tmiRoom) {
          if (tmiRoom.publicInvitesEnabled !== options.publicInvitesEnabled) {
            tmiRoom.setPublicInvitesEnabled(options.publicInvitesEnabled)
            .done(function () { resolve(tmiRoom); })
            .fail(function () { resolve(tmiRoom); });
          } else {
            resolve(tmiRoom);
          }
        })
        .fail(reject);
      }
    }, reject);
  });
};

roomModel.rejectUser = function roomModel_rejectUser(tmiSessionPromise, options) { // eslint-disable-line camelcase
  if (options && options.user) {
    tmiSessionPromise.then(function (tmiSession) {
      tmiSession.ignoreUser(options.user);
    });
  }
};

roomModel.defineCollection('group', {
  tmi: injectService(),

  /** TMI.js will throttle the request for us; no need to worry about frequent fetch. */
  expiration: 1,

  init() {
    this._super(...arguments);
    this.get('tmi.tmiSession').then((tmiSession) => {
      tmiSession.on('listroomschanged', () => {
        this.load();
      });
    });
  },

  request() {
    return new RSVP.Promise((resolve, reject) => {
      let globals = window.App.__container__.lookup('service:globals'),
          disableGroupRooms = globals.get('disableGroupRooms');

      /** If the option switch disable_group_rooms is enabled, do not fetch rooms. */
      if (disableGroupRooms) {
        resolve();
        return;
      }
      this.get('tmi.tmiSession').then(function (tmiSession) {
        tmiSession.listRooms().done(function (tmiRooms) {
          run(null, resolve, tmiRooms);
        }).fail(run.bind(null, reject));
      }, reject);
    });
  },

  afterSuccess(tmiRooms) {
    let previous = this.get('content') || [];
    let current  = _.map(tmiRooms, tmiRoom => roomModel.findOne(tmiRoom.name)) || [];


    /** Destroy rooms that have been removed from the list (disconnect). */
    let deleted  = _.difference(previous, current);
    _.each(deleted, room => room.destroy());

    this.set('content', current);
  },

  /** Override the empty function defined in store.js because we don't want "expired" state to empty content. */
  empty() { }

});

export default roomModel;
