import computed from 'ember-computed';
import { libraries as libs } from 'web-client/utilities/conversations-util';
import { WHISPER_NOTICES } from 'web-client/models/room';
import RSVP from 'rsvp';
import Service from 'ember-service';
import on from 'ember-evented/on';
import run from 'ember-runloop';
import Evented from 'ember-evented';
import injectService from 'ember-service/inject';
import injectController from 'web-client/utilities/inject-controller';
import { A as emberArray } from 'ember-array/utils';
import ConversationModel from 'web-client/models/conversation';
import { assert } from 'ember-metal/utils';
import threadLastMessages from 'web-client/utilities/thread-last-messages';

export default Service.extend(Evented, {
  tmi: injectService(),
  store: injectService(),
  imApi: injectService(),
  api: injectService(),
  layout: injectService(),
  session: injectService(),
  tracking: injectService(),
  whispersShim: injectService('whispers-shim'),
  userEmotes: injectService('user-emotes'),

  chatController: injectController('chat'),

  isDNDEnabled: false,
  totalThreads: null,
  unreadMeta: {},

  // Pertains to the conversation of social-column/active-window
  activeConversation: null,
  activeWindowLogin: null,
  hasMissedChat: false,
  closeConversationTimer: null,
  isWhisperExpanded: false,
  isLoading: true,

  allUnread: computed(function () { return emberArray(); }),
  allConversations: computed(function () { return emberArray(); }),
  sortedConversations: computed('allConversations.[]', 'allConversations.@each.thread', function () {
    return this.get('allConversations')
      .rejectBy('thread.isArchived')
      .rejectBy('thread.messages.length', 0)
      .sortBy('thread.lastUpdatedAt')
      .reverse();
  }),

  // FIXME: If the thread is focused it should not be considered
  totalUnreadCount: computed('allUnread.[]', 'allUnread.@each.unreadCount', function () {
    let totalUnread = 0;
    this.get('allUnread').forEach(unread => {
      totalUnread += unread.get('unreadCount');
    });

    if (totalUnread < 0) {
      totalUnread = 0;
    }
    return totalUnread;
  }),

  setupService() {
    let whispersShim = this.get('whispersShim');
    whispersShim.setupService().then(() => {
      if (this.isDestroyed) { return; }

      whispersShim.on('reconnecting', this._onReconnect.bind(this));
      whispersShim.on('whisper', this._onPubsubWhisper.bind(this));
      whispersShim.on('thread', this._onPubsubThread.bind(this));
      whispersShim.on('threads', this._onPubsubThreads.bind(this));
      whispersShim.on('chat_whisper', this._onChatWhisper.bind(this));

      this.get('userEmotes').populate().then(emotes => {
        if (this.isDestroyed) { return; }
        this._setEmotes(emotes);
      });

      // Load the most recent threads
      return this.loadThreads({queryParams: {limit: 20}}).then(() => {
        this.set('isLoading', false);
      });
    });
  },

  // Called when the 'Enter' key is pressed
  sendMessage(thread, messageBody, nonce) {
    thread.addLocalMessage(messageBody, nonce);
    this._afterWhisperMessageStored(thread.get("id"));

    let options = {
      toId: parseInt(thread.get('otherUser.id'), 10),
      toLogin: thread.get('otherUsername'),
      nonce: nonce
    };

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

    let friendInfo = this.get('store').peekRecord('friends-list-user', thread.get('otherUser.id'));

    this.get('tracking').trackEvent({
      event: 'whisper',
      data: {
        to: thread.get('otherUsername'),
        from: this.get('session.userData.login'),
        player: 'web',
        is_turbo: !!this.get('session.userData.has_turbo'),
        conversation_id: thread.get('id'),
        availability: friendInfo ? friendInfo.get('availability') : null
      }
    });
  },

  openConversation(login) {
    if (this.get('closeConversationTimer')) {
      run.cancel(this.get('closeConversationTimer'));
      this.set('closeConversationTimer', null);
    }

    this.get('layout').trigger('dismissProfileCard');
    this.startConversationForUsername(login).then(conversation => {
      // Setting this property immediately would prevent the active window's
      // opening animation, so we schedule it for the next loop.
      run.next(this, function() {
        this.set('isWhisperExpanded', true);
        this.set('layout.isRightColumnClosedByUserAction', false);
        let chatController = this.get('chatController');
        if (chatController.get('currentRoom')) {
          let action = this.get('layout.isRightColumnClosedByUserAction') ? 'hideChat' : 'show';
          chatController.send(action);
        }
        this.get('tracking').trackEvent({
          event: 'chat_convo_mod',
          data: {
            conversation_id: conversation.get('thread.id'),
            action: 'open',
            login: this.get('session.userData.login')
          }
        });
      });
    });
  },

  closeConversation() {
    this.set('hasMissedChat', false);
    this.set('isWhisperExpanded', false);
    this.get('tracking').trackEvent({
      event: 'chat_convo_mod',
      data: {
        conversation_id: this.get('activeConversation.thread.id'),
        action: 'close',
        login: this.get('session.userData.login')
      }
    });
    this.set('closeConversationTimer', run.later(this, function() {
      this.set('activeConversation', null);
      this.set('activeWindowLogin', null);
      this.set('closeConversationTimer', null);
      this.trigger('conversationWindowClosed');
    }, 200));
  },

  _onUnauthorizedWhisper(thread, error) {
    let message = WHISPER_NOTICES[error];

    if (message) {
      thread.addNotice(message);
    }
  },

  ignoreUser(thread, reason, source) {
    let name = thread.get('otherUsername');
    this._reportThread(thread, reason, source);
    return this.get('tmi').ignoreUser(name, reason, {isWhisper: true}).then(() => {
      if (this.isDestroyed) { return; }
      thread.set('isIgnored', true);
      thread.addNotice('User successfully blocked.');
    }, () => {
      thread.addNotice('There was a problem blocking that user.');
    });
  },

  markNotSpam(conversation) {
    let thread = conversation.get('thread');

    let body = {
      not_spam: true
    };
    this.get('imApi').makeRequest('POST', `/v1/threads/${thread.get('id')}`, body).then(threadData => {
      thread.set('lastMarkedNotSpam', threadData.spam_info.last_marked_not_spam);
    });
  },

  unignoreUser(thread) {
    let name = thread.get('otherUsername');
    return this.get('tmi').unignoreUser(name).then(() => {
      if (this.isDestroyed) { return; }
      thread.set('isIgnored', false);
      thread.addNotice('User successfully unblocked.');
    }, () => {
      thread.addNotice('There was a problem unblocking that user.');
    });
  },

  initialSyncUnreadCounts() {
    run(this, this._syncUnreadHandler);
  },

  syncUnreadCounts() {
    if (this.isDestroyed) { return; }
    this.debounceTask('_syncUnreadHandler', 1000);
  },

  _onPubsubThread(threadData) {
    let threadId = threadData.id;

    let thread = this.get('store').peekRecord('thread', threadId);
    if (!thread) {
      return;
    }

    run(() => {
      thread.set('isMuted', threadData.muted);
      if (thread.get('isMuted')) {
        return;
      }

      if (threadData.last_read && thread.get('unreadCount') > 0) {
        // set thread to read
        thread.set("lastReadId", threadData.last_read);
        this._updateUnreadLastRead(threadId, threadData.last_read);
      }

      // archive
      if (threadData.archived) {
        let conversation = this.get('allConversations').find((conv) => {
          return conv.get('thread').get('id') === threadId;
        });

        if (conversation && !conversation.get('thread.isArchived')) {
          this.archive(conversation, false);
        }
      }

      if (threadData.spam_info.last_marked_not_spam) {
        thread.set('lastMarkedNotSpam', threadData.spam_info.last_marked_not_spam);
      }

      if (threadData.spam_info.likelihood) {
        thread.set('spamLikelihood', threadData.spam_info.likelihood);
      }
    });
  },

  markRead(thread) {
    if (thread.get('isMuted')) {
      return true;
    }
    let unreadCount = thread.get('unreadCount');
    if (unreadCount > 0) {
      let markReadId = parseInt(thread.get('mostRecentMessage.messageId'), 10);
      if (markReadId) {
        thread.set('markRead', markReadId);
        thread.save().then(() => {
          run(() => {
            if (this.isDestroyed) { return; }
            this._updateUnreadLastRead(thread.get('id'), markReadId);
          });
        });
      }
    }
  },

  _updateUnreadLastRead(threadId, messageId) {
    let unread = this.get('store').peekRecord('unread', threadId);
    if (unread) {
      unread.set('lastReadMessageId', messageId);
      if (!unread.get('meta.complete')) {
        this.syncUnreadCounts();
      }
    }
  },

  _onPubsubThreads(threadsData) {
    if (threadsData.mark_all_read && this.get('totalUnreadCount') > 0) {
      this.syncUnreadCounts();
    }
  },

  markAllRead() {
    if (this.get('totalUnreadCount') > 0) {
      return this.get('store').query('thread', {
        method: 'PUT',
        queryParams: {
          mark_all_read: true
        }
      }).then(() => {
        this._updateAllLocalUnread();
        // FIXME: We should wait for `_updateAllLocalUnread` to finish because there's a possible
        // race condition. It's "unlikely" because syncUnreadCounts is debounced
        this.syncUnreadCounts();
      });
    }
    return RSVP.resolve();
  },

  _updateAllLocalUnread() {
    this.get('store').filter('thread', record => {
      return record.get('unreadCount') > 0;
    }).then(threads => {
      threads.forEach(thread => {
        let markReadId = parseInt(thread.get('mostRecentMessage.messageId'), 10);
        if (markReadId) {
          thread.set('markRead', markReadId);
          thread.save();
        }
      });
    });
  },

  // Called when clicking on a search result
  startConversationForUsername(username) {
    if (username === this.get('session.userData.login')) {
      throw new Error('cannot start conversation with yourself');
    }

    return this._getUserData(username).then(otherUserData => {
      if (this.isDestroyed) { return; }

      let threadId = this.generateThreadId(this.get('session.userData.id'), otherUserData.id);

      return this.findThread(threadId).then(thread => {
        if (this.isDestroyed) { return; }
        if (thread.get('isArchived')) {
          thread.set('isArchived', false);
          thread.save();
        }
        return thread;
      }, () => {
        return this._createDummyThreadForUser(otherUserData);
      });
    }).then(thread => {
      if (this.isDestroyed) { return; }
      let conversation = this._getOrCreateConversation(thread);
      this.makeActive(conversation);
      this.display(conversation);
      conversation.set('isFocused', true);

      this.set('activeConversation', conversation);
      this.set('activeWindowLogin', username);

      return conversation;
    });
  },

  fetchMoreThreads(limit = 10) {
    let threads = this.get('store').peekAll('thread');
    let numThreads = threads.rejectBy('isArchived', true)
      .rejectBy('messages.length', 0).get('length');
    let totalThreads = this.get('totalThreads');
    let queryParams = { offset: numThreads, limit: limit };
    if (numThreads < totalThreads) {
      this.loadThreads({ queryParams });
    }
  },

  makeActive(conversation) {
    if (!conversation.get('isActive')) {
      conversation.set('isActive', true);
      conversation.get("thread").loadEarlierMessages();
    }
  },

  display(conversation) {
    this._displayConversation(conversation);
  },

  maximize(conversation) {
    this.trigger('maximizeConversation', conversation);
  },

  makeInactive(conversation) {
    conversation.set('isActive', false);
    conversation.set('isDisplayed', false);
    conversation.set('isFocused', false);
    conversation.set('isCollapsed', false);
    run.scheduleOnce('afterRender', this, function () {
      this._displayNextActive();
    });
  },

  archive(conversation, saveThread=true) {
    this.makeInactive(conversation);
    conversation.set('thread.isArchived', true);

    if (saveThread) {
      conversation.get('thread').save().then(() => {
        if (this.isDestroyed) { return; }
        this.fetchMoreThreads(1);
      });
    } else {
      this.fetchMoreThreads(1);
    }
  },

  loadThreads(query={}) {
    return this.get('store').query('thread', query).then(threadData => {
      if (this.isDestroyed) { return; }

      let meta = threadData.get("meta.lastMessages");
      assert(`Missing threads lastMessages`, !!meta);

      this.set('totalThreads', threadData.get('meta.total'));

      threadData.forEach(thread => {
        let threadId = thread.get("id");
        let lastMessage = meta[threadId];
        assert(`Missing last message for thread ID ${threadId}`, !!lastMessage);
        thread.addRemoteLastMessage(lastMessage);
        this._getOrCreateConversation(thread);
      });

      return threadData;
    }).catch(err => {
      throw err;
    });
  },

  findThread(threadId) {
    let existingThread = this.get('store').peekRecord('thread', threadId);
    if (existingThread) {
      return RSVP.resolve(existingThread);
    }

    return this.get('store').findRecord('thread', threadId).then(thread => {
      if (this.isDestroyed) { return; }

      this._getOrCreateConversation(thread);
      let id = thread.get('id');
      let lastMessage = threadLastMessages[id];

      assert(`lastMessage is missing for thread ID ${id}`, !!lastMessage);

      thread.addRemoteLastMessage(lastMessage);
      delete threadLastMessages[id];
      return thread;
    });
  },

  _getOrCreateConversation(thread) {
    let conversation = this.findConversationForThread(thread);

    if (!conversation) {
      conversation = ConversationModel.create({thread});
      this.get('allConversations').addObject(conversation);
    }

    return conversation;
  },

  toggleDND() {
    this.set('isDNDEnabled', !this.get('isDNDEnabled'));
  },

  _setAllThreads: on('init', function () {
    this.set('allUnread', this.get('store').peekAll('unread'));
  }),

  _onReconnect() {
    this.syncUnreadCounts();
    // FIXME: Additionally resync thread data. This used to happen in the
    // `syncUnreadCounts` flow but it does not belong in that flow.
  },

  _onNoticeReceived(msg) {
    if (msg.targetId) {
      let threadId = this.generateThreadId(msg.targetId, this.get('session.userData.id')),
          thread = this.get('store').peekRecord('thread', threadId);
      if (thread) {
        thread.addNotice(msg.message);
      }
    }
  },

  // Called when receiving a whisper via pubsub
  //
  // FIXME: Lazy update the participant model with badges, display-name etc.
  _onPubsubWhisper(rawMessage) {
    let threadId = rawMessage.thread_id;
    assert('Missing threadId', !!threadId);

    let wasThreadLoaded = this.get('store').hasRecordForId('thread', threadId);

    this.findThread(threadId).then((thread) => {
      if (this.isDestroyed) { return; }

      // Case A - Thread is remote. When the thread is fetched, the last_message is
      // added to its `messages` collection. In this case we're guessing that the last_message
      // is the same as rawMessage. There's a small window that could cause subsequent messages
      // to be missed.
      //
      // Case B - Thread is local, so we need to update the local copy
      //
      if (wasThreadLoaded) {
        // FIXME: There should probably be a run-loop here but it makes the scrollToBottom
        // work inconsistently
        thread.addRawMessage(rawMessage);
      }

      // Check whether should trigger quick reply
      // TODO: After disabling toasts is added, add check for that too
      let isDiffThanActiveConv = this.get('activeConversation.thread.id') !== thread.get('id');
      if (!this.get('isDNDEnabled') &&
          isDiffThanActiveConv &&
          !thread.get('isMuted') &&
          this.get('layout.isSocialColumnEnabled')
      ) {
        this.trigger('quickReply', thread);
      }

      // Make the thread active in both cases
      this._afterWhisperMessageStored(threadId);
    }, () => {
      // This should theoretically never trigger on messages from pubsub
      throw new Error(`Thread for thread ID ${threadId} is missing in onPubsubWhisper`);
    });
  },

  // Called when the user does `/w <login> <body>`
  //
  // FIXME: This can double render on step 4. There's a race between local rendering
  // and fetching the remote thread's last_message. This bug has existed for a while
  //
  // Example scenario
  //
  //    1. POST message to im-api
  //    2. Thread does not exist locally and is fetched
  //    3. The thread's last_message is inserted
  //    4. Message is local rendered
  //
  //
  _onChatWhisper(message) {
    assert(`Missing body`, !!message.body);
    assert(`Missing toLogin`, !!message.toLogin);
    assert(`Missing nonce`, !!message.nonce);

    // 1. Map the recipient's login to ID
    this._getUserData(message.toLogin).then(otherUserData => {
      if (this.isDestroyed) { return; }

      let threadId = this.generateThreadId(this.get('session.userData.id'), otherUserData.id);
      // 2. Try to find the thread
      let threadPromise = this.findThread(threadId).then(thread => {
        if (this.isDestroyed) { return; }

        // 3a. Thread exists locally or remotely
        return thread;
      }, () => {
        if (this.isDestroyed) { return; }

        // 3b. Thread doesn't exist remotely yet
        return this._createDummyThreadForUser(otherUserData);
      });

      return threadPromise.then((thread) => {
        // 4. Local render
        if (this.isDestroyed) { return; }

        // FIXME: There should probably be a run-loop here but it makes the scrollToBottom
        // work inconsistently
        thread.addLocalMessage(message.body, message.nonce);
        this._afterWhisperMessageStored(threadId);
      });
    }).catch(err => {
      throw err;
    });
  },

  _afterWhisperMessageStored(threadId) {
    let unread = this.get('store').peekRecord('unread', threadId);
    let thread = this.get('store').peekRecord('thread', threadId);

    let conversation = this._getOrCreateConversation(thread);
    thread.set('lastUpdatedAt', new Date());

    // FIXME: Refactor sortedConversations CP so setting this is not necessary
    this.set('sortedConversations', this.get('allConversations').sortBy('thread.lastUpdatedAt').reverse());

    if (!conversation.get('thread.isMuted') && !this.get('isDNDEnabled')) {
      this.makeActive(conversation);
      this.display(conversation);
    }

    if (!unread) {
      unread = this.get('store').push({
        data: {
          id: threadId,
          attributes: {
            lastMessageId: thread.get('mostRecentMessage.messageId'),
            lastReadMessageId: thread.get('lastReadId')
          },
          type: 'unread'
        }
      });
    }

    unread.set('lastMessageId', thread.get('mostRecentMessage.messageId'));

    let message = thread.get('mostRecentMessage');

    if (message.fromId === this.get('session.userData.id') || thread.get('isMuted')) {
      unread.set('lastReadMessageId', thread.get('mostRecentMessage.messageId'));
      thread.set('lastLocalReadId', thread.get('mostRecentMessage.messageId'));
    } else {
      if (!this.get('unreadMeta.complete')) {
        this.syncUnreadCounts();
      }

      this.get('tracking').trackEvent({
        event: 'whisper_received',
        data: {
          to: this.get('session.userData.login'),
          from: message.get('from.username'),
          player: 'web',
          is_turbo: !!this.get('session.userData.has_turbo'),
          conversation_id: threadId
        }
      });
    }
  },

  _setEmotes(response) {
    this.set('allUserEmotes', response);
  },

  _createDummyThreadForUser(otherUserData) {
    assert('Missing otherUserData', !!otherUserData);

    let userData = this.get('session.userData');
    let threadId = this.generateThreadId(userData.id, otherUserData.id);
    let serializer = this.get('store').serializerFor('thread');
    let threadObj = serializer.createDummyThreadForUser(threadId, userData, otherUserData);
    return this.get('store').push(threadObj);
  },

  _getUserData(username) {
    let participant = this.get('store').peekAll('participant').findBy('username', username);
    if (participant) {
      let userData = this._newUserData(
        participant.get('id'),
        participant.get('username'),
        participant.get('displayName')
      );
      return RSVP.resolve(userData);
    }
    return this.get('api').request('get', `users/${username}`).then(data => {
      if (this.isDestroyed) { return; }
      return this._newUserData(data._id, data.name, data.display_name);
    });
  },

  _newUserData(id, login, name) {
    assert('Missing id', !!id);
    assert('Missing login', !!login);
    assert('Missing name', !!name);

    return {
      id,
      login,
      name // displayName
    };
  },

  generateThreadId(userid1, userid2) {
    let user1 = parseInt(userid1, 10),
        user2 = parseInt(userid2, 10);

    if (isNaN(user1) || isNaN(user2)) {
      throw new Error('userIds cannot be parsed as numbers');
    }

    let [lower, higher] = user1 < user2 ? [user1, user2] : [user2, user1];
    return `${lower}_${higher}`;
  },

  _syncUnreadHandler() {
    if (this.isDestroyed) { return; }

    // Clear existing unread data
    this.get('store').peekAll('unread').forEach(unread => {
      let thread = this.get('store').peekRecord('thread', unread.get('id'));
      // FIXME: Re-evaluate why this is here
      thread.set('lastLocalReadId', thread.get('mostRecentMessage.messageId'));
      this.get('store').unloadRecord(unread);
    });

    // Need to use `query` in order to retrieve metadata...findAll does not preserve meta properly
    this.get('store').query('unread', {}).then((unreadData) => {
      if (this.isDestroyed) { return; }
      this.set('unreadMeta', unreadData.get('meta'));
      unreadData.forEach((unreadThreadInfo) => {
        let thread = this.get('store').peekRecord('thread', unreadThreadInfo.get('id'));
        if (thread) {
          // FIXME: Unread logic is not accurate if the user tried to send messages without internet
          let lastReadMessageId = unreadThreadInfo.get('lastReadMessageId');
          let lastMessageId = unreadThreadInfo.get('lastMessageId');

          let mostRecentId = thread.get('mostRecentMessage.messageId');

          // Always update based on remote information
          thread.set('lastLocalReadId', lastReadMessageId);

          // Case A: mostRecentId <= lastReadMessageId
          //
          //    Get the messages from (mostRecentId, lastMessageId]
          //
          // Case B: mostRecentId > lastReadMessageId
          //
          //    This can happen if the unread is synced multiple times. Do the same thing
          //    as Case A.
          //
          let hasUnread = mostRecentId < lastMessageId;
          // This is possible if ALL the unread messages were already synced
          if (!hasUnread) {
            return;
          }

          thread.loadUnreadMessages(mostRecentId, lastMessageId);
        }
      });
    });
  },

  findConversationForThread(thread) {
    return this.get('allConversations').findBy('thread', thread);
  },

  _displayNextActive() {
    let availableActives = this.get('sortedConversations').filterBy('isActive').rejectBy('isDisplayed');
    if (availableActives.length > 0) {
      this._displayConversation(availableActives.objectAt(0));
    }
  },

  _displayConversation(conversation) {
    this.trigger('displayConversation', conversation);
  },
  // This isn't updating a model, so make the request directly instead of going through the store
  _reportThread(thread, reason, source) {
    let body = {
      reporter_user_id: this.get('session.userData.id').toString(),
      target_user_id: thread.get('otherUser.id').toString(),
      bcp47_language: libs.userLanguage,
      reason: reason,
      source: source
    };
    this.get('imApi').makeRequest('POST', `/v1/threads/${thread.get('id')}/report`, body);
  }
});
