import json
import time
from collections import OrderedDict, defaultdict
from concurrent.futures import Future, ThreadPoolExecutor, wait
from typing import (
    Any, Callable, DefaultDict, Dict, FrozenSet, Iterable, List, Optional, Set,
    Tuple, Union,
)
from urllib.parse import urlparse

import zulip
from mypy_extensions import TypedDict

from zulipterminal.config.keys import keys_for_command
from zulipterminal.helper import (
    Message, asynch, canonicalize_color, classify_unread_counts,
    index_messages, initial_index, notify, set_count,
)
from zulipterminal.ui_tools.utils import create_msg_box_list


Event = TypedDict('Event', {
    'type': str,
    # typing:
    'sender': Dict[str, Any],  # 'email', ...
    # typing & reaction:
    'op': str,
    # reaction:
    'user': Dict[str, Any],  # 'email', 'user_id', 'full_name'
    'reaction_type': str,
    'emoji_code': str,
    'emoji_name': str,
    # reaction & update_message:
    'message_id': int,
    # update_message:
    'rendered_content': str,
    # update_message_flags:
    'messages': List[int],
    'operation': str,
    'flag': str,
    'all': bool,
    # message:
    'message': Message,
    'flags': List[str],
    'subject': str,
    # subscription:
    'property': str,
    'stream_id': int,
    'value': bool,
    'message_ids': List[int]  # Present when subject of msg(s) is updated
}, total=False)  # Each Event will only have a subset of these

OFFLINE_THRESHOLD_SECS = 140


class ServerConnectionFailure(Exception):
    pass


class Model:
    """
    A class responsible for storing the data to be displayed.
    """

    def __init__(self, controller: Any) -> None:
        self.controller = controller
        self.client = controller.client

        self.msg_view = None  # type: Any
        self.msg_list = None  # type: Any
        self.narrow = []  # type: List[Any]
        self.found_newest = False
        self.stream_id = -1
        self.recipients = frozenset()  # type: FrozenSet[Any]
        self.index = initial_index

        self.user_id = -1
        self.user_email = ""
        self.user_full_name = ""
        self.server_url = '{uri.scheme}://{uri.netloc}/'.format(
                          uri=urlparse(self.client.base_url))
        self.server_name = ""

        self._notified_user_of_notification_failure = False

        self.event_actions = OrderedDict([
            ('message', self._handle_message_event),
            ('update_message', self._handle_update_message_event),
            ('reaction', self._handle_reaction_event),
            ('subscription', self._handle_subscription_event),
            ('typing', self._handle_typing_event),
            ('update_message_flags', self._handle_update_message_flags_event),
        ])  # type: OrderedDict[str, Callable[[Event], None]]

        self.initial_data = {}  # type: Dict[str, Any]

        # Register to the queue before initializing further so that we don't
        # lose any updates while messages are being fetched.
        self._update_initial_data()

        self.server_version = self.initial_data['zulip_version']
        self.server_feature_level = (
            self.initial_data.get('zulip_feature_level')
        )

        self.users = self.get_all_users()

        subscriptions = self.initial_data['subscriptions']
        stream_data = Model._stream_info_from_subscriptions(subscriptions)
        (self.stream_dict, self.muted_streams,
         self.pinned_streams, self.unpinned_streams) = stream_data

        self.muted_topics = (
            self.initial_data['muted_topics'])  # type: List[List[str]]

        groups = self.initial_data['realm_user_groups']
        self.user_group_by_id = {}  # type: Dict[int, Dict[str, Any]]
        self.user_group_names = self._group_info_from_realm_user_groups(groups)

        self.unread_counts = classify_unread_counts(self)

        self.fetch_all_topics(workers=5)

        self.new_user_input = True
        self._start_presence_updates()

    def get_focus_in_current_narrow(self) -> Union[int, Set[None]]:
        """
        Returns the focus in the current narrow.
        For no existing focus this returns {}, otherwise the message ID.
        """
        return self.index['pointer'][str(self.narrow)]

    def set_focus_in_current_narrow(self, focus_message: int) -> None:
        self.index['pointer'][str(self.narrow)] = focus_message

    def is_search_narrow(self) -> bool:
        """
        Checks if the current narrow is a result of a previous search for
        a messages in a different narrow.
        """
        return 'search' in [subnarrow[0] for subnarrow in self.narrow]

    def set_narrow(self, *,
                   stream: Optional[str]=None,
                   topic: Optional[str]=None,
                   pms: bool=False,
                   pm_with: Optional[str]=None,
                   starred: bool=False,
                   mentioned: bool=False) -> bool:
        selected_params = {k for k, v in locals().items() if k != 'self' and v}
        valid_narrows = {
            frozenset(): [],
            frozenset(['stream']): [['stream', stream]],
            frozenset(['stream', 'topic']): [['stream', stream],
                                             ['topic', topic]],
            frozenset(['pms']): [['is', 'private']],
            frozenset(['pm_with']): [['pm_with', pm_with]],
            frozenset(['starred']): [['is', 'starred']],
            frozenset(['mentioned']): [['is', 'mentioned']],
        }  # type: Dict[FrozenSet[str], List[Any]]
        for narrow_param, narrow in valid_narrows.items():
            if narrow_param == selected_params:
                new_narrow = narrow
                break
        else:
            raise RuntimeError("Model.set_narrow parameters used incorrectly.")

        if new_narrow != self.narrow:
            self.narrow = new_narrow

            if pm_with is not None and new_narrow[0][0] == 'pm_with':
                users = pm_with.split(', ')
                self.recipients = frozenset(
                    [self.user_dict[user]['user_id'] for user in users]
                    + [self.user_id]
                )
            else:
                self.recipients = frozenset()
            return False
        else:
            return True

    def set_search_narrow(self, search_query: str) -> None:
        self.unset_search_narrow()
        self.narrow.append(['search', search_query])

    def unset_search_narrow(self) -> None:
        # If current narrow is a result of a previous started search,
        # we pop the ['search', 'text'] term in the narrow, before
        # setting a new narrow.
        if self.is_search_narrow():
            self.narrow = [item for item in self.narrow
                           if item[0] != 'search']

    def get_message_ids_in_current_narrow(self) -> Set[int]:
        narrow = self.narrow
        index = self.index
        if narrow == []:
            ids = index['all_msg_ids']
        elif self.is_search_narrow():  # Check searches first
            ids = index['search']
        elif narrow[0][0] == 'stream':
            stream_id = self.stream_id
            if len(narrow) == 1:
                ids = index['stream_msg_ids_by_stream_id'][stream_id]
            elif len(narrow) == 2:
                topic = narrow[1][1]
                ids = index['topic_msg_ids'][stream_id].get(topic, set())
        elif narrow[0][1] == 'private':
            ids = index['private_msg_ids']
        elif narrow[0][0] == 'pm_with':
            recipients = self.recipients
            ids = index['private_msg_ids_by_user_ids'].get(recipients, set())
        elif narrow[0][1] == 'starred':
            ids = index['starred_msg_ids']
        elif narrow[0][1] == 'mentioned':
            ids = index['mentioned_msg_ids']
        return ids.copy()

    def _notify_server_of_presence(self) -> Dict[str, Any]:
        response = self.client.update_presence(
                request={
                    # TODO: Determine `status` from terminal tab focus.
                    'status': 'active' if self.new_user_input else 'idle',
                    'new_user_input': self.new_user_input,
                }
            )
        self.new_user_input = False
        return response

    @asynch
    def _start_presence_updates(self) -> None:
        """
        Call `_notify_server_of_presence` every minute (version 1a).
        Use 'response' to update user list (version 1b).
        """
        # FIXME: Version 2: call endpoint with ping_only=True only when
        #        needed, and rely on presence events to update
        while True:
            response = self._notify_server_of_presence()
            if response['result'] == 'success':
                self.initial_data['presences'] = response['presences']
                self.users = self.get_all_users()
                if hasattr(self.controller, 'view'):
                    self.controller.view.users_view.update_user_list(
                        user_list=self.users)
            time.sleep(60)

    @asynch
    def react_to_message(self,
                         message: Message,
                         reaction_to_toggle: str) -> None:
        # FIXME Only support thumbs_up for now
        assert reaction_to_toggle == 'thumbs_up'

        reaction_to_toggle_spec = dict(
            emoji_name='thumbs_up',
            emoji_code='1f44d',
            reaction_type='unicode_emoji',
            message_id=str(message['id']))
        existing_reactions = [
            reaction['emoji_code']
            for reaction in message['reactions']
            if (reaction['user'].get('user_id', None) == self.user_id
                or reaction['user'].get('id', None) == self.user_id)
        ]
        if reaction_to_toggle_spec['emoji_code'] in existing_reactions:
            response = self.client.remove_reaction(reaction_to_toggle_spec)
        else:
            response = self.client.add_reaction(reaction_to_toggle_spec)

    @asynch
    def toggle_message_star_status(self, message: Message) -> None:
        base_request = dict(flag='starred', messages=[message['id']])
        if 'starred' in message['flags']:
            request = dict(base_request, op='remove')
        else:
            request = dict(base_request, op='add')
        response = self.client.update_message_flags(request)

    @asynch
    def mark_message_ids_as_read(self, id_list: List[int]) -> None:
        if not id_list:
            return
        self.client.update_message_flags({
            'messages': id_list,
            'flag': 'read',
            'op': 'add',
        })

    def send_private_message(self, recipients: str,
                             content: str) -> bool:
        request = {
            'type': 'private',
            'to': recipients,
            'content': content,
        }
        response = self.client.send_message(request)
        return response['result'] == 'success'

    def send_stream_message(self, stream: str, topic: str,
                            content: str) -> bool:
        request = {
            'type': 'stream',
            'to': stream,
            'subject': topic,
            'content': content,
        }
        response = self.client.send_message(request)
        return response['result'] == 'success'

    def update_private_message(self, msg_id: int, content: str) -> bool:
        request = {
            "message_id": msg_id,
            "content": content,
        }
        response = self.client.update_message(request)
        return response['result'] == 'success'

    def update_stream_message(self, topic: str, msg_id: int,
                              content: str) -> bool:
        request = {
            "message_id": msg_id,
            "content": content,
            # TODO: Add support for "change_later" & "change_all"
            "propagate_mode": "change_one",
            "subject": topic,
        }
        response = self.client.update_message(request)
        return response['result'] == 'success'

    def get_messages(self, *,
                     num_after: int, num_before: int,
                     anchor: Optional[int]) -> str:
        # anchor value may be specific message (int) or next unread (None)
        first_anchor = anchor is None
        anchor_value = anchor if anchor is not None else 0

        request = {
            'anchor': anchor_value,
            'num_before': num_before,
            'num_after': num_after,
            'apply_markdown': True,
            'use_first_unread_anchor': first_anchor,
            'client_gravatar': True,
            'narrow': json.dumps(self.narrow),
        }
        response = self.client.get_messages(message_filters=request)
        if response['result'] == 'success':
            self.index = index_messages(response['messages'], self, self.index)
            if first_anchor and response['anchor'] != 10000000000000000:
                self.index['pointer'][str(self.narrow)] = response['anchor']
            if 'found_newest' in response:
                self.found_newest = response['found_newest']
            else:
                # Older versions of the server does not contain the
                # 'found_newest' flag. Instead, we use this logic:
                query_range = num_after + num_before + 1
                self.found_newest = len(response['messages']) < query_range
            return ""
        return response['msg']

    def get_topics_in_stream(self, stream_list: Iterable[int]) -> str:
        """
        Fetch all topics with specified stream_id's and
        index their names (Version 1)
        """
        # FIXME: Version 2: Fetch last 'n' recent topics for each stream.
        for stream_id in stream_list:
            response = self.client.get_stream_topics(stream_id)
            if response['result'] == 'success':
                self.index['topics'][stream_id] = [topic['name'] for
                                                   topic in response['topics']]
            else:
                return response['msg']
        return ""

    @staticmethod
    def exception_safe_result(future: 'Future[str]') -> str:
        try:
            return future.result()
        except zulip.ZulipError as e:
            return str(e)

    def fetch_all_topics(self, workers: int) -> None:
        """
        Distribute stream ids across threads in order to fetch
        topics concurrently.
        """
        with ThreadPoolExecutor(max_workers=workers) as executor:
            list_of_streams = list(self.stream_dict.keys())
            thread_objects = {
                i: executor.submit(self.get_topics_in_stream,
                                   list_of_streams[i::workers])
                for i in range(workers)
            }  # type: Dict[int, Future[str]]
            wait(thread_objects.values())

        results = {
            str(name): self.exception_safe_result(thread_object)
            for name, thread_object in thread_objects.items()
        }  # type: Dict[str, str]
        if any(results.values()):
            failures = ['fetch_topics[{}]'.format(name)
                        for name, result in results.items()
                        if result]
            raise ServerConnectionFailure(", ".join(failures))

    def is_muted_stream(self, stream_id: int) -> bool:
        return stream_id in self.muted_streams

    def is_muted_topic(self, stream_id: int, topic: str) -> bool:
        if stream_id in self.muted_streams:
            return True
        stream_name = self.stream_dict[stream_id]['name']
        topic_to_search = [stream_name, topic]  # type: List[str]
        return topic_to_search in self.muted_topics

    def _update_initial_data(self) -> None:
        # Thread Processes to reduce start time.
        # NOTE: Exceptions do not work well with threads
        with ThreadPoolExecutor(max_workers=1) as executor:
            futures = {
                'get_messages': executor.submit(self.get_messages,
                                                num_after=10,
                                                num_before=30,
                                                anchor=None),
                'register': executor.submit(self._register_desired_events,
                                            fetch_data=True),
            }  # type: Dict[str, Future[str]]

            # Wait for threads to complete
            wait(futures.values())

        results = {
            name: self.exception_safe_result(future)
            for name, future in futures.items()
        }  # type: Dict[str, str]
        if not any(results.values()):
            self.user_id = self.initial_data['user_id']
            self.user_email = self.initial_data['email']
            self.user_full_name = self.initial_data['full_name']
            self.server_name = self.initial_data['realm_name']
        else:
            failures = defaultdict(list)  # type: DefaultDict[str, List[str]]
            for name, result in results.items():
                if result:
                    failures[result].append(name)
            failure_text = [
                "{} ({})".format(error, ", ".join(sorted(calls)))
                for error, calls in failures.items()
            ]
            raise ServerConnectionFailure(", ".join(failure_text))

    def get_all_users(self) -> List[Dict[str, Any]]:
        # Dict which stores the active/idle status of users (by email)
        presences = self.initial_data['presences']

        # Construct a dict of each user in the realm to look up by email
        # and a user-id to email mapping
        self.user_dict = dict()  # type: Dict[str, Dict[str, Any]]
        self.user_id_email_dict = dict()  # type: Dict[int, str]
        for user in self.initial_data['realm_users']:
            if self.user_id == user['user_id']:
                current_user = {
                    'full_name': user['full_name'],
                    'email': user['email'],
                    'user_id': user['user_id'],
                    'status': 'active',
                }
                continue
            email = user['email']
            if email in presences:  # presences currently subset of all users
                """
                * Aggregate our information on a user's presence across their
                * clients.
                *
                * For an explanation of the Zulip presence model this helps
                * implement, see the subsystem doc:
                https://zulip.readthedocs.io/en/latest/subsystems/presence.html
                *
                * This logic should match `status_from_timestamp` in the web
                * app's
                * `static/js/presence.js`.
                *
                * Out of the ClientPresence objects found in `presence`, we
                * consider only those with a timestamp newer than
                * OFFLINE_THRESHOLD_SECS; then of
                * those, return the one that has the greatest UserStatus, where
                * `active` > `idle` > `offline`.
                *
                * If there are several ClientPresence objects with the greatest
                * UserStatus, an arbitrary one is chosen.
                """
                aggregate_status = 'offline'
                for client in presences[email].items():
                    client_name = client[0]
                    status = client[1]['status']
                    timestamp = client[1]['timestamp']
                    if client_name == 'aggregated':
                        continue
                    elif (time.time() - timestamp) < OFFLINE_THRESHOLD_SECS:
                        if status == 'active':
                            aggregate_status = 'active'
                        if status == 'idle':
                            if aggregate_status != 'active':
                                aggregate_status = status
                        if status == 'offline':
                            if (aggregate_status != 'active'
                                    and aggregate_status != 'idle'):
                                aggregate_status = status

                status = aggregate_status
            else:
                # Set status of users not in the  `presence` list
                # as 'inactive'. They will not be displayed in the
                # user's list by default (only in the search list).
                status = 'inactive'
            self.user_dict[email] = {
                'full_name': user['full_name'],
                'email': email,
                'user_id': user['user_id'],
                'status': status,
            }
            self.user_id_email_dict[user['user_id']] = email

        # Add internal (cross-realm) bots to dicts
        for bot in self.initial_data['cross_realm_bots']:
            email = bot['email']
            self.user_dict[email] = {
                'full_name': bot['full_name'],
                'email': email,
                'user_id': bot['user_id'],
                'status': 'inactive',
            }
            self.user_id_email_dict[bot['user_id']] = email

        # Generate filtered lists for active & idle users
        active = [properties for properties in self.user_dict.values()
                  if properties['status'] == 'active']
        idle = [properties for properties in self.user_dict.values()
                if properties['status'] == 'idle']
        offline = [properties for properties in self.user_dict.values()
                   if properties['status'] == 'offline']
        inactive = [properties for properties in self.user_dict.values()
                    if properties['status'] == 'inactive']

        # Construct user_list from sorted components of each list
        user_list = sorted(active, key=lambda u: u['full_name'].casefold())
        user_list += sorted(idle, key=lambda u: u['full_name'].casefold())
        user_list += sorted(offline, key=lambda u: u['full_name'].casefold())
        user_list += sorted(inactive, key=lambda u: u['full_name'].casefold())
        # Add current user to the top of the list
        user_list.insert(0, current_user)
        self.user_dict[current_user['email']] = current_user
        self.user_id_email_dict[self.user_id] = current_user['email']

        return user_list

    @staticmethod
    def _stream_info_from_subscriptions(
            subscriptions: List[Dict[str, Any]]
    ) -> Tuple[Dict[int, Any], Set[int], List[List[str]], List[List[str]]]:
        stream_keys = ('name', 'stream_id', 'color',
                       'invite_only', 'description')

        # Canonicalize color formats, since zulip server versions may use
        # different formats
        for subscription in subscriptions:
            subscription['color'] = canonicalize_color(subscription['color'])

        # Mapping of stream-id to all available stream info
        # Stream IDs for muted streams
        # Limited stream info sorted by name (used in display)
        return (
            {stream['stream_id']: stream for stream in subscriptions},
            {stream['stream_id'] for stream in subscriptions
             if stream['in_home_view'] is False},
            sorted([[stream[key] for key in stream_keys]
                    for stream in subscriptions if stream['pin_to_top']],
                   key=lambda s: s[0].lower()),
            sorted([[stream[key] for key in stream_keys]
                    for stream in subscriptions if not stream['pin_to_top']],
                   key=lambda s: s[0].lower())
        )

    def _group_info_from_realm_user_groups(self,
                                           groups: List[Dict[str, Any]]
                                           ) -> List[str]:
        """
        Stores group information in the model and returns a list of
        group_names which helps in group typeahead. (Eg: @*terminal*)
        """
        for sub_group in groups:
            self.user_group_by_id[sub_group['id']] = {
                key: sub_group[key] for key in sub_group if key != 'id'}
        user_group_names = [self.user_group_by_id[group_id]['name']
                            for group_id in self.user_group_by_id]
        # Sort groups for typeahead to work alphabetically (case-insensitive)
        user_group_names.sort(key=str.lower)
        return user_group_names

    def toggle_stream_muted_status(self, stream_id: int) -> bool:
        request = [{
            'stream_id': stream_id,
            'property': 'is_muted',
            'value': not self.is_muted_stream(stream_id)
            # True for muting and False for unmuting.
        }]
        response = self.client.update_subscription_settings(request)
        return response['result'] == 'success'

    def _handle_subscription_event(self, event: Event) -> None:
        """
        Handle changes in subscription (eg. muting/unmuting streams)
        """
        if hasattr(self.controller, 'view'):
            if event.get('property', None) == 'in_home_view':
                stream_id = event['stream_id']

                # FIXME: Does this always contain the stream_id?
                stream_button = (
                    self.controller.view.stream_id_to_button[stream_id]
                )

                if event['value']:  # Unmuting streams
                    self.muted_streams.remove(stream_id)
                    stream_button.mark_unmuted()
                else:  # Muting streams
                    self.muted_streams.add(stream_id)
                    stream_button.mark_muted()

                self.controller.update_screen()

    def _handle_typing_event(self, event: Event) -> None:
        """
        Handle typing notifications (in private messages)
        """
        if hasattr(self.controller, 'view'):
            # If the user is in pm narrow with the person typing
            narrow = self.narrow
            if (len(narrow) == 1 and narrow[0][0] == 'pm_with'
                    and event['sender']['email'] in narrow[0][1].split(',')):
                if event['op'] == 'start':
                    user = self.user_dict[event['sender']['email']]
                    self.controller.view.set_footer_text([
                        ' ',
                        ('code', user['full_name']),
                        ' is typing...'
                    ])
                elif event['op'] == 'stop':
                    self.controller.view.set_footer_text()
                else:
                    raise RuntimeError("Unknown typing event operation")

    def notify_user(self, message: Message) -> str:
        """
        return value signifies if notification failed, if it should occur
        """
        # Check if notifications are enabled by the user.
        # It is disabled by default.
        if not self.controller.notify_enabled:
            return ""
        if message['sender_id'] == self.user_id:
            return ""

        recipient = ''
        if message['type'] == 'private':
            target = 'you'
            if len(message['display_recipient']) > 2:
                extra_targets = [target] + [
                    recip['full_name']
                    for recip in message['display_recipient']
                    if recip['id'] not in (self.user_id, message['sender_id'])
                ]
                target = ', '.join(extra_targets)
            recipient = ' (to {})'.format(target)
        elif message['type'] == 'stream' and (
            {'mentioned', 'wildcard_mentioned'}.intersection(
                set(message['flags'])
            )
            or self.stream_dict[message['stream_id']]['desktop_notifications']
        ):
            recipient = ' (to {} -> {})'.format(message['display_recipient'],
                                                message['subject'])

        if recipient:
            return notify((self.server_name + ":\n"
                           + message['sender_full_name'] + recipient),
                          message['content'])
        return ""

    def _handle_message_event(self, event: Event) -> None:
        """
        Handle new messages (eg. add message to the end of the view)
        """
        message = event['message']
        # sometimes `flags` are missing in `event` so initialize
        # an empty list of flags in that case.
        message['flags'] = event.get('flags', [])
        # We need to update the topic order in index, unconditionally.
        if message['type'] == 'stream':
            self._update_topic_index(message['stream_id'],
                                     message['subject'])
            # If the topic view is toggled for incoming message's
            # recipient stream, then we re-arrange topic buttons
            # with most recent at the top.
            if (hasattr(self.controller, 'view')
                and self.controller.view.left_panel.is_in_topic_view
                and message['stream_id'] == self.controller.view.
                    topic_w.stream_button.stream_id):
                self.controller.view.topic_w.update_topics_list(
                    message['stream_id'], message['subject'],
                    message['sender_id'])
                self.controller.update_screen()

        # We can notify user regardless of whether UI is rendered or not,
        # but depend upon the UI to indicate failures.
        failed_command = self.notify_user(message)
        if (failed_command
                and hasattr(self.controller, 'view')
                and not self._notified_user_of_notification_failure):
            notice_template = (
                "You have enabled notifications, but your notification "
                "command '{}' could not be found."
                "\n\n"
                "The application will continue attempting to run this command "
                "in this session, but will not notify you again."
                "\n\n"
                "Press '{}' to close this window."
            )
            notice = notice_template.format(failed_command,
                                            keys_for_command("GO_BACK").pop())
            self.controller.popup_with_message(notice, width=50)
            self.controller.update_screen()
            self._notified_user_of_notification_failure = True

        # Index messages before calling set_count.
        self.index = index_messages([message], self, self.index)
        if 'read' not in message['flags']:
            set_count([message['id']], self.controller, 1)

        if hasattr(self.controller, 'view') and self.found_newest:
            if self.msg_list.log:
                last_message = self.msg_list.log[-1].original_widget.message
            else:
                last_message = None
            msg_w_list = create_msg_box_list(self, [message['id']],
                                             last_message=last_message)
            if not msg_w_list:
                return
            else:
                msg_w = msg_w_list[0]

            if not self.narrow:
                self.msg_list.log.append(msg_w)

            elif (self.narrow[0][1] == 'mentioned'
                    and 'mentioned' in message['flags']):
                self.msg_list.log.append(msg_w)

            elif (self.narrow[0][1] == message['type']
                    and len(self.narrow) == 1):
                self.msg_list.log.append(msg_w)

            elif (message['type'] == 'stream'
                    and self.narrow[0][0] == "stream"):
                recipient_stream = message['display_recipient']
                narrow_stream = self.narrow[0][1]
                append_to_stream = recipient_stream == narrow_stream

                if (append_to_stream
                    and (len(self.narrow) == 1
                         or (len(self.narrow) == 2
                             and self.narrow[1][1] == message['subject']))):
                    self.msg_list.log.append(msg_w)

            elif (message['type'] == 'private' and len(self.narrow) == 1
                    and self.narrow[0][0] == "pm_with"):
                narrow_recipients = self.recipients
                message_recipients = frozenset(
                    [user['id'] for user in message['display_recipient']])
                if narrow_recipients == message_recipients:
                    self.msg_list.log.append(msg_w)
            self.controller.update_screen()

    def _update_topic_index(self, stream_id: int, topic_name: str) -> None:
        """
        Update topic order in index based on incoming message.
        Helper method called by _handle_message_event
        """
        topic_index = self.index['topics'][stream_id]
        for topic_iterator, topic in enumerate(topic_index):
            if topic == topic_name:
                topic_index.insert(0, topic_index.pop(topic_iterator))
                return
        # No previous topics with same topic names are found
        # hence, it must be a new topic.
        topic_index.insert(0, topic_name)

    def _handle_update_message_event(self, event: Event) -> None:
        """
        Handle updated (edited) messages (changed content/subject)
        """
        message_id = event['message_id']
        # If the message is indexed
        if self.index['messages'].get(message_id):
            message = self.index['messages'][message_id]
            self.index['edited_messages'].add(message_id)

            if 'rendered_content' in event:
                message['content'] = event['rendered_content']
                self.index['messages'][message_id] = message
                self._update_rendered_view(message_id)

            # 'subject' is not present in update event if
            # the event didn't have a 'subject' update.
            if 'subject' in event:
                new_subject = event['subject']
                for msg_id in event['message_ids']:
                    self.index['messages'][msg_id]['subject'] = new_subject
                    self._update_rendered_view(msg_id)

    def _handle_reaction_event(self, event: Event) -> None:
        """
        Handle change to reactions on a message
        """
        message_id = event['message_id']
        # If the message is indexed
        if self.index['messages'][message_id] != {}:

            message = self.index['messages'][message_id]
            if event['op'] == 'add':
                message['reactions'].append(
                    {
                        'user': event['user'],
                        'reaction_type': event['reaction_type'],
                        'emoji_code': event['emoji_code'],
                        'emoji_name': event['emoji_name'],
                    }
                )
            else:
                emoji_code = event['emoji_code']
                for reaction in message['reactions']:
                    # Since Who reacted is not displayed,
                    # remove the first one encountered
                    if reaction['emoji_code'] == emoji_code:
                        message['reactions'].remove(reaction)

            self.index['messages'][message_id] = message
            self._update_rendered_view(message_id)

    def _handle_update_message_flags_event(self, event: Event) -> None:
        """
        Handle change to message flags (eg. starred, read)
        """
        if event['all']:  # FIXME Should handle eventually
            return

        flag_to_change = event['flag']
        if flag_to_change not in {'starred', 'read'}:
            return

        if flag_to_change == 'read' and event['operation'] == 'remove':
            return

        indexed_message_ids = set(self.index['messages'])
        message_ids_to_mark = set(event['messages'])

        for message_id in message_ids_to_mark & indexed_message_ids:
            msg = self.index['messages'][message_id]
            if event['operation'] == 'add':
                if flag_to_change not in msg['flags']:
                    msg['flags'].append(flag_to_change)
            elif event['operation'] == 'remove':
                if flag_to_change in msg['flags']:
                    msg['flags'].remove(flag_to_change)
            else:
                raise RuntimeError(event, msg['flags'])

            self.index['messages'][message_id] = msg
            self._update_rendered_view(message_id)

        if event['operation'] == 'add' and flag_to_change == 'read':
            set_count(list(message_ids_to_mark & indexed_message_ids),
                      self.controller, -1)

    def _update_rendered_view(self, msg_id: int) -> None:
        """
        Helper method called by various _handle_* methods
        """
        # Update new content in the rendered view
        for msg_w in self.msg_list.log:
            msg_box = msg_w.original_widget
            if msg_box.message['id'] == msg_id:
                # Remove the message if it no longer belongs in the current
                # narrow.
                if (len(self.narrow) == 2
                        and msg_box.message['subject'] != self.narrow[1][1]):
                    self.msg_list.log.remove(msg_w)
                    # Change narrow if there are no messages left in the
                    # current narrow.
                    if not self.msg_list.log:
                        msg_w_list = create_msg_box_list(
                                        self, [msg_id],
                                        last_message=msg_box.last_message)
                        if msg_w_list:
                            self.controller.narrow_to_topic(
                                msg_w_list[0].original_widget)
                    self.controller.update_screen()
                    return

                msg_w_list = create_msg_box_list(
                                self, [msg_id],
                                last_message=msg_box.last_message)
                if not msg_w_list:
                    return
                else:
                    new_msg_w = msg_w_list[0]
                    msg_pos = self.msg_list.log.index(msg_w)
                    self.msg_list.log[msg_pos] = new_msg_w

                    # If this is not the last message in the view
                    # update the next message's last_message too.
                    if len(self.msg_list.log) != (msg_pos + 1):
                        next_msg_w = self.msg_list.log[msg_pos + 1]
                        msg_w_list = create_msg_box_list(
                            self, [next_msg_w.original_widget.message['id']],
                            last_message=new_msg_w.original_widget.message)
                        self.msg_list.log[msg_pos + 1] = msg_w_list[0]
                    self.controller.update_screen()
                    return

    def _register_desired_events(self, *, fetch_data: bool=False) -> str:
        fetch_types = None if not fetch_data else [
            'realm',
            'presence',
            'subscription',
            'message',
            'update_message_flags',
            'muted_topics',
            'realm_user',  # Enables cross_realm_bots
            'realm_user_groups',
            # zulip_version and zulip_feature_level are always returned in
            # POST /register from Feature level 3.
            'zulip_version',
        ]
        event_types = list(self.event_actions)
        try:
            response = self.client.register(event_types=event_types,
                                            fetch_event_types=fetch_types,
                                            client_gravatar=True,
                                            apply_markdown=True)
        except zulip.ZulipError as e:
            return str(e)

        if response['result'] == 'success':
            if fetch_data:
                self.initial_data.update(response)
            self.max_message_id = response['max_message_id']
            self.queue_id = response['queue_id']
            self.last_event_id = response['last_event_id']
            return ""
        return response['msg']

    @asynch
    def poll_for_events(self) -> None:
        reregister_timeout = 10
        queue_id = self.queue_id
        last_event_id = self.last_event_id
        while True:
            if queue_id is None:
                while True:
                    if self._register_desired_events():
                        queue_id = self.queue_id
                        last_event_id = self.last_event_id
                        break
                    time.sleep(reregister_timeout)

            response = self.client.get_events(
                queue_id=queue_id,
                last_event_id=last_event_id
            )

            if 'error' in response['result']:
                if response["msg"].startswith("Bad event queue id:"):
                    # Our event queue went away, probably because
                    # we were asleep or the server restarted
                    # abnormally.  We may have missed some
                    # events while the network was down or
                    # something, but there's not really anything
                    # we can do about it other than resuming
                    # getting new ones.
                    #
                    # Reset queue_id to register a new event queue.
                    queue_id = None
                time.sleep(1)
                continue

            for event in response['events']:
                last_event_id = max(last_event_id, int(event['id']))
                if event['type'] in self.event_actions:
                    self.event_actions[event['type']](event)