
import pickle as pickle
import locale
import os
import re
import socket
import sys

from PyQt5 import uic
from PyQt5.QtCore import Qt, QAbstractListModel, QAbstractTableModel, QEasingCurve, QModelIndex, QPropertyAnimation, QSortFilterProxyModel
from PyQt5.QtCore import QByteArray, QEvent, QMimeData, QPointF, QRectF, QRect, QSize, QTimer, QUrl, pyqtSignal, QT_TRANSLATE_NOOP
from PyQt5.QtGui import QBrush, QColor, QIcon, QKeyEvent, QLinearGradient, QMouseEvent, QPainter, QPainterPath, QPalette, QPen, QPixmap, QPolygonF
from PyQt5.QtWebKitWidgets import QWebView
from PyQt5.QtWidgets import QAction, QApplication, QItemDelegate, QStyledItemDelegate, QStyle
from PyQt5.QtWidgets import QButtonGroup, QComboBox, QFileDialog, QHBoxLayout, QListView, QMenu, QRadioButton, QTableView, QWidget

from application import log
from application.notification import IObserver, NotificationCenter, NotificationData, ObserverWeakrefProxy
from application.python.descriptor import WriteOnceAttribute
from application.python.threadpool import ThreadPool, run_in_threadpool
from application.python.types import MarkerType, Singleton
from application.python import Null
from application.system import makedirs, unlink
from collections import OrderedDict, deque
from datetime import datetime
from functools import partial
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from heapq import heappush
from httplib2 import Http, HttpLib2Error
from itertools import count
from oauth2client.client import OAuth2WebServerFlow, AccessTokenRefreshError
from oauth2client.file import Storage
from operator import attrgetter
from threading import Event
from urllib.parse import parse_qsl
from zope.interface import implementer

from sipsimple import addressbook
from sipsimple.account import AccountManager, BonjourAccount
from sipsimple.account.bonjour import BonjourServiceDescription
from sipsimple.configuration import ConfigurationManager, DefaultValue, Setting, SettingsState, SettingsObjectMeta, ObjectNotFoundError
from sipsimple.configuration.settings import SIPSimpleSettings
from sipsimple.core import BaseSIPURI, SIPURI
from sipsimple.threading import run_in_thread

from blink.configuration.datatypes import IconDescriptor, FileURL
from blink.resources import ApplicationData, Resources, IconManager
from blink.sessions import SessionManager, StreamDescription
from blink.messages import MessageManager
from blink.util import call_in_gui_thread, run_in_gui_thread, translate
from blink.widgets.buttons import SwitchViewButton
from blink.widgets.color import ColorHelperMixin
from blink.widgets.util import ContextMenuActions


__all__ = ['Group', 'Contact', 'ContactModel', 'ContactSearchModel', 'ContactListView', 'ContactSearchListView', 'ContactEditorDialog', 'URIUtils']

translation_table = dict.fromkeys(map(ord, ' \t'), None)


@implementer(IObserver)
class VirtualGroupManager(object, metaclass=Singleton):

    __groups__ = []

    def __init__(self):
        self.groups = {}
        notification_center = NotificationCenter()
        notification_center.add_observer(self, name='SIPApplicationWillStart')
        notification_center.add_observer(self, name='VirtualGroupWasActivated')
        notification_center.add_observer(self, name='VirtualGroupWasDeleted')

    def has_group(self, id):
        return id in self.groups

    def get_group(self, id):
        return self.groups[id]

    def get_groups(self):
        return list(self.groups.values())

    def handle_notification(self, notification):
        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    def _NH_SIPApplicationWillStart(self, notification):
        [cls() for cls in self.__groups__]

    def _NH_VirtualGroupWasActivated(self, notification):
        group = notification.sender
        self.groups[group.id] = group
        notification.center.post_notification('VirtualGroupManagerDidAddGroup', sender=self, data=NotificationData(group=group))

    def _NH_VirtualGroupWasDeleted(self, notification):
        group = notification.sender
        del self.groups[group.id]
        notification.center.post_notification('VirtualGroupManagerDidRemoveGroup', sender=self, data=NotificationData(group=group))


class VirtualGroupMeta(SettingsObjectMeta):
    def __init__(cls, name, bases, dic):
        if not (cls.__id__ is None or isinstance(cls.__id__, str)):
            raise TypeError("%s.__id__ must be None or a string" % name)
        super(VirtualGroupMeta, cls).__init__(name, bases, dic)
        if cls.__id__ is not None:
            VirtualGroupManager.__groups__.append(cls)


class VirtualGroup(SettingsState, metaclass=VirtualGroupMeta):
    __id__ = None

    name = Setting(type=str, default='')
    position = Setting(type=int, default=None, nillable=True)
    collapsed = Setting(type=bool, default=False)

    def __new__(cls):
        if cls.__id__ is None:
            raise ValueError("%s.__id__ must be defined in order to instantiate" % cls.__name__)
        instance = SettingsState.__new__(cls)
        configuration = ConfigurationManager()
        try:
            data = configuration.get(instance.__key__)
        except ObjectNotFoundError:
            pass
        else:
            instance.__setstate__(data)
        return instance

    def __repr__(self):
        return "%s()" % self.__class__.__name__

    @property
    def __key__(self):
        return ['Addressbook', 'VirtualGroups', self.__id__]

    @property
    def id(self):
        return self.__id__

    @run_in_thread('file-io')
    def save(self):
        """
        Store the virtual group into persistent storage.

        This method will post the VirtualGroupDidChange notification on save,
        regardless of whether the contact has been saved to persistent storage
        or not. A CFGManagerSaveFailed notification is posted if saving to the
        persistent configuration storage fails.
        """

        modified_settings = self.get_modified()

        if not modified_settings:
            return

        configuration = ConfigurationManager()
        notification_center = NotificationCenter()

        configuration.update(self.__key__, self.__getstate__())
        notification_center.post_notification('VirtualGroupDidChange', sender=self, data=NotificationData(modified=modified_settings))
        modified_data = modified_settings

        try:
            configuration.save()
        except Exception as e:
            log.exception()
            notification_center.post_notification('CFGManagerSaveFailed', sender=configuration, data=NotificationData(object=self, operation='save', modified=modified_data, exception=e))


class AllContactsList(object):
    def __init__(self):
        self.manager = addressbook.AddressbookManager()

    def __iter__(self):
        return iter(self.manager.get_contacts())

    def __getitem__(self, id):
        return self.manager.get_contact(id)

    def __contains__(self, id):
        return self.manager.has_contact(id)

    def __len__(self):
        return len(self.manager.get_contacts())

    __hash__ = None


@implementer(IObserver)
class AllContactsGroup(VirtualGroup):

    __id__ = 'all_contacts'

    name = Setting(type=str, default='All Contacts')
    contacts = WriteOnceAttribute()

    def __init__(self):
        self.contacts = AllContactsList()
        notification_center = NotificationCenter()
        notification_center.add_observer(self, name='AddressbookContactWasActivated')
        notification_center.add_observer(self, name='AddressbookContactWasDeleted')

    def __establish__(self):
        notification_center = NotificationCenter()
        notification_center.post_notification('VirtualGroupWasActivated', sender=self, data=NotificationData(contacts=list(self.contacts)))

    def handle_notification(self, notification):
        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    def _NH_AddressbookContactWasActivated(self, notification):
        contact = notification.sender
        notification.center.post_notification('VirtualGroupDidAddContact', sender=self, data=NotificationData(contact=contact))

    def _NH_AddressbookContactWasDeleted(self, notification):
        contact = notification.sender
        notification.center.post_notification('VirtualGroupDidRemoveContact', sender=self, data=NotificationData(contact=contact))


class PreferredMedia(str):
    @property
    def stream_descriptions(self):
        streams = set(self.split('+'))
        if 'video' in streams:
            streams.add('audio')
        return [StreamDescription(stream) for stream in streams]

    @property
    def autoconnect(self):
        return self != 'chat' and self != 'messages'


class BonjourNeighbourID(str):
    pass


class BonjourURI(str):
    def __new__(cls, value):
        instance = str.__new__(cls, str(value).partition(':')[2])
        instance.__uri__ = value
        return instance

    @property
    def user(self):
        return self.__uri__.user

    @property
    def host(self):
        return self.__uri__.host

    @property
    def transport(self):
        return self.__uri__.transport


class BonjourNeighbourURI(object):
    def __init__(self, id, uri):
        self.id = id
        self.uri = uri

    @property
    def type(self):
        return self.uri.transport.upper()

    def __repr__(self):
        return "%s(%r, %r)" % (self.__class__.__name__, self.id, self.uri.__uri__)

    def __setattr__(self, name, value):
        if name == 'uri' and not isinstance(value, BonjourURI):
            value = BonjourURI(value)
        object.__setattr__(self, name, value)


class BonjourNeighbourURIList(object):
    def __init__(self, uris):
        self._uri_map = OrderedDict((uri.id, uri) for uri in uris)

    def __getitem__(self, id):
        return self._uri_map[id]

    def __contains__(self, id):
        return id in self._uri_map

    def __iter__(self):
        return iter(list(self._uri_map.values()))

    def __len__(self):
        return len(self._uri_map)

    __hash__ = None

    def get(self, key, default=None):
        return self._uri_map.get(key, default)

    def add(self, uri):
        self._uri_map[uri.id] = uri

    def pop(self, id, *args):
        return self._uri_map.pop(id, *args)

    def remove(self, uri):
        self._uri_map.pop(uri.id, None)

    @property
    def default(self):
        return sorted(self, key=lambda item: 0 if item.uri.transport == 'tls' else 1 if item.uri.transport == 'tcp' else 2)[0] if self._uri_map else None


class BonjourPresence(object):
    def __init__(self, state=None, note=None):
        self.state = state
        self.note = note


class BonjourNeighbour(object):
    id = WriteOnceAttribute()

    def __init__(self, id, name, hostname, uris, presence=None):
        self.id = BonjourNeighbourID(id) if isinstance(id, str) else id
        self.name = name
        self.hostname = hostname
        self.uris = BonjourNeighbourURIList(uris)
        self.presence = presence or BonjourPresence()
        self.preferred_media = PreferredMedia('audio')


class BonjourNeighboursList(object):
    def __init__(self):
        self._contact_map = {}

    def __getitem__(self, id):
        return self._contact_map[id]

    def __contains__(self, id):
        return id in self._contact_map

    def __iter__(self):
        return iter(list(self._contact_map.values()))

    def __len__(self):
        return len(self._contact_map)

    __hash__ = None

    def add(self, contact):
        self._contact_map[contact.id] = contact

    def pop(self, id, *args):
        return self._contact_map.pop(id, *args)

    def remove(self, contact):
        return self._contact_map.pop(contact.id, None)


@implementer(IObserver)
class BonjourNeighboursManager(object, metaclass=Singleton):

    contacts = WriteOnceAttribute()

    def __init__(self):
        self.contacts = BonjourNeighboursList()
        notification_center = NotificationCenter()
        notification_center.add_observer(self, sender=BonjourAccount())

    def handle_notification(self, notification):
        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    def _NH_BonjourAccountDidAddNeighbour(self, notification):
        neighbour, record = notification.data.neighbour, notification.data.record
        contact_id = record.id or neighbour
        contact_uri = BonjourNeighbourURI(neighbour, record.uri)
        try:
            contact = self.contacts[contact_id]
        except KeyError:
            contact = BonjourNeighbour(contact_id, record.name, record.host, [contact_uri], BonjourPresence(record.presence.state, record.presence.note))
            self.contacts.add(contact)
            notification.center.post_notification('BonjourNeighboursManagerDidAddContact', sender=self, data=NotificationData(contact=contact))
        else:
            contact.uris.add(contact_uri)
            notification.center.post_notification('BonjourNeighboursManagerDidUpdateContact', sender=self, data=NotificationData(contact=contact))

    def _NH_BonjourAccountDidRemoveNeighbour(self, notification):
        contact_id = notification.data.record.id or notification.data.neighbour
        contact = self.contacts[contact_id]
        contact.uris.pop(notification.data.neighbour, None)
        if not contact.uris:
            self.contacts.remove(contact)
            notification.center.post_notification('BonjourNeighboursManagerDidRemoveContact', sender=self, data=NotificationData(contact=contact))
        else:
            notification.center.post_notification('BonjourNeighboursManagerDidUpdateContact', sender=self, data=NotificationData(contact=contact))

    def _NH_BonjourAccountDidUpdateNeighbour(self, notification):
        neighbour, record = notification.data.neighbour, notification.data.record
        contact = self.contacts[record.id or neighbour]
        contact_uri = contact.uris[neighbour]
        contact.name = record.name
        contact.host = record.host
        contact.presence.state = record.presence.state
        contact.presence.note = record.presence.note
        contact_uri.uri = record.uri
        notification.center.post_notification('BonjourNeighboursManagerDidUpdateContact', sender=self, data=NotificationData(contact=contact))


@implementer(IObserver)
class BonjourNeighboursGroup(VirtualGroup):

    __id__ = 'bonjour_neighbours'

    name = Setting(type=str, default='Bonjour Neighbours')
    contacts = property(lambda self: self.__manager__.contacts)

    def __init__(self):
        self.__manager__ = BonjourNeighboursManager()
        notification_center = NotificationCenter()
        notification_center.add_observer(self, sender=BonjourAccount())
        notification_center.add_observer(self, sender=self.__manager__)

    def handle_notification(self, notification):
        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    def _NH_SIPAccountWillActivate(self, notification):
        notification.center.post_notification('VirtualGroupWasActivated', sender=self, data=NotificationData(contacts=[]))

    def _NH_SIPAccountDidDeactivate(self, notification):
        notification.center.post_notification('VirtualGroupWasDeactivated', sender=self)

    def _NH_BonjourNeighboursManagerDidAddContact(self, notification):
        notification.center.post_notification('VirtualGroupDidAddContact', sender=self, data=notification.data)

    def _NH_BonjourNeighboursManagerDidRemoveContact(self, notification):
        notification.center.post_notification('VirtualGroupDidRemoveContact', sender=self, data=notification.data)

    def _NH_BonjourNeighboursManagerDidUpdateContact(self, notification):
        notification.center.post_notification('VirtualContactDidChange', sender=notification.data.contact)


class GoogleContactID(str):
    pass


class GoogleContactIconMetadata(object):
    def __init__(self, metadata):
        metadata = metadata or {'source': {'id': None, 'type': None}}
        self.__dict__.update({name: GoogleContactIconMetadata(value) if isinstance(value, dict) else value for name, value in metadata.items()})

    def __getattr__(self, name):  # stop PyCharm from complaining about undefined attributes
        raise AttributeError(name)


class GoogleContactIcon(object):
    def __init__(self, url, metadata):
        self.url = url
        self.metadata = GoogleContactIconMetadata(metadata)
        self.downloaded_url = None

    @property
    def alternate_url(self):
        if self.metadata.source.type == 'CONTACT':
            return 'https://www.google.com/m8/feeds/photos/media/default/' + self.metadata.source.id
        else:
            return None

    @property
    def needs_update(self):
        return self.url != self.downloaded_url


class GoogleContactIconRetriever(object):
    threadpool = ThreadPool(name='google-icons', min_threads=1, max_threads=10)
    threadpool.start()

    def __init__(self, contact, credentials):
        self.contact = contact
        self.credentials = credentials
        self._event = Event()

    def wait(self):
        return self._event.wait()

    @run_in_threadpool(threadpool)
    def run(self):
        owner = self.contact.name or self.contact.organization or self.contact.id
        icon = self.contact.icon
        http = self.credentials.authorize(Http(timeout=5))
        try:
            if icon.url is not None:
                response, content = http.request(icon.url + '?size={}'.format(IconManager.max_size))
            else:
                response = content = None
        except (HttpLib2Error, socket.error) as e:
            log.warning('could not retrieve icon for {owner}: {exception!s}'.format(owner=owner, exception=e))
        else:
            if response is None:
                icon_manager = IconManager()
                icon_manager.store_data(self.contact.id, None)
                icon.downloaded_url = None
            elif response['status'] == '200' and response['content-type'].startswith('image/'):
                icon_manager = IconManager()
                try:
                    icon_manager.store_data(self.contact.id, content)
                except Exception as e:
                    log.error('could not store icon for {owner}: {exception!s}'.format(owner=owner, exception=e))
                else:
                    icon.downloaded_url = icon.url
            elif response['status'] in ('403', '404') and icon.alternate_url:  # private or unavailable photo. use old GData protocol if alternate_url is available.
                try:
                    response, content = http.request(icon.alternate_url, headers={'GData-Version': '3.0'})
                except (HttpLib2Error, socket.error) as e:
                    log.warning('could not retrieve icon for {owner}: {exception!s}'.format(owner=owner, exception=e))
                else:
                    if response['status'] == '200' and response['content-type'].startswith('image/'):
                        icon_manager = IconManager()
                        try:
                            icon_manager.store_data(self.contact.id, content)
                        except Exception as e:
                            log.error('could not store icon for {owner}: {exception!s}'.format(owner=owner, exception=e))
                        else:
                            icon.downloaded_url = icon.url
                    else:
                        log.error('could not retrieve icon for {} (status={}, content-type={!r})'.format(owner, response['status'], response['content-type']))
            else:
                log.error('could not retrieve icon for {} (status={}, content-type={!r})'.format(owner, response['status'], response['content-type']))
        finally:
            self._event.set()


class GoogleContactURI(object):
    id = property(lambda self: self.uri)

    def __init__(self, uri, type, default=False):
        self.uri = uri.strip() if uri is not None else uri
        self.type = type
        self.default = default

    def __repr__(self):
        return "%s(%r, %r, %r)" % (self.__class__.__name__, self.uri, self.type, self.default)

    @classmethod
    def from_number(cls, number):
        return cls(number.get('canonicalForm') or number['value'], number.get('formattedType', 'Other'), number['metadata'].get('primary', False))

    @classmethod
    def from_im(cls, address):
        return cls(re.sub('^sips?:', '', address['username']), address.get('formattedType', 'Other'), address['metadata'].get('primary', False))

    @classmethod
    def from_email(cls, address):
        return cls(re.sub('^sips?:', '', address['value']), address.get('formattedType', 'Other'), address['metadata'].get('primary', False))


class GoogleContactURIList(object):
    def __init__(self, uris):
        self._uri_map = OrderedDict((uri.id, uri) for uri in uris)

    def __getitem__(self, id):
        return self._uri_map[id]

    def __contains__(self, id):
        return id in self._uri_map

    def __iter__(self):
        return iter(list(self._uri_map.values()))

    def __len__(self):
        return len(self._uri_map)

    __hash__ = None

    def get(self, key, default=None):
        return self._uri_map.get(key, default)

    def add(self, uri):
        self._uri_map[uri.id] = uri

    def pop(self, id, *args):
        return self._uri_map.pop(id, *args)

    def remove(self, uri):
        self._uri_map.pop(uri.id, None)

    @property
    def default(self):
        return next((uri for uri in self if uri.default), None)


class GooglePresence(object):
    def __init__(self, state=None, note=None):
        self.state = state
        self.note = note


class GoogleContact(object):
    id = WriteOnceAttribute()

    def __init__(self, id, name, organization, uris, icon=None, etag=None):
        self.id = GoogleContactID(id)
        self.name = name
        self.organization = organization
        self.uris = GoogleContactURIList(uris)
        self.icon = icon
        self.etag = etag
        self.presence = GooglePresence()
        self.preferred_media = PreferredMedia('audio')

    def __reduce__(self):
        return self.__class__, (self.id, self.name, self.organization, self.uris, self.icon, self.etag)

    def __repr__(self):
        return "<GoogleContact: id={0.id!r}, name={0.name!r}, organization={0.organization!r}, uris={0.uris!r}, icon={0.icon!r}, etag={0.etag!r}>".format(self)

    def update(self, contact_data):
        assert self.id == contact_data['resourceName']

        etag = contact_data['etag']
        name = next((entry['displayName'] for entry in contact_data.get('names', Null)), None)
        organization = next((entry.get('name') for entry in contact_data.get('organizations', Null)), None)
        icon_url, icon_metadata = next(((entry['url'], entry['metadata']) for entry in contact_data.get('photos', Null)), (None, None))

        name = name.strip() if name is not None else 'Unknown'
        organization = organization.strip() if organization is not None else organization

        uris = [GoogleContactURI.from_number(number) for number in contact_data.get('phoneNumbers', Null)]
        uris.extend(GoogleContactURI.from_im(address) for address in contact_data.get('imClients', Null))
        uris.extend(GoogleContactURI.from_email(address) for address in contact_data.get('emailAddresses', Null))

        name = name if not organization else '%s (%s)' % (name, organization)
        self.name = name
        self.organization = organization
        self.uris = GoogleContactURIList(uris)
        self.icon.url = icon_url
        self.icon.metadata = GoogleContactIconMetadata(icon_metadata)
        self.etag = etag

    @classmethod
    def from_google_data(cls, contact_data):
        contact_id = contact_data['resourceName']
        etag = contact_data['etag']

        name = next((entry['displayName'] for entry in contact_data.get('names', Null)), None)
        organization = next((entry.get('name') for entry in contact_data.get('organizations', Null)), None)
        icon_url, icon_metadata = next(((entry['url'], entry['metadata']) for entry in contact_data.get('photos', Null)), (None, None))

        name = name.strip() if name is not None else translate('contact_list', 'Unknown')
        organization = organization.strip() if organization is not None else organization

        uris = [GoogleContactURI.from_number(number) for number in contact_data.get('phoneNumbers', Null)]
        uris.extend(GoogleContactURI.from_im(address) for address in contact_data.get('imClients', Null))
        uris.extend(GoogleContactURI.from_email(address) for address in contact_data.get('emailAddresses', Null))

        icon = GoogleContactIcon(icon_url, icon_metadata)
        name = name if not organization else '%s (%s)' % (name, organization)
        return cls(contact_id, name, organization, uris, icon, etag)


class GoogleContactsList(object):
    def __init__(self):
        self._contact_map = {}

    def __getitem__(self, id):
        return self._contact_map[id]

    def __contains__(self, id):
        return id in self._contact_map

    def __iter__(self):
        return iter(list(self._contact_map.values()))

    def __len__(self):
        return len(self._contact_map)

    __hash__ = None

    @property
    def ids(self):
        return set(self._contact_map)

    def add(self, contact):
        self._contact_map[contact.id] = contact

    def pop(self, id, *args):
        return self._contact_map.pop(id, *args)


class GoogleAuthorizationView(QWebView):
    finished = pyqtSignal()
    accepted = pyqtSignal(str, str)  # accepted.emit(code, email)
    rejected = pyqtSignal()

    success_token = 'Success code='
    failure_token = 'Denied error=access_denied'

    def __init__(self, parent=None):
        super(GoogleAuthorizationView, self).__init__(parent)
        self.email = None
        self.setWindowTitle('Blink Google Authorization')
        self.setWindowIcon(QIcon(Resources.get('icons/blink48.png')))
        self.selectionChanged.connect(self._SH_SelectionChanged)
        self.titleChanged.connect(self._SH_TitleChanged)
        self.urlChanged.connect(self._SH_URLChanged)
        self.resize(500, 630)

    @run_in_gui_thread
    def open(self, url):
        self.load(QUrl.fromEncoded(url.encode()))
        self.show()

    def closeEvent(self, event):
        super(GoogleAuthorizationView, self).closeEvent(event)
        self.finished.emit()
        self.rejected.emit()

    def _SH_SelectionChanged(self):
        self.email = self.page().mainFrame().findFirstElement('input#Email').evaluateJavaScript('this.value') or self.email  # the input changes to None during submit

    # TODO: Check if this is still needed -- Tijmen
    def _SH_TitleChanged(self, title):
        self.setWindowTitle(title)
        if title == self.failure_token:
            self.hide()
            self.finished.emit()
            self.rejected.emit()
        elif title.startswith(self.success_token):
            code = title[len(self.success_token):]
            self.hide()
            self.finished.emit()
            self.accepted.emit(code, self.email)

    def _SH_URLChanged(self, url):
        if '127.0.0.1' in url.host():
            params = dict(parse_qsl(url.query()))
            if 'error' in params:
                self.hide()
                self.finished.emit()
                self.rejected.emit()
            elif 'code' in params:
                self.hide()
                self.finished.emit()
                self.accepted.emit(params['code'], self.email)


class GoogleAuthorizationStorage(Storage):
    def __init__(self, filename):
        self._directory = os.path.dirname(filename)
        super(GoogleAuthorizationStorage, self).__init__(filename)

    def put(self, credentials):
        makedirs(self._directory)
        super(GoogleAuthorizationStorage, self).put(credentials)


class GoogleAuthorization(object):
    client_id = '28246556873-20215d5a5ttd0l3sa7cchsm7hklh2d3c.apps.googleusercontent.com'
    client_secret = '3L8FDV5LELGmMIwr3NhfaZsq'
    redirect_uri = 'http://127.0.0.1'
    scope = 'https://www.googleapis.com/auth/contacts.readonly profile'

    def __init__(self):
        settings = SIPSimpleSettings()
        self.storage = GoogleAuthorizationStorage(ApplicationData.get('google/credentials'))
        self.flow = OAuth2WebServerFlow(client_id=self.client_id, client_secret=self.client_secret, scope=self.scope, redirect_uri=self.redirect_uri, login_hint=settings.google_contacts.username, user_agent=settings.user_agent)
        self.view = GoogleAuthorizationView()
        self.view.accepted.connect(self._SH_AuthorizationAccepted)
        self.view.rejected.connect(self._SH_AuthorizationRejected)

    @property
    def credentials(self):
        return self.storage.get()

    @property
    def email(self):
        return self.flow.login_hint

    @email.setter
    def email(self, email):
        self.flow.login_hint = email

    @run_in_thread('network-io')
    def request_credentials(self):
        credentials = self.storage.get()
        if credentials is None or credentials.invalid:
            self.view.open(self.flow.step1_get_authorize_url())
        else:
            notification_center = NotificationCenter()
            notification_center.post_notification('GoogleAuthorizationWasAccepted', sender=self, data=NotificationData(credentials=credentials, email=self.email))

    @run_in_thread('network-io')
    def _SH_AuthorizationAccepted(self, code, email):
        self.email = email
        credentials = self.flow.step2_exchange(code)
        self.storage.put(credentials)
        notification_center = NotificationCenter()
        notification_center.post_notification('GoogleAuthorizationWasAccepted', sender=self, data=NotificationData(credentials=credentials, email=email))

    @run_in_thread('network-io')
    def _SH_AuthorizationRejected(self):
        notification_center = NotificationCenter()
        notification_center.post_notification('GoogleAuthorizationWasRejected', sender=self)


@implementer(IObserver)
class GoogleContactsManager(object, metaclass=Singleton):

    def __init__(self):
        self.contacts = GoogleContactsList()
        self.running = False
        self.active = False
        self.auth = None
        self._service = None
        self._sync_timer = None
        self._sync_token = None
        self._initialize()
        notification_center = NotificationCenter()
        notification_center.add_observer(self, name='SIPApplicationDidStart')
        notification_center.add_observer(self, name='SIPApplicationWillEnd')
        notification_center.add_observer(self, name='CFGSettingsObjectDidChange', sender=SIPSimpleSettings())

    @property
    def active(self):
        return self.__dict__['active']

    @active.setter
    def active(self, value):
        old_value = self.__dict__.get('active', False)
        new_value = self.__dict__['active'] = value
        if old_value != new_value:
            notification_center = NotificationCenter()
            if new_value:
                notification_center.post_notification('GoogleContactsManagerDidActivate', sender=self)
            else:
                notification_center.post_notification('GoogleContactsManagerDidDeactivate', sender=self)

    @run_in_gui_thread
    def _initialize(self):  # object is instantiated from a non-UI thread, while these need to be created in the UI thread
        self.auth = GoogleAuthorization()
        self._sync_timer = QTimer()
        self._sync_timer.setInterval(60 * 1000)  # a minute (in milliseconds)
        self._sync_timer.setSingleShot(True)
        self._sync_timer.timeout.connect(self.sync_contacts)
        try:
            self.contacts, self._sync_token = pickle.load(open(ApplicationData.get('google/contacts')))
        except Exception:
            pass
        notification_center = NotificationCenter()
        notification_center.add_observer(self, sender=self.auth)

    @run_in_gui_thread
    def _start(self):
        if not self.running:
            self.running = True
            self.auth.request_credentials()

    @run_in_gui_thread
    def _stop(self):
        if self.running:
            self.running = False
            self.auth.view.hide()
            self._sync_timer.stop()
            self._terminate()

    @run_in_thread('network-io')
    def _terminate(self):
        self.active = False

    @run_in_thread('network-io', scheduled=True)
    def sync_contacts(self):
        if not self.active:
            return

        # A person's available attributes:
        #
        # addresses, age_range, biographies, birthdays, bragging_rights, cover_photos, email_addresses, events, genders,
        # im_clients, interests, locales, memberships, metadata, names, nicknames, occupations, organizations, phone_numbers,
        # photos, relations, relationship_interests, relationship_statuses, residences, skills, taglines, urls

        person_fields = 'email_addresses,im_clients,metadata,names,organizations,phone_numbers,photos,urls'

        try:
            connections, sync_token = self._get_connections(person_fields, sync_token=self._sync_token)
        except AccessTokenRefreshError:
            self.auth.request_credentials()
            return
        except HttpError as e:
            if e.resp.status == 400 and self._sync_token is not None:  # one reason why we get 400 is that the sync token is expired
                self._sync_token = None
                self.sync_contacts()
                return
            log.warning('Could not fetch Google contacts: {!s}'.format(e))
        except (HttpLib2Error, socket.error) as e:
            log.warning('Could not fetch Google contacts: {!s}'.format(e))
        else:
            added_contacts = []
            modified_contacts = []
            deleted_contact_ids = self.contacts.ids - {contact['resourceName'] for contact in connections} if self._sync_token is None else set()

            for contact_data in connections:
                contact_id = contact_data['resourceName']
                if contact_data['metadata'].get('deleted') is True:
                    if contact_id in self.contacts:
                        deleted_contact_ids.add(contact_id)
                    continue
                try:
                    contact = self.contacts[contact_id]
                except KeyError:
                    contact = GoogleContact.from_google_data(contact_data)
                    if contact.uris:
                        added_contacts.append(contact)
                else:
                    if contact.etag != contact_data['etag']:
                        contact.update(contact_data)
                        if contact.uris:
                            modified_contacts.append(contact)
                        else:
                            deleted_contact_ids.add(contact.id)

            notification_center = NotificationCenter()
            for contact_id in deleted_contact_ids:
                contact = self.contacts.pop(contact_id)
                notification_center.post_notification('GoogleContactsManagerDidRemoveContact', sender=self, data=NotificationData(contact=contact))
            for contact in added_contacts:
                self.contacts.add(contact)
                notification_center.post_notification('GoogleContactsManagerDidAddContact', sender=self, data=NotificationData(contact=contact))
            for contact in modified_contacts:
                notification_center.post_notification('GoogleContactsManagerDidUpdateContact', sender=self, data=NotificationData(contact=contact))

            icon_retrievers = [GoogleContactIconRetriever(contact, self.auth.credentials) for contact in self.contacts if contact.icon.needs_update]
            for retriever in icon_retrievers:
                retriever.run()
            for retriever in icon_retrievers:
                retriever.wait()
                notification_center.post_notification('GoogleContactsManagerDidUpdateContact', sender=self, data=NotificationData(contact=retriever.contact))

            GoogleContactIconRetriever.threadpool.compact()

            self._sync_token = sync_token

            if added_contacts or modified_contacts or deleted_contact_ids or icon_retrievers:
                filename = ApplicationData.get('google/contacts')
                tempname = '{}.{}'.format(filename, os.getpid())
                try:
                    makedirs(os.path.dirname(filename))
                    with open(tempname, 'wb') as f:
                        pickle.dump((self.contacts, self._sync_token), f)
                    if sys.platform == 'win32':
                        unlink(filename)
                    os.rename(tempname, filename)
                except Exception as e:
                    log.error('could not save google contacts: %s' % e)

        call_in_gui_thread(self._sync_timer.start)

    def _get_connections(self, person_fields, sync_token=None):
        connections = []
        request = self._service.people().connections().list(resourceName='people/me', personFields=person_fields, syncToken=sync_token, requestSyncToken=True, pageSize=2000)
        while request is not None:
            response = request.execute()
            connections.extend(response.get('connections', []))
            sync_token = response.get('nextSyncToken', sync_token)
            request = self._service.people().connections().list_next(request, response)
        return connections, sync_token

    def handle_notification(self, notification):
        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    def _NH_GoogleAuthorizationWasAccepted(self, notification):
        settings = SIPSimpleSettings()
        settings.google_contacts.username = notification.data.email
        settings.save()
        try:
            # self._service = build('people', 'v1', credentials=notification.data.credentials, http=Http(timeout=10), cache_discovery=False)  # todo: what's the best fix for cache?
            # Http can't be used like this in this version, see https://github.com/googleapis/google-api-python-client/issues/851

            self._service = build('people', 'v1', credentials=notification.data.credentials, cache_discovery=False)  # todo: what's the best fix for cache?
        except Exception as e:
            log.error('Error fetching Google contacts: %s' % str(e))
        else:
            self.active = True
            self.sync_contacts()  # sync_contacts is always scheduled in order to not queue posting notifications until after sync_contacts finishes, when called from a notification handler

    def _NH_GoogleAuthorizationWasRejected(self, notification):
        self._service = None
        self.active = False
        self.running = False
        settings = SIPSimpleSettings()
        settings.google_contacts.enabled = False
        settings.save()

    def _NH_SIPApplicationDidStart(self, notification):
        settings = SIPSimpleSettings()
        if settings.google_contacts.enabled:
            self._start()

    def _NH_SIPApplicationWillEnd(self, notification):
        self._stop()

    def _NH_CFGSettingsObjectDidChange(self, notification):
        if 'google_contacts.enabled' in notification.data.modified:
            if notification.sender.google_contacts.enabled:
                self._start()
            else:
                self._stop()


@implementer(IObserver)
class GoogleContactsGroup(VirtualGroup):

    __id__ = 'google_contacts'

    name = Setting(type=str, default='Google Contacts')
    contacts = property(lambda self: self.__manager__.contacts)

    def __init__(self):
        self.__manager__ = GoogleContactsManager()
        notification_center = NotificationCenter()
        notification_center.add_observer(self, sender=self.__manager__)

    def handle_notification(self, notification):
        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    def _NH_GoogleContactsManagerDidActivate(self, notification):
        notification.center.post_notification('VirtualGroupWasActivated', sender=self, data=NotificationData(contacts=list(self.contacts)))

    def _NH_GoogleContactsManagerDidDeactivate(self, notification):
        notification.center.post_notification('VirtualGroupWasDeactivated', sender=self)

    def _NH_GoogleContactsManagerDidAddContact(self, notification):
        notification.center.post_notification('VirtualGroupDidAddContact', sender=self, data=notification.data)

    def _NH_GoogleContactsManagerDidRemoveContact(self, notification):
        notification.center.post_notification('VirtualGroupDidRemoveContact', sender=self, data=notification.data)

    def _NH_GoogleContactsManagerDidUpdateContact(self, notification):
        notification.center.post_notification('VirtualContactDidChange', sender=notification.data.contact)


class DummyContactURI(object):
    id = property(lambda self: self.uri)

    def __init__(self, uri, type='', default=False):
        self.uri = uri
        self.type = type
        self.default = default

    def __repr__(self):
        return "%s(%r, %r, %r)" % (self.__class__.__name__, self.uri, self.type, self.default)


class DummyContactURIList(object):
    def __init__(self, uris):
        self._uri_map = OrderedDict((uri.id, uri) for uri in uris)

    def __getitem__(self, id):
        return self._uri_map[id]

    def __contains__(self, id):
        return id in self._uri_map

    def __iter__(self):
        return iter(list(self._uri_map.values()))

    def __len__(self):
        return len(self._uri_map)

    __hash__ = None

    def get(self, key, default=None):
        return self._uri_map.get(key, default)

    def add(self, uri):
        self._uri_map[uri.id] = uri

    def pop(self, id, *args):
        return self._uri_map.pop(id, *args)

    def remove(self, uri):
        self._uri_map.pop(uri.id, None)

    @property
    def default(self):
        try:
            return next(uri for uri in self if uri.default)
        except StopIteration:
            return None


class DummyPresence(object):
    def __init__(self, state=None, note=None):
        self.state = state
        self.note = note


class DummyContact(object):
    def __init__(self, name, uris):
        self.name = name
        self.uris = DummyContactURIList(uris)
        self.presence = DummyPresence()
        self.preferred_media = PreferredMedia('audio')

    def __reduce__(self):
        return self.__class__, (self.name, self.uris)


class RelocationInfo(object):
    def __init__(self, successor):
        self.successor = successor


@implementer(IObserver)
class Group(object):

    size_hint = QSize(200, 18)

    virtual = property(lambda self: isinstance(self.settings, VirtualGroup))

    movable = True
    editable = True
    deletable = property(lambda self: not self.virtual)

    def __init__(self, group):
        self.settings = group
        self.widget = Null
        self.saved_state = None
        self.relocation_info = None
        notification_center = NotificationCenter()
        notification_center.add_observer(ObserverWeakrefProxy(self), sender=group)

    def __repr__(self):
        return "%s(%r)" % (self.__class__.__name__, self.settings)

    def __getstate__(self):
        return self.settings.id, dict(widget=Null, saved_state=self.saved_state, relocation_info=self.relocation_info)

    def __setstate__(self, state):
        group_id, state = state
        if isinstance(group_id, addressbook.ID):
            manager = addressbook.AddressbookManager()
        else:
            manager = VirtualGroupManager()
        self.settings = manager.get_group(group_id)
        self.__dict__.update(state)

    def __unicode__(self):
        return self.settings.name

    def _get_widget(self):
        return self.__dict__['widget']

    def _set_widget(self, widget):
        old_widget = self.__dict__.get('widget', Null)
        old_widget.collapse_button.clicked.disconnect(self._collapsed_changed)
        old_widget.name_editor.editingFinished.disconnect(self._name_changed)
        widget.collapse_button.clicked.connect(self._collapsed_changed)
        widget.name_editor.editingFinished.connect(self._name_changed)
        widget.collapse_button.setChecked(old_widget.collapse_button.isChecked() if old_widget is not Null else self.settings.collapsed)
        widget.name = self.name
        self.__dict__['widget'] = widget

    widget = property(_get_widget, _set_widget)
    del _get_widget, _set_widget

    @property
    def name(self):
        return self.settings.name

    @property
    def position(self):
        return self.settings.position

    @property
    def collapsed(self):
        return self.widget.collapse_button.isChecked()

    def collapse(self):
        self.widget.collapse_button.setChecked(True)

    def expand(self):
        self.widget.collapse_button.setChecked(False)

    def save_state(self):
        """Saves the current state of the group (collapsed or not)"""
        self.saved_state = self.widget.collapse_button.isChecked()

    def restore_state(self):
        """Restores the last saved state of the group (collapsed or not)"""
        self.widget.collapse_button.setChecked(self.saved_state)

    def reset_state(self):
        """Resets the collapsed state of the group to the one saved in the configuration"""
        if self.collapsed and not self.settings.collapsed:
            self.expand()
        elif not self.collapsed and self.settings.collapsed:
            self.collapse()

    def _collapsed_changed(self, state):
        self.settings.collapsed = state
        self.settings.save()

    def _name_changed(self):
        if self.settings.save is Null:
            del self.settings.save  # re-enable saving after the name was provided
        self.settings.name = self.widget.name_editor.text()
        self.settings.save()

    @run_in_gui_thread
    def handle_notification(self, notification):
        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    def _NH_AddressbookGroupDidChange(self, notification):
        if 'name' in notification.data.modified:
            self.widget.name = notification.sender.name


class ContactIconDescriptor(object):
    def __init__(self, filename):
        self.filename = filename
        self.icon = None

    def __get__(self, instance, owner):
        if self.icon is None:
            self.icon = QIcon(self.filename)
            self.icon.filename = self.filename
        return self.icon

    def __set__(self, instance, value):
        raise AttributeError("attribute cannot be set")

    def __delete__(self, instance):
        raise AttributeError("attribute cannot be deleted")


@implementer(IObserver)
class Contact(object):

    size_hint = QSize(220, 42)

    native = property(lambda self: self.type == 'addressbook')

    movable = property(lambda self: self.type == 'addressbook')
    editable = property(lambda self: self.type == 'addressbook')
    deletable = property(lambda self: self.type == 'addressbook')

    default_user_icon = ContactIconDescriptor(Resources.get('icons/default-avatar.png'))

    stylish_icons = True

    def __init__(self, contact, group):
        self.settings = contact
        self.group = group
        notification_center = NotificationCenter()
        notification_center.add_observer(ObserverWeakrefProxy(self), sender=contact)

    def __gt__(self, other):
        if isinstance(other, Contact):
            return locale.strcoll(self.name, other.name) > 0
        return NotImplemented

    def __ge__(self, other):
        if isinstance(other, Contact):
            return locale.strcoll(self.name, other.name) >= 0
        return NotImplemented

    def __lt__(self, other):
        if isinstance(other, Contact):
            return locale.strcoll(self.name, other.name) < 0
        return NotImplemented

    def __le__(self, other):
        if isinstance(other, Contact):
            return locale.strcoll(self.name, other.name) <= 0
        return NotImplemented

    def __repr__(self):
        return '%s(%r, %r)' % (self.__class__.__name__, self.settings, self.group)

    def __getstate__(self):
        return self.settings.id, dict(group=self.group)

    def __setstate__(self, state):
        contact_id, state = state
        if isinstance(contact_id, addressbook.ID):
            group = AllContactsGroup()
        elif isinstance(contact_id, GoogleContactID):
            group = GoogleContactsGroup()
        elif isinstance(contact_id, (BonjourNeighbourID, BonjourServiceDescription)):
            group = BonjourNeighboursGroup()
        else:
            group = None
        self.settings = group.contacts[contact_id]  # problem if group is None -Dan
        self.__dict__.update(state)

    def __unicode__(self):
        return self.name or ''

    @property
    def type(self):
        try:
            return self.__dict__['type']
        except KeyError:
            if isinstance(self.settings, addressbook.Contact):
                type = 'addressbook'
            elif isinstance(self.settings, BonjourNeighbour):
                type = 'bonjour'
            elif isinstance(self.settings, GoogleContact):
                type = 'google'
            elif isinstance(self.settings, DummyContact):
                type = 'dummy'
            else:
                type = 'unknown'
            return self.__dict__.setdefault('type', type)

    @property
    def name(self):
        if self.type == 'bonjour':
            return '%s (%s)' % (self.settings.name, self.settings.hostname)
        elif self.type == 'google':
            return self.settings.name or self.settings.organization or ''
        else:
            return self.settings.name

    @property
    def location(self):
        if self.type == 'bonjour':
            return self.settings.hostname
        else:
            return None

    @property
    def info(self):
        try:
            return self.note or (self.uri.uri.split('@')[1] if self.type == 'bonjour' else self.uri.uri)
        except (AttributeError, TypeError):
            return ''

    @property
    def uris(self):
        return self.settings.uris

    @property
    def uri(self):
        try:
            return self.settings.uris.default or next(iter(self.settings.uris))
        except StopIteration:
            return None

    @property
    def state(self):
        return self.settings.presence.state

    @property
    def note(self):
        return self.settings.presence.note

    @property
    def preferred_media(self):
        return PreferredMedia(self.settings.preferred_media)

    @property
    def icon(self):
        try:
            return self.__dict__['icon']
        except KeyError:
            if self.type == 'addressbook':
                icon_manager = IconManager()
                icon = icon_manager.get(self.settings.id + '_alt') or icon_manager.get(self.settings.id) or self.default_user_icon
            elif self.type == 'google':
                icon_manager = IconManager()
                icon = icon_manager.get(self.settings.id) or self.default_user_icon
            else:
                icon = self.default_user_icon
            return self.__dict__.setdefault('icon', icon)

    @property
    def pixmap(self):
        try:
            return self.__dict__['pixmap']
        except KeyError:
            size = 32
            if self.stylish_icons:
                pixmap = QPixmap(size, size)
                pixmap.fill(Qt.transparent)
                path = QPainterPath()
                path.addRoundedRect(0, 0, size, size, 3.7, 3.7)
                painter = QPainter(pixmap)
                painter.setRenderHint(QPainter.Antialiasing, True)
                painter.setCompositionMode(QPainter.CompositionMode_SourceOver)
                painter.setClipPath(path)
                self.icon.paint(painter, pixmap.rect(), Qt.AlignCenter)
                painter.end()
            else:
                pixmap = self.icon.pixmap(size)
            return self.__dict__.setdefault('pixmap', pixmap)

    def handle_notification(self, notification):
        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    def _NH_AddressbookContactDidChange(self, notification):
        if {'icon', 'alternate_icon'}.intersection(notification.data.modified):
            self.__dict__.pop('icon', None)
            self.__dict__.pop('pixmap', None)
        notification.center.post_notification('BlinkContactDidChange', sender=self)

    def _NH_VirtualContactDidChange(self, notification):
        self.__dict__.pop('icon', None)
        self.__dict__.pop('pixmap', None)
        notification.center.post_notification('BlinkContactDidChange', sender=self)


@implementer(IObserver)
class ContactDetail(object):

    size_hint = QSize(200, 36)

    native = property(lambda self: self.type == 'addressbook')

    editable = property(lambda self: self.type == 'addressbook')
    deletable = property(lambda self: self.type == 'addressbook')

    default_user_icon = ContactIconDescriptor(Resources.get('icons/default-avatar.png'))

    stylish_icons = True

    def __init__(self, contact):
        self.settings = contact
        notification_center = NotificationCenter()
        notification_center.add_observer(ObserverWeakrefProxy(self), sender=contact)

    def __repr__(self):
        return '%s(%r)' % (self.__class__.__name__, self.settings)

    def __getstate__(self):
        return self.settings.id, {}

    def __setstate__(self, state):
        contact_id, state = state
        if isinstance(contact_id, addressbook.ID):
            group = AllContactsGroup()
        elif isinstance(contact_id, GoogleContactID):
            group = GoogleContactsGroup()
        elif isinstance(contact_id, (BonjourNeighbourID, BonjourServiceDescription)):
            group = BonjourNeighboursGroup()
        else:
            group = None
        self.settings = group.contacts[contact_id]  # problem if group is None -Dan
        self.__dict__.update(state)

    def __unicode__(self):
        return self.name or ''

    @property
    def type(self):
        try:
            return self.__dict__['type']
        except KeyError:
            if isinstance(self.settings, addressbook.Contact):
                type = 'addressbook'
            elif isinstance(self.settings, BonjourNeighbour):
                type = 'bonjour'
            elif isinstance(self.settings, GoogleContact):
                type = 'google'
            elif isinstance(self.settings, DummyContact):
                type = 'dummy'
            else:
                type = 'unknown'
            return self.__dict__.setdefault('type', type)

    @property
    def name(self):
        if self.type == 'bonjour':
            return '%s (%s)' % (self.settings.name, self.settings.hostname)
        elif self.type == 'google':
            return self.settings.name or self.settings.organization or ''
        else:
            return self.settings.name

    @property
    def location(self):
        if self.type == 'bonjour':
            return self.settings.hostname
        else:
            return None

    @property
    def info(self):
        try:
            return self.note or ('@' + self.uri.uri.host if self.type == 'bonjour' else self.uri.uri)
        except AttributeError:
            return ''

    @property
    def uris(self):
        return self.settings.uris

    @property
    def uri(self):
        try:
            return self.settings.uris.default or next(iter(self.settings.uris))
        except StopIteration:
            return None

    @property
    def state(self):
        return self.settings.presence.state

    @property
    def note(self):
        return self.settings.presence.note

    @property
    def preferred_media(self):
        return PreferredMedia(self.settings.preferred_media)

    @property
    def icon(self):
        try:
            return self.__dict__['icon']
        except KeyError:
            if self.type == 'addressbook':
                icon_manager = IconManager()
                icon = icon_manager.get(self.settings.id + '_alt') or icon_manager.get(self.settings.id) or self.default_user_icon
            elif self.type == 'google':
                icon_manager = IconManager()
                icon = icon_manager.get(self.settings.id) or self.default_user_icon
            else:
                icon = self.default_user_icon
            return self.__dict__.setdefault('icon', icon)

    @property
    def pixmap(self):
        try:
            return self.__dict__['pixmap']
        except KeyError:
            size = 32
            if self.stylish_icons:
                pixmap = QPixmap(size, size)
                pixmap.fill(Qt.transparent)
                path = QPainterPath()
                path.addRoundedRect(0, 0, size, size, 3.7, 3.7)
                painter = QPainter(pixmap)
                painter.setRenderHint(QPainter.Antialiasing, True)
                painter.setCompositionMode(QPainter.CompositionMode_SourceOver)
                painter.setClipPath(path)
                self.icon.paint(painter, pixmap.rect(), Qt.AlignCenter)
                painter.end()
            else:
                pixmap = self.icon.pixmap(size)
            return self.__dict__.setdefault('pixmap', pixmap)

    def handle_notification(self, notification):
        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    def _NH_AddressbookContactDidChange(self, notification):
        if {'icon', 'alternate_icon'}.intersection(notification.data.modified):
            self.__dict__.pop('icon', None)
            self.__dict__.pop('pixmap', None)
        notification.center.post_notification('BlinkContactDetailDidChange', sender=self)

    def _NH_VirtualContactDidChange(self, notification):
        self.__dict__.pop('icon', None)
        self.__dict__.pop('pixmap', None)
        notification.center.post_notification('BlinkContactDetailDidChange', sender=self)


@implementer(IObserver)
class ContactURI(object):

    size_hint = QSize(200, 24)

    native = property(lambda self: isinstance(self.contact, addressbook.Contact))

    editable = property(lambda self: isinstance(self.contact, addressbook.Contact))
    deletable = property(lambda self: isinstance(self.contact, addressbook.Contact))

    def __init__(self, contact, uri):
        self.contact = contact
        self.uri = uri
        notification_center = NotificationCenter()
        notification_center.add_observer(ObserverWeakrefProxy(self), sender=contact)

    def __repr__(self):
        return '%s(%r, %r)' % (self.__class__.__name__, self.contact, self.uri)

    def __getstate__(self):
        if isinstance(self.contact, addressbook.Contact):
            uri_id = self.uri.id
            state_dict = dict()
        else:
            uri_id = None
            state_dict = dict(uri=self.uri)
        return self.contact.id, uri_id, state_dict

    def __setstate__(self, state):
        contact_id, uri_id, state = state
        if isinstance(contact_id, addressbook.ID):
            group = AllContactsGroup()
        elif isinstance(contact_id, GoogleContactID):
            group = GoogleContactsGroup()
        elif isinstance(contact_id, (BonjourNeighbourID, BonjourServiceDescription)):
            group = BonjourNeighboursGroup()
        else:
            group = None
        self.contact = group.contacts[contact_id]  # problem if group is None -Dan
        if uri_id is not None:
            self.uri = self.contact.uris[uri_id]
        self.__dict__.update(state)

    def __unicode__(self):
        return '%s (%s)' % (self.uri.uri, self.uri.type) if self.uri.type else str(self.uri.uri)

    def handle_notification(self, notification):
        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    def _NH_AddressbookContactDidChange(self, notification):
        modified_uris    = notification.data.modified.get('uris', Null)
        modified_default = notification.data.modified.get('uris.default', Null)
        if self.uri.id in modified_uris.modified or self.uri in (modified_default.old, modified_default.new) and self.uri not in modified_uris.removed:
            notification.center.post_notification('BlinkContactURIDidChange', sender=self)


ui_class, base_class = uic.loadUiType(Resources.get('contact.ui'))


class ContactWidget(base_class, ui_class):
    def __init__(self, parent=None):
        super(ContactWidget, self).__init__(parent)
        with Resources.directory:
            self.setupUi(self)
        self.info_label.setForegroundRole(QPalette.Dark)
        # AlternateBase set to #f0f4ff or #e0e9ff

    def paintEvent(self, event):
        super(ContactWidget, self).paintEvent(event)
        if self.backgroundRole() == QPalette.Highlight and self.state_label.state is not None:
            rect = self.state_label.geometry()
            rect.setWidth(self.width() - rect.x())
            gradient = QLinearGradient(0, 0, 1, 0)
            gradient.setCoordinateMode(QLinearGradient.ObjectBoundingMode)
            gradient.setColorAt(0.0, Qt.transparent)
            gradient.setColorAt(1.0, Qt.white)
            painter = QPainter(self)
            painter.setRenderHint(QPainter.Antialiasing, True)
            painter.fillRect(rect, QBrush(gradient))
            painter.end()

    def init_from_contact(self, contact):
        self.name_label.setText(contact.name)
        self.info_label.setText(contact.info)
        self.icon_label.setPixmap(contact.pixmap)
        self.state_label.state = contact.state


del ui_class, base_class


ui_class, base_class = uic.loadUiType(Resources.get('contact_group.ui'))


class GroupWidget(base_class, ui_class):
    def __init__(self, parent=None):
        super(GroupWidget, self).__init__(parent)
        with Resources.directory:
            self.setupUi(self)
        self.selected = False
        self.drop_indicator = None
        self._disable_dnd = False
        self.label_widget.setFocusProxy(self)
        self.name_view.setCurrentWidget(self.label_widget)
        self.name_editor.editingFinished.connect(self._end_editing)
        self.collapse_button.pressed.connect(self._collapse_button_pressed)

    @property
    def editing(self):
        return self.name_view.currentWidget() is self.editor_widget

    def _get_name(self):
        return self.name_label.text()

    def _set_name(self, value):
        self.name_label.setText(value)
        self.name_editor.setText(value)

    name = property(_get_name, _set_name)
    del _get_name, _set_name

    def _get_selected(self):
        return self.__dict__['selected']

    def _set_selected(self, value):
        if self.__dict__.get('selected', None) == value:
            return
        self.__dict__['selected'] = value
        self.name_label.setStyleSheet("color: #ffffff; font-weight: bold;" if value else "color: #000000;")
        # self.name_label.setForegroundRole(QPalette.BrightText if value else QPalette.WindowText)
        self.update()

    selected = property(_get_selected, _set_selected)
    del _get_selected, _set_selected

    def _get_drop_indicator(self):
        return self.__dict__['drop_indicator']

    def _set_drop_indicator(self, value):
        if self.__dict__.get('drop_indicator', Null) == value:
            return
        self.__dict__['drop_indicator'] = value
        self.update()

    drop_indicator = property(_get_drop_indicator, _set_drop_indicator)
    del _get_drop_indicator, _set_drop_indicator

    def edit(self):
        self._start_editing()

    def _start_editing(self):
        # self.name_editor.setText(self.name_label.text())
        self.name_editor.selectAll()
        self.name_view.setCurrentWidget(self.editor_widget)
        self.name_editor.setFocus()

    def _end_editing(self):
        self.name_label.setText(self.name_editor.text())
        self.name_view.setCurrentWidget(self.label_widget)

    def _collapse_button_pressed(self):
        self._disable_dnd = True

    def mousePressEvent(self, event):
        self._disable_dnd = False
        super(GroupWidget, self).mousePressEvent(event)

    def mouseMoveEvent(self, event):
        if self._disable_dnd:
            return
        super(GroupWidget, self).mouseMoveEvent(event)

    def paintEvent(self, event):
        painter = QPainter(self)
        rect = self.rect()

        background = QLinearGradient(0, 0, self.width(), self.height())
        if self.selected:
            background.setColorAt(0.0, QColor('#cacaca'))
            background.setColorAt(1.0, QColor('#b4b4b4'))
            upper_color = QColor('#f0f0f0')
            lower_color = QColor('#a4a4a4')
            foreground = QColor('#ffffff')
        else:
            background.setColorAt(0.0, QColor('#eeeeee'))
            background.setColorAt(1.0, QColor('#d8d8d8'))
            upper_color = QColor('#f8f8f8')
            lower_color = QColor('#c4c4c4')
            foreground = QColor('#888888')

        painter.fillRect(rect, QBrush(background))
        painter.setPen(upper_color)
        painter.drawLine(rect.topLeft(), rect.topRight())
        painter.setPen(lower_color)
        painter.drawLine(rect.bottomLeft(), rect.bottomRight())

        painter.setRenderHint(QPainter.Antialiasing, True)

        painter.setPen(QPen(QBrush(QColor('#dc3169')), 2.0))
        if self.drop_indicator is ContactListView.AboveItem:
            line_rect = QRectF(rect.adjusted(18, 0, 0, 5 - rect.height()))
            arc_rect = line_rect.adjusted(-5, -3, -line_rect.width(), -3)
            path = QPainterPath(line_rect.topRight())
            path.lineTo(line_rect.topLeft())
            path.arcTo(arc_rect, 0, -180)
            painter.drawPath(path)
        elif self.drop_indicator is ContactListView.BelowItem:
            line_rect = QRectF(rect.adjusted(18, rect.height() - 5, 0, 0))
            arc_rect = line_rect.adjusted(-5, 2, -line_rect.width(), 2)
            path = QPainterPath(line_rect.bottomRight())
            path.lineTo(line_rect.bottomLeft())
            path.arcTo(arc_rect, 0, 180)
            painter.drawPath(path)
        elif self.drop_indicator is ContactListView.OnItem:
            painter.setBrush(Qt.NoBrush)
            painter.drawRoundedRect(rect.adjusted(1, 1, -1, -1), 3, 3)

        if self.collapse_button.isChecked():
            arrow = QPolygonF([QPointF(0, 0), QPointF(0, 9), QPointF(8, 4.5)])
            arrow.translate(QPointF(5, 4))
        else:
            arrow = QPolygonF([QPointF(0, 0), QPointF(9, 0), QPointF(4.5, 8)])
            arrow.translate(QPointF(5, 5))
        painter.setBrush(foreground)
        painter.setPen(QPen(painter.brush(), 0, Qt.NoPen))
        painter.drawPolygon(arrow)

        painter.end()

    def event(self, event):
        if type(event) is QKeyEvent and self.editing:
            return True  # do not propagate keyboard events while editing
        elif type(event) is QMouseEvent and event.type() == QEvent.MouseButtonDblClick and event.button() == Qt.LeftButton:
            self._start_editing()
        return super(GroupWidget, self).event(event)


del ui_class, base_class


class ContactDelegate(QStyledItemDelegate, ColorHelperMixin):
    def __init__(self, parent=None):
        super(ContactDelegate, self).__init__(parent)

        self.contact_oddline_widget  = ContactWidget(None)
        self.contact_evenline_widget = ContactWidget(None)
        self.contact_selected_widget = ContactWidget(None)

        self.contact_oddline_widget.setBackgroundRole(QPalette.Base)
        self.contact_oddline_widget.setForegroundRole(QPalette.WindowText)
        self.contact_evenline_widget.setBackgroundRole(QPalette.AlternateBase)
        self.contact_evenline_widget.setForegroundRole(QPalette.WindowText)
        self.contact_selected_widget.setBackgroundRole(QPalette.Highlight)
        self.contact_selected_widget.setForegroundRole(QPalette.HighlightedText)
        self.contact_selected_widget.name_label.setForegroundRole(QPalette.HighlightedText)
        self.contact_selected_widget.info_label.setForegroundRole(QPalette.HighlightedText)

        # No theme except Oxygen honors the BackgroundRole
        palette = self.contact_oddline_widget.palette()
        palette.setColor(QPalette.Window, palette.color(QPalette.Base))
        self.contact_oddline_widget.setPalette(palette)

        palette = self.contact_evenline_widget.palette()
        palette.setColor(QPalette.Window, palette.color(QPalette.AlternateBase))
        self.contact_evenline_widget.setPalette(palette)

        palette = self.contact_selected_widget.palette()
        palette.setColor(QPalette.Window, palette.color(QPalette.Highlight))
        self.contact_selected_widget.setPalette(palette)

    def _update_list_view(self, group, collapsed):
        list_view = self.parent()
        list_items = list_view.model().items
        for position in range(list_items.index(group) + 1, len(list_items)):
            if isinstance(list_items[position], Group):
                break
            list_view.setRowHidden(position, collapsed)

    def createEditor(self, parent, options, index):
        item = index.data(Qt.UserRole)
        if isinstance(item, Group):
            item.widget = GroupWidget(parent)
            item.widget.collapse_button.toggled.connect(partial(self._update_list_view, item))  # the partial still creates a memory cycle -Dan
            return item.widget
        else:
            return None

    def editorEvent(self, event, model, option, index):
        arrow_rect = QRect(0, 0, 14, option.rect.height())
        arrow_rect.moveTopRight(option.rect.topRight())
        if event.type() == QEvent.MouseButtonRelease and event.button() == Qt.LeftButton and event.modifiers() == Qt.NoModifier and arrow_rect.contains(event.pos()):
            model.contact_list.detail_model.contact = index.data(Qt.UserRole).settings
            detail_view = model.contact_list.detail_view
            detail_view.animation.setDirection(QPropertyAnimation.Forward)
            detail_view.animation.setStartValue(option.rect)
            detail_view.animation.setEndValue(model.contact_list.geometry())
            detail_view.raise_()
            detail_view.show()
            detail_view.animation.start()
            return True
        return super(ContactDelegate, self).editorEvent(event, model, option, index)

    def updateEditorGeometry(self, editor, option, index):
        editor.setGeometry(option.rect)

    def paintContact(self, contact, painter, option, index):
        if option.state & QStyle.State_Selected:
            widget = self.contact_selected_widget
        elif index.row() % 2 == 1:
            widget = self.contact_evenline_widget
        else:
            widget = self.contact_oddline_widget
        item_size = option.rect.size()
        widget.setFixedSize(item_size)
        widget.init_from_contact(contact)

        painter.save()
        pixmap = QPixmap(item_size)
        widget.render(pixmap)
        painter.drawPixmap(option.rect, pixmap)

        if option.state & QStyle.State_MouseOver:
            self.drawExpansionIndicator(contact, option, painter, widget)

        if 0 and (option.state & QStyle.State_MouseOver):
            painter.setRenderHint(QPainter.Antialiasing, True)
            if option.state & QStyle.State_Selected:
                painter.fillRect(option.rect, QColor(240, 244, 255, 40))
            else:
                painter.setCompositionMode(QPainter.CompositionMode_DestinationIn)
                painter.fillRect(option.rect, QColor(240, 244, 255, 230))

        painter.restore()

    def drawExpansionIndicator(self, contact, option, painter, widget):
        pen_thickness = 1.6

        if contact.state is not None:
            foreground_color = option.palette.color(QPalette.Normal, QPalette.WindowText)
            background_color = widget.state_label.state_colors[contact.state]
            base_contrast_color = self.calc_light_color(background_color)
            gradient = QLinearGradient(0, 0, 1, 0)
            gradient.setCoordinateMode(QLinearGradient.ObjectBoundingMode)
            gradient.setColorAt(0.0, self.color_with_alpha(base_contrast_color, 0.3 * 255))
            gradient.setColorAt(1.0, self.color_with_alpha(base_contrast_color, 0.8 * 255))
            contrast_color = QBrush(gradient)
        else:
            # foreground_color = option.palette.color(QPalette.Normal, QPalette.WindowText)
            # background_color = option.palette.color(QPalette.Window)
            foreground_color = widget.palette().color(QPalette.Normal, widget.foregroundRole())
            background_color = widget.palette().color(widget.backgroundRole())
            contrast_color = self.calc_light_color(background_color)
        line_color = self.deco_color(background_color, foreground_color)

        pen = QPen(line_color, pen_thickness, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
        contrast_pen = QPen(contrast_color, pen_thickness, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)

        # this fits best with a state_label of width 14
        arrow_rect = QRect(0, 0, 14, 14)
        arrow_rect.moveBottomRight(widget.state_label.geometry().bottomRight())
        arrow_rect.translate(option.rect.topLeft())

        arrow = QPolygonF([QPointF(-3, -1.5), QPointF(0.5, 2.5), QPointF(4, -1.5)])
        arrow.translate(1, 1)

        painter.save()
        painter.setRenderHint(QPainter.Antialiasing, True)
        painter.setCompositionMode(QPainter.CompositionMode_SourceOver)
        painter.translate(arrow_rect.center())
        painter.translate(0, +1)
        painter.setPen(contrast_pen)
        painter.drawPolyline(arrow)
        painter.translate(0, -1)
        painter.setPen(pen)
        painter.drawPolyline(arrow)
        painter.restore()

    def paintGroup(self, group, painter, option, index):
        if group.widget.size() != option.rect.size():
            # For some reason updateEditorGeometry only receives the peak value
            # of the size that the widget ever had, so it will never shrink it.
            group.widget.resize(option.rect.size())
        group.widget.selected = bool(option.state & QStyle.State_Selected)

        if option.state & QStyle.State_Selected and not option.state & QStyle.State_HasFocus:
            # This condition is met when dragging is started on this group.
            # We use this to to draw the dragged item image.
            painter.save()
            pixmap = QPixmap(option.rect.size())
            group.widget.render(pixmap)
            painter.drawPixmap(option.rect, pixmap)
            painter.restore()

    def paint(self, painter, option, index):
        item = index.data(Qt.UserRole)
        handler = getattr(self, 'paint%s' % item.__class__.__name__, Null)
        handler(item, painter, option, index)

    def sizeHint(self, option, index):
        return index.data(Qt.SizeHintRole)


class ContactDetailDelegate(QStyledItemDelegate, ColorHelperMixin):
    def __init__(self, parent=None):
        super(ContactDetailDelegate, self).__init__(parent)
        self.widget = ContactWidget(None)
        self.widget.setBackgroundRole(QPalette.Base)
        # No theme except Oxygen honors the BackgroundRole
        palette = self.widget.palette()
        palette.setColor(QPalette.Window, palette.color(QPalette.Base))
        self.widget.setPalette(palette)

    def editorEvent(self, event, model, option, index):
        arrow_rect = QRect(0, 0, 14, option.rect.height())
        arrow_rect.moveTopRight(option.rect.topRight())
        if index.row() == 0 and event.type() == QEvent.MouseButtonRelease and event.button() == Qt.LeftButton and event.modifiers() == Qt.NoModifier and arrow_rect.contains(event.pos()):
            detail_view = self.parent()
            detail_view.animation.setDirection(QPropertyAnimation.Backward)
            detail_view.animation.start()
            return True
        return super(ContactDetailDelegate, self).editorEvent(event, model, option, index)

    def paintContactDetail(self, contact, painter, option, index):
        widget = self.widget
        item_size = option.rect.size()
        widget.setFixedSize(item_size)
        widget.init_from_contact(contact)

        painter.save()
        pixmap = QPixmap(item_size)
        widget.render(pixmap)
        painter.drawPixmap(option.rect, pixmap)

        self.drawCollapseIndicator(contact, option, painter, widget)

        painter.restore()

    def paintContactURI(self, contact_uri, painter, option, index):
        widget = option.widget
        style = widget.style()

        painter.save()
        painter.setClipRect(option.rect)

        # draw the background
        style.proxy().drawPrimitive(QStyle.PE_PanelItemViewItem, option, painter, widget)

        # draw the check mark
        if option.features & option.HasCheckIndicator:
            self.drawCheckMark(option, painter, widget)

        # draw the icon
        mode = QIcon.Disabled if not option.state & QStyle.State_Enabled else QIcon.Selected if option.state & QStyle.State_Selected else QIcon.Normal
        state = QIcon.On if option.state & QStyle.State_Open else QIcon.Off
        icon_rect = style.subElementRect(QStyle.SE_ItemViewItemDecoration, option, widget)
        option.icon.paint(painter, icon_rect, option.decorationAlignment, mode, state)

        # draw the text
        if contact_uri.uri.uri:
            color_group = QPalette.Disabled if not option.state & QStyle.State_Enabled else QPalette.Normal if option.state & QStyle.State_Active else QPalette.Inactive
            text_rect = style.subElementRect(QStyle.SE_ItemViewItemText, option, widget)
            text_rect.setRight(option.rect.right() - 5)
            if contact_uri.uri.type:
                painter.setPen(option.palette.color(color_group, QPalette.HighlightedText if option.state & QStyle.State_Selected else QPalette.Dark))
                painter.drawText(text_rect, Qt.TextSingleLine | Qt.AlignRight | Qt.AlignVCenter, contact_uri.uri.type)
                text_rect.adjust(0, 0, -option.fontMetrics.width(contact_uri.uri.type) - 5, 0)
            text_color = option.palette.color(color_group, QPalette.HighlightedText if option.state & QStyle.State_Selected else QPalette.Text)
            text_width = text_rect.width()
            if option.fontMetrics.width(contact_uri.uri.uri) > text_width:
                fade_start = 1 - 50.0 / text_width if text_width > 50 else 0.0
                gradient = QLinearGradient(text_rect.x(), 0, text_rect.right(), 0)
                gradient.setColorAt(fade_start, text_color)
                gradient.setColorAt(1.0, Qt.transparent)
                painter.setClipRect(text_rect)
                painter.setPen(QPen(QBrush(gradient), 1.0))
            else:
                painter.setPen(text_color)
            painter.drawText(text_rect, Qt.TextSingleLine | Qt.AlignLeft | Qt.AlignVCenter, contact_uri.uri.uri)

        painter.restore()

    def drawCollapseIndicator(self, contact, option, painter, widget):
        pen_thickness = 1.6

        if contact.state is not None:
            foreground_color = option.palette.color(QPalette.Normal, QPalette.WindowText)
            background_color = widget.state_label.state_colors[contact.state]
            base_contrast_color = self.calc_light_color(background_color)
            gradient = QLinearGradient(0, 0, 1, 0)
            gradient.setCoordinateMode(QLinearGradient.ObjectBoundingMode)
            gradient.setColorAt(0.0, self.color_with_alpha(base_contrast_color, 0.3 * 255))
            gradient.setColorAt(1.0, self.color_with_alpha(base_contrast_color, 0.8 * 255))
            contrast_color = QBrush(gradient)
        else:
            foreground_color = widget.palette().color(QPalette.Normal, widget.foregroundRole())
            background_color = widget.palette().color(widget.backgroundRole())
            contrast_color = self.calc_light_color(background_color)
        line_color = self.deco_color(background_color, foreground_color)

        pen = QPen(line_color, pen_thickness, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
        contrast_pen = QPen(contrast_color, pen_thickness, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)

        # this fits best with a state_label of width 14
        arrow_rect = QRect(0, 0, 14, 14)
        arrow_rect.moveBottomRight(widget.state_label.geometry().bottomRight())
        arrow_rect.translate(option.rect.topLeft())

        arrow = QPolygonF([QPointF(3, 1.5), QPointF(-0.5, -2.5), QPointF(-4, 1.5)])
        arrow.translate(2, 1)

        painter.save()
        painter.setRenderHint(QPainter.Antialiasing, True)
        painter.setCompositionMode(QPainter.CompositionMode_SourceOver)
        painter.translate(arrow_rect.center())
        painter.translate(0, +1)
        painter.setPen(contrast_pen)
        painter.drawPolyline(arrow)
        painter.translate(0, -1)
        painter.setPen(pen)
        painter.drawPolyline(arrow)
        painter.restore()

    def drawCheckMark(self, option, painter, widget):
        if option.checkState == Qt.Unchecked:
            return

        palette = option.palette
        rect = widget.style().subElementRect(QStyle.SE_ItemViewItemCheckIndicator, option, widget)

        x = int(rect.center().x() - 3.5)
        y = int(rect.center().y() - 2.5)

        pen_thickness = 2.0
        color = palette.color(QPalette.WindowText)
        background = palette.color(QPalette.Highlight if option.state & QStyle.State_Selected else QPalette.Window)
        pen = QPen(self.deco_color(background, color), pen_thickness, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
        contrast_pen = QPen(self.calc_light_color(background), pen_thickness, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)

        if option.checkState == Qt.PartiallyChecked:
            dashes = [1.0, 2.0]
            pen_thickness = 1.3
            pen.setWidthF(pen_thickness)
            contrast_pen.setWidthF(pen_thickness)
            pen.setDashPattern(dashes)
            contrast_pen.setDashPattern(dashes)

        offset = min(pen_thickness, 1.0)

        painter.save()
        painter.translate(0, -1)
        painter.setRenderHint(QPainter.Antialiasing)
        painter.setPen(contrast_pen)
        painter.translate(0, offset)
        painter.drawLine(x + 9, y, x + 3, y + 7)
        painter.drawLine(x, y + 4, x + 3, y + 7)
        painter.setPen(pen)
        painter.translate(0, -offset)
        painter.drawLine(x + 9, y, x + 3, y + 7)
        painter.drawLine(x, y + 4, x + 3, y + 7)
        painter.restore()

    def paint(self, painter, option, index):
        self.initStyleOption(option, index)
        item = index.data(Qt.UserRole)
        handler = getattr(self, 'paint%s' % item.__class__.__name__, Null)
        handler(item, painter, option, index)

    def sizeHint(self, option, index):
        return index.data(Qt.SizeHintRole)


class Operation(object):
    __params__ = ()
    __priority__ = None

    def __init__(self, **params):
        for name, value in params.items():
            setattr(self, name, value)
        for param in set(self.__params__).difference(params):
            raise ValueError("missing operation parameter: '%s'" % param)
        self.timestamp = datetime.utcnow()


class AddContactOperation(Operation):
    __params__ = ('contact', 'group_ids', 'icon', 'alternate_icon')
    __priority__ = 0


class AddGroupOperation(Operation):
    __params__ = ('group',)
    __priority__ = 1


class AddGroupMemberOperation(Operation):
    __params__ = ('group_id', 'contact_id')
    __priority__ = 2


class RecallState(object):
    def __init__(self, obj):
        self.id = obj.id
        self.state = self._normalize_state(obj.__getstate__())

    def __repr__(self):
        return "%s(%r, %r)" % (self.__class__.__name__, self.id, self.state)

    def _normalize_state(self, state):
        normalized_state = {}
        for key, value in state.items():
            if isinstance(value, dict):
                normalized_state[key] = self._normalize_state(value)
            elif value is not DefaultValue:
                normalized_state[key] = value
        return normalized_state


class GroupList(metaclass=MarkerType):     pass
class GroupElement(metaclass=MarkerType):  pass
class GroupContacts(metaclass=MarkerType): pass


class GroupContactList(tuple):
    def __new__(cls, *args):
        instance = tuple.__new__(cls, *args)
        instance.__contactmap__ = dict((item.settings, item) for item in instance)
        return instance

    def __contains__(self, item):
        return item in self.__contactmap__ or tuple.__contains__(self, item)

    def __getitem__(self, index):
        if isinstance(index, (int, slice)):
            return tuple.__getitem__(self, index)
        else:
            return self.__contactmap__[index]


class ItemList(list):
    def __init__(self, *args):
        list.__init__(self, *args)
        self.__groupmap__ = dict((item.settings, item) for item in self if isinstance(item, Group))

    def __add__(self, other):
        return self.__class__(list.__add__(self, other))

    def __contains__(self, item):
        return item in self.__groupmap__ or list.__contains__(self, item)

    def __delitem__(self, index):
        list.__delitem__(self, index)
        self.__groupmap__ = dict((item.settings, item) for item in self if isinstance(item, Group))

    def __delslice__(self, i, j):
        list.__delslice__(self, i, j)
        self.__groupmap__ = dict((item.settings, item) for item in self if isinstance(item, Group))

    def __getitem__(self, index):
        if index is GroupList:
            return [item for item in self if isinstance(item, Group)]
        elif isinstance(index, tuple):
            try:
                operation, key = index
            except ValueError:
                raise KeyError(index)
            if operation is GroupElement:
                return self.__groupmap__[key]
            elif operation is GroupContacts:
                group = key if isinstance(key, Group) else self.__groupmap__[key]
                return GroupContactList(item for item in self if isinstance(item, Contact) and item.group is group)
            else:
                raise KeyError(key)
        return list.__getitem__(self, index)

    def __iadd__(self, other):
        list.__iadd__(self, other)
        self.__groupmap__.update((item.settings, item) for item in other if isinstance(item, Group))
        return self

    def __imul__(self, factor):
        raise NotImplementedError

    def __setitem__(self, index, item):
        list.__setitem__(self, index, item)
        self.__groupmap__ = dict((item.settings, item) for item in self if isinstance(item, Group))

    def __setslice__(self, i, j, value):
        list.__setslice__(self, i, j, value)
        self.__groupmap__ = dict((item.settings, item) for item in self if isinstance(item, Group))

    def append(self, item):
        list.append(self, item)
        if isinstance(item, Group):
            self.__groupmap__[item.settings] = item

    def extend(self, iterable):
        list.extend(self, iterable)
        self.__groupmap__ = dict((item.settings, item) for item in self if isinstance(item, Group))

    def insert(self, index, item):
        list.insert(self, index, item)
        if isinstance(item, Group):
            self.__groupmap__[item.settings] = item

    def pop(self, *args):
        item = list.pop(self, *args)
        self.__groupmap__.pop(item.settings, None)

    def remove(self, item):
        list.remove(self, item)
        self.__groupmap__.pop(item.settings, None)


@implementer(IObserver)
class ContactModel(QAbstractListModel):

    itemsAdded = pyqtSignal(list)
    itemsRemoved = pyqtSignal(list)

    # The MIME types we accept in drop operations, in the order they should be handled
    accepted_mime_types = ['application/x-blink-group-list', 'application/x-blink-contact-list', 'text/uri-list']
    # TODO: Maybe translate? -Tijmen
    test_contacts = (dict(id='test_call',       name='Test Call',       preferred_media='audio+chat', uri='echo@conference.sip2sip.info', icon=Resources.get('icons/test-call.png')),
                     dict(id='test_conference', name='Test Conference', preferred_media='audio+chat', uri='test@conference.sip2sip.info', icon=Resources.get('icons/test-conference.png')))

    def __init__(self, parent=None):
        super(ContactModel, self).__init__(parent)
        self.state = 'stopped'
        self.items = ItemList()
        self.deleted_items = []
        self.contact_list = parent.contact_list
        self.virtual_group_manager = VirtualGroupManager()

        notification_center = NotificationCenter()
        notification_center.add_observer(self, name='SIPApplicationWillStart')
        notification_center.add_observer(self, name='SIPApplicationDidStart')
        notification_center.add_observer(self, name='SIPApplicationWillEnd')
        notification_center.add_observer(self, name='SIPApplicationDidEnd')
        notification_center.add_observer(self, name='SIPAccountManagerDidStart')
        notification_center.add_observer(self, name='SIPAccountManagerDidChangeDefaultAccount')
        notification_center.add_observer(self, name='AddressbookContactDidChange')
        notification_center.add_observer(self, name='AddressbookGroupWasActivated')
        notification_center.add_observer(self, name='AddressbookGroupWasDeleted')
        notification_center.add_observer(self, name='AddressbookGroupDidChange')
        notification_center.add_observer(self, name='VirtualGroupWasActivated')
        notification_center.add_observer(self, name='VirtualGroupWasDeactivated')
        notification_center.add_observer(self, name='VirtualGroupDidAddContact')
        notification_center.add_observer(self, name='VirtualGroupDidRemoveContact')
        notification_center.add_observer(self, name='BlinkContactDidChange')

    @property
    def bonjour_group(self):
        try:
            return self.items[GroupElement, BonjourNeighboursGroup()]
        except KeyError:
            return None

    @property
    def google_contacts_group(self):
        try:
            return self.items[GroupElement, GoogleContactsGroup()]
        except KeyError:
            return None

    def flags(self, index):
        if index.isValid():
            return QAbstractListModel.flags(self, index) | Qt.ItemIsDropEnabled | Qt.ItemIsDragEnabled | Qt.ItemIsEditable
        else:
            return QAbstractListModel.flags(self, index) | Qt.ItemIsDropEnabled

    def rowCount(self, parent=QModelIndex()):
        return len(self.items)

    def data(self, index, role=Qt.DisplayRole):
        if not index.isValid():
            return None
        item = self.items[index.row()]
        if role == Qt.UserRole:
            return item
        elif role == Qt.SizeHintRole:
            return item.size_hint
        elif role == Qt.DisplayRole:
            return str(item)
        return None

    def supportedDropActions(self):
        return Qt.CopyAction | Qt.MoveAction

    def mimeTypes(self):
        return ['application/x-blink-contact-list']

    def mimeData(self, indexes):
        mime_data = QMimeData()
        contacts = [item for item in (self.items[index.row()] for index in indexes if index.isValid()) if isinstance(item, Contact)]
        groups = [item for item in (self.items[index.row()] for index in indexes if index.isValid()) if isinstance(item, Group)]
        if contacts:
            mime_data.setData('application/x-blink-contact-list', QByteArray(pickle.dumps(contacts)))
        if groups:
            mime_data.setData('application/x-blink-group-list', QByteArray(pickle.dumps(groups)))
        return mime_data

    def dropMimeData(self, mime_data, action, row, column, parent_index):
        # this is here just to keep the default Qt DnD API happy
        # the custom handler is in handleDroppedData
        return False

    def handleDroppedData(self, mime_data, action, index):
        if action == Qt.IgnoreAction:
            return True

        for mime_type in self.accepted_mime_types:
            if mime_data.hasFormat(mime_type):
                name = mime_type.replace('/', ' ').replace('-', ' ').title().replace(' ', '')
                handler = getattr(self, '_DH_%s' % name)
                return handler(mime_data, action, index)
        else:
            return False

    def _DH_ApplicationXBlinkGroupList(self, mime_data, action, index):
        groups = self.items[GroupList]
        group = self.items[index.row()] if index.isValid() else groups[-1]
        drop_indicator = group.widget.drop_indicator
        if group.widget.drop_indicator is None:
            return False
        selected_indexes = self.contact_list.selectionModel().selectedIndexes()
        moved_groups = set(self.items[index.row()] for index in selected_indexes if index.isValid() and self.items[index.row()].movable)
        if group is groups[0] and group in moved_groups:
            drop_group = next(group for group in groups if group not in moved_groups)
            drop_position = self.contact_list.AboveItem
        elif group is groups[-1] and group in moved_groups:
            drop_group = next(group for group in reversed(groups) if group not in moved_groups)
            drop_position = self.contact_list.BelowItem
        elif group in moved_groups:
            position = groups.index(group)
            if drop_indicator is self.contact_list.AboveItem:
                drop_group = next(group for group in reversed(groups[:position]) if group not in moved_groups)
                drop_position = self.contact_list.BelowItem
            else:
                drop_group = next(group for group in groups[position:] if group not in moved_groups)
                drop_position = self.contact_list.AboveItem
        else:
            drop_group = group
            drop_position = drop_indicator
        items = self._pop_items(selected_indexes)
        groups = self.items[GroupList]  # get group list again as it changed
        if drop_position is self.contact_list.AboveItem:
            position = self.items.index(drop_group)
        else:
            position = len(self.items) if drop_group is groups[-1] else self.items.index(groups[groups.index(drop_group) + 1])
        self.beginInsertRows(QModelIndex(), position, position + len(items) - 1)
        self.items[position:position] = items
        self.endInsertRows()
        for index, item in enumerate(items):
            if isinstance(item, Group):
                self.contact_list.openPersistentEditor(self.index(position + index))
            else:
                self.contact_list.setRowHidden(position + index, item.group.collapsed)
        bonjour_group = self.bonjour_group
        if bonjour_group in moved_groups:
            bonjour_group.relocation_info = None
        self._update_group_positions()
        return True

    def _DH_ApplicationXBlinkContactList(self, mime_data, action, index):
        group = self.items[index.row()] if index.isValid() else self.items[GroupList][-1]
        if group.widget.drop_indicator is None:
            return False
        all_contacts_group = AllContactsGroup()
        movable_contacts = [self.items[index.row()] for index in self.contact_list.selectionModel().selectedIndexes() if index.isValid() and self.items[index.row()].movable]
        modified_settings = set()
        for contact in movable_contacts:
            if contact.group.settings is not all_contacts_group:
                contact.group.settings.contacts.remove(contact.settings)
                modified_settings.add(contact.group.settings)
            group.settings.contacts.add(contact.settings)
            modified_settings.add(group.settings)
        self._atomic_update(save=modified_settings)
        return True

    def _DH_TextUriList(self, mime_data, action, index):
        if not index.isValid():
            return False
        item = self.items[index.row()]
        if not isinstance(item, Contact):
            return False

        # TODO: support directories? -Saul
        files = [url.toLocalFile() for url in mime_data.urls() if url.isLocalFile() and os.path.isfile(url.toLocalFile())]
        if not files:
            return False

        contact = item
        session_manager = SessionManager()
        for filename in files:
            session_manager.send_file(contact, contact.uri, filename)

        return True

    @run_in_gui_thread
    def handle_notification(self, notification):
        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    def _NH_SIPApplicationWillStart(self, notification):
        from blink import Blink
        self.state = 'starting'
        blink = Blink()
        if blink.first_run:
            test_group = addressbook.Group(id='test')
            test_group.name = 'Test'
            test_group.contacts = [self._create_contact(**entry) for entry in self.test_contacts]
            changed_items = list(test_group.contacts) + [test_group]
            self._atomic_update(save=changed_items)
        else:
            addressbook_manager = addressbook.AddressbookManager()

            # upgrade test contacts if test_call doesn't exist but test_audio and/or test_microphone do (test_call replaced test_audio + test_microphone). to be removed later -Dan

            obsolete_contacts = [contact for contact in addressbook_manager.get_contacts() if contact.id in {'test_audio', 'test_microphone'}]
            need_upgrade  = bool(obsolete_contacts and not addressbook_manager.has_contact('test_call'))

            changed_items = deque()
            deleted_items = obsolete_contacts if need_upgrade else []

            if need_upgrade:
                try:
                    test_group = addressbook_manager.get_group('test')
                except KeyError:
                    test_group = addressbook.Group(id='test')
                    test_group.name = 'Test'
                changed_items.append(test_group)
                for entry in self.test_contacts:
                    try:
                        contact = addressbook_manager.get_contact(entry['id'])
                    except KeyError:
                        contact = self._create_contact(**entry)
                    else:
                        self._update_contact(contact, **entry)
                    test_group.contacts.add(contact)
                    changed_items.appendleft(contact)
            else:
                for entry in self.test_contacts:
                    try:
                        contact = addressbook_manager.get_contact(entry['id'])
                    except KeyError:
                        continue
                    else:
                        if self._update_contact(contact, icon=entry['icon']):
                            changed_items.appendleft(contact)
            self._atomic_update(save=changed_items, delete=deleted_items)

    def _NH_SIPApplicationDidStart(self, notification):
        self.state = 'started'
        self._update_group_positions()

    def _NH_SIPApplicationWillEnd(self, notification):
        self.state = 'stopping'

    def _NH_SIPApplicationDidEnd(self, notification):
        self.state = 'stopped'

    def _NH_AddressbookGroupWasActivated(self, notification):
        group = Group(notification.sender)
        self.addGroup(group)
        for contact in notification.sender.contacts:
            self.addContact(Contact(contact, group))

    def _NH_AddressbookGroupWasDeleted(self, notification):
        group = self.items[GroupElement, notification.sender]
        self.removeGroup(group)

    def _NH_AddressbookGroupDidChange(self, notification):
        if 'contacts' not in notification.data.modified:
            return
        group = self.items[GroupElement, notification.sender]
        group_contacts = self.items[GroupContacts, notification.sender]
        for contact in notification.data.modified['contacts'].removed:
            self.removeContact(group_contacts[contact])
        for contact in notification.data.modified['contacts'].added:
            self.addContact(Contact(contact, group))

    def _NH_VirtualGroupWasActivated(self, notification):
        group = Group(notification.sender)
        self.addGroup(group)
        for contact in notification.data.contacts:
            self.addContact(Contact(contact, group))

    def _NH_VirtualGroupWasDeactivated(self, notification):
        group = self.items[GroupElement, notification.sender]
        self.removeGroup(group)

    def _NH_VirtualGroupDidAddContact(self, notification):
        group = self.items[GroupElement, notification.sender]
        self.addContact(Contact(notification.data.contact, group))

    def _NH_VirtualGroupDidRemoveContact(self, notification):
        contact = self.items[GroupContacts, notification.sender][notification.data.contact]
        self.removeContact(contact)
        if notification.sender is AllContactsGroup():
            icon_manager = IconManager()
            icon_manager.remove(contact.settings.id)
            icon_manager.remove(contact.settings.id + '_alt')
        elif notification.sender is GoogleContactsGroup():
            icon_manager = IconManager()
            icon_manager.remove(contact.settings.id)

    def _NH_BlinkContactDidChange(self, notification):
        contact = notification.sender
        position = self.items.index(contact)
        move_point = self._find_contact_move_point(contact)
        if move_point is not None:
            self.beginMoveRows(QModelIndex(), position, position, QModelIndex(), move_point)
            del self.items[position]
            self.items.insert(self._find_contact_insertion_point(contact), contact)
            self.endMoveRows()
        index = self.index(self.items.index(contact))
        self.dataChanged.emit(index, index)

    def _NH_SIPAccountManagerDidStart(self, notification):
        if notification.sender.default_account is BonjourAccount():
            groups = self.items[GroupList]
            bonjour_group = self.bonjour_group
            try:
                bonjour_group.relocation_info = RelocationInfo(successor=groups[groups.index(bonjour_group) + 1])
            except IndexError:
                bonjour_group.relocation_info = RelocationInfo(successor=None)
            if bonjour_group is not groups[0]:
                self.moveGroup(bonjour_group, successor=groups[0])
            bonjour_group.expand()

    def _NH_SIPAccountManagerDidChangeDefaultAccount(self, notification):
        account = notification.data.account
        old_account = notification.data.old_account
        if account is BonjourAccount():
            groups = self.items[GroupList]
            bonjour_group = self.bonjour_group
            try:
                bonjour_group.relocation_info = RelocationInfo(successor=groups[groups.index(bonjour_group) + 1])
            except IndexError:
                bonjour_group.relocation_info = RelocationInfo(successor=None)
            if bonjour_group is not groups[0]:
                self.moveGroup(bonjour_group, successor=groups[0])
            bonjour_group.expand()
        elif old_account is BonjourAccount() and old_account.enabled:
            bonjour_group = self.bonjour_group
            if bonjour_group.relocation_info is not None:
                self.moveGroup(bonjour_group, successor=bonjour_group.relocation_info.successor)
                bonjour_group.relocation_info = None
            bonjour_group.reset_state()

    def _NH_AddressbookContactDidChange(self, notification):
        # make sure the presence policy and subscribe flag are synchronized
        contact = notification.sender
        if contact.presence.policy == 'default':
            contact.presence.policy = 'allow' if contact.presence.subscribe else 'block'
            contact.save()
        elif contact.presence.subscribe != (True if contact.presence.policy == 'allow' else False):
            contact.presence.subscribe = True if contact.presence.policy == 'allow' else False
            contact.save()

    @staticmethod
    def range_iterator(indexes):
        """Return contiguous ranges from indexes"""
        start = last = None
        for index in sorted(indexes):
            if start is None:
                start = index
            elif index - last > 1:
                yield (start, last)
                start = index
            last = index
        else:
            if indexes:
                yield (start, last)

    @staticmethod
    def reversed_range_iterator(indexes):
        """Return contiguous ranges from indexes starting from the end"""
        end = last = None
        for index in reversed(sorted(indexes)):
            if end is None:
                end = index
            elif last - index > 1:
                yield (last, end)
                end = index
            last = index
        else:
            if indexes:
                yield (last, end)

    @run_in_thread('file-io')
    def _atomic_update(self, save=(), delete=()):
        with addressbook.AddressbookManager.transaction():
            [item.save() for item in save]
            [item.delete() for item in delete]

    def _create_contact(self, id, name, preferred_media, uri, icon):
        contact = addressbook.Contact(id)
        contact.name = name
        contact.preferred_media = preferred_media
        contact.uris = [addressbook.ContactURI(uri=uri, type='SIP')]
        contact.icon = IconDescriptor(FileURL(icon), str(int(os.stat(icon).st_mtime)))
        icon_manager = IconManager()
        icon_manager.store_file(id, icon)
        return contact

    def _update_contact(self, contact, **data):
        modified = False
        if 'name' in data:
            contact.name = data['name']
            modified = True
        if 'preferred_media' in data:
            contact.preferred_media = data['preferred_media']
            modified = True
        if 'uri' in data and data['uri'] not in {uri.uri for uri in contact.uris}:
            uri = addressbook.ContactURI(uri=data['uri'], type='SIP')
            contact.uris.add(uri)
            if len(contact.uris) > 1:
                contact.uris.default = uri
            modified = True
        if 'icon' in data:
            icon_descriptor = IconDescriptor(FileURL(data['icon']), str(int(os.stat(data['icon']).st_mtime)))
            if contact.icon != icon_descriptor:
                icon_manager = IconManager()
                icon_manager.store_file(contact.id, data['icon'])
                contact.icon = icon_descriptor
                modified = True
        return modified

    def _find_contact_move_point(self, contact):
        position = self.items.index(contact)
        prev_item = self.items[position - 1] if position > 0 else None
        next_item = self.items[position + 1] if position + 1 < len(self.items) else None
        prev_ok = prev_item is None or isinstance(prev_item, Group) or prev_item <= contact
        next_ok = next_item is None or isinstance(next_item, Group) or next_item >= contact
        if prev_ok and next_ok:
            return None
        for position in range(self.items.index(contact.group) + 1, len(self.items)):
            item = self.items[position]
            if isinstance(item, Group) or item > contact:
                break
        else:
            position = len(self.items)
        return position

    def _find_contact_insertion_point(self, contact):
        for position in range(self.items.index(contact.group) + 1, len(self.items)):
            item = self.items[position]
            if isinstance(item, Group) or item > contact:
                break
        else:
            position = len(self.items)
        return position

    def _find_group_insertion_point(self, group):
        if group.settings.position is None:
            return 0  # insert new groups at the top
        for item in self.items[GroupList]:
            if item.relocation_info is None and item.settings.position >= group.settings.position:
                position = self.items.index(item)
                break
            elif item.relocation_info is not None and item.settings.position == group.settings.position - 1:
                item.relocation_info.successor = group
        else:
            position = len(self.items)
        return position

    def _add_contact(self, contact):
        position = self._find_contact_insertion_point(contact)
        self.beginInsertRows(QModelIndex(), position, position)
        self.items.insert(position, contact)
        self.endInsertRows()
        self.contact_list.setRowHidden(position, contact.group.collapsed)

    def _add_group(self, group):
        position = self._find_group_insertion_point(group)
        self.beginInsertRows(QModelIndex(), position, position)
        self.items.insert(position, group)
        self.endInsertRows()
        self.contact_list.openPersistentEditor(self.index(position))

    def _pop_contact(self, contact):
        position = self.items.index(contact)
        self.beginRemoveRows(QModelIndex(), position, position)
        del self.items[position]
        self.endRemoveRows()
        return contact

    def _pop_group(self, group):
        start = self.items.index(group)
        end = start + len(self.items[GroupContacts, group])
        self.beginRemoveRows(QModelIndex(), start, end)
        items = self.items[start:end + 1]
        del self.items[start:end + 1]
        self.endRemoveRows()
        return items

    def _pop_items(self, indexes):
        items = []
        rows = set(index.row() for index in indexes if index.isValid())
        removed_groups = set(self.items[row] for row in rows if isinstance(self.items[row], Group))
        rows.update(row for row, item in enumerate(self.items) if isinstance(item, Contact) and item.group in removed_groups)
        for start, end in self.reversed_range_iterator(rows):
            self.beginRemoveRows(QModelIndex(), start, end)
            items[0:0] = self.items[start:end + 1]
            del self.items[start:end + 1]
            self.endRemoveRows()
        return items

    def _update_group_positions(self):
        if self.state != 'started':
            return
        groups = self.items[GroupList]
        bonjour_group = self.bonjour_group
        if bonjour_group is groups[0] and bonjour_group.relocation_info is not None:
            groups.pop(0)
            if bonjour_group.relocation_info.successor is not None:
                groups.insert(groups.index(bonjour_group.relocation_info.successor), bonjour_group)
            else:
                groups.append(bonjour_group)
        for position, group in enumerate(groups):
            group.settings.position = position
            group.settings.save()

    def addContact(self, contact):
        if contact in self.items:
            return
        self._add_contact(contact)
        self.itemsAdded.emit([contact])

    def removeContact(self, contact):
        if contact not in self.items:
            return
        self._pop_contact(contact)
        self.itemsRemoved.emit([contact])

    def addGroup(self, group):
        if group in self.items or group.settings in self.items:
            return
        self._add_group(group)
        self.itemsAdded.emit([group])
        self._update_group_positions()

    def removeGroup(self, group):
        if group not in self.items:
            return
        items = self._pop_group(group)
        group.widget = Null
        self.itemsRemoved.emit(items)
        self._update_group_positions()

    def moveGroup(self, group, successor):
        groups = self.items[GroupList]
        if group not in groups or groups.index(group) + 1 == (groups.index(successor) if successor in groups else len(groups)):
            return
        items = self._pop_group(group)
        position = self.items.index(successor) if successor in groups else len(self.items)
        self.beginInsertRows(QModelIndex(), position, position + len(items) - 1)
        self.items[position:position] = items
        self.endInsertRows()
        self.contact_list.openPersistentEditor(self.index(position))
        self._update_group_positions()

    def removeItems(self, indexes):
        all_contacts_group = AllContactsGroup()
        icon_manager = IconManager()
        removed_items = deque()
        removed_members = []
        undo_operations = []
        for item in (self.items[index.row()] for index in indexes if self.items[index.row()].deletable):
            if isinstance(item, Group):
                removed_items.appendleft(item.settings)
                undo_operations.append(AddGroupOperation(group=RecallState(item.settings)))
            elif item.group.settings is all_contacts_group:
                removed_items.append(item.settings)
                group_ids = [contact.group.settings.id for contact in self.iter_contacts() if contact.settings is item.settings and not contact.group.virtual]
                icon = icon_manager.get(item.settings.id)
                icon_data = icon and icon.content
                alternate_icon = icon_manager.get(item.settings.id + '_alt')
                alternate_icon_data = alternate_icon and alternate_icon.content
                undo_operations.append(AddContactOperation(contact=RecallState(item.settings), group_ids=group_ids, icon=icon_data, alternate_icon=alternate_icon_data))
            elif item.group.settings not in removed_items:
                item.group.settings.contacts.remove(item.settings)
                removed_members.append(item.group.settings)
                undo_operations.append(AddGroupMemberOperation(group_id=item.group.settings.id, contact_id=item.settings.id))
        self.deleted_items.append(sorted(undo_operations, key=attrgetter('__priority__')))
        self._atomic_update(save=removed_members, delete=removed_items)

    def iter_contacts(self):
        return (item for item in self.items if isinstance(item, Contact))

    def iter_groups(self):
        return (item for item in self.items if isinstance(item, Group))


class ContactSearchModel(QSortFilterProxyModel):
    # The MIME types we accept in drop operations, in the order they should be handled
    accepted_mime_types = ['text/uri-list']

    def __init__(self, model, parent=None):
        super(ContactSearchModel, self).__init__(parent)
        self.contact_list = parent.search_list
        self.setSourceModel(model)
        self.setDynamicSortFilter(True)
        self.sort(0)

    def flags(self, index):
        if index.isValid():
            return QSortFilterProxyModel.flags(self, index) | Qt.ItemIsDropEnabled | Qt.ItemIsDragEnabled
        else:
            return QSortFilterProxyModel.flags(self, index) | Qt.ItemIsDropEnabled

    def filterAcceptsRow(self, source_row, source_parent):
        source_model = self.sourceModel()
        source_index = source_model.index(source_row, 0, source_parent)
        item = source_index.data(Qt.UserRole)
        if isinstance(item, Group) or not item.group.virtual:
            return False
        search_tokens = self.filterRegExp().pattern().lower().split()
        searched_item = ' '.join([item.name] + [uri.uri for uri in item.uris]).lower()  # should we only search in the username part of the uris? -Dan
        return all(token in searched_item for token in search_tokens)

    def lessThan(self, left_index, right_index):
        return left_index.data(Qt.DisplayRole) < right_index.data(Qt.DisplayRole)

    def supportedDropActions(self):
        return Qt.CopyAction

    def mimeTypes(self):
        return ['application/x-blink-contact-list']

    def mimeData(self, indexes):
        mime_data = QMimeData()
        contacts = [index.data(Qt.UserRole) for index in indexes if index.isValid()]
        if contacts:
            mime_data.setData('application/x-blink-contact-list', QByteArray(pickle.dumps(contacts)))
        return mime_data

    def dropMimeData(self, mime_data, action, row, column, parent_index):
        # this is here just to keep the default Qt DnD API happy
        # the custom handler is in handleDroppedData
        return False

    def handleDroppedData(self, mime_data, action, index):
        if action == Qt.IgnoreAction:
            return True

        for mime_type in self.accepted_mime_types:
            if mime_data.hasFormat(mime_type):
                name = mime_type.replace('/', ' ').replace('-', ' ').title().replace(' ', '')
                handler = getattr(self, '_DH_%s' % name)
                return handler(mime_data, action, index)
        else:
            return False

    def _DH_TextUriList(self, mime_data, action, index):
        if not index.isValid():
            return False

        # TODO: support directories? -Saul
        files = [url.toLocalFile() for url in mime_data.urls() if url.isLocalFile() and os.path.isfile(url.toLocalFile())]
        if not files:
            return False

        contact = index.data(Qt.UserRole)
        session_manager = SessionManager()
        for filename in files:
            session_manager.send_file(contact, contact.uri, filename)

        return True


@implementer(IObserver)
class ContactDetailModel(QAbstractListModel):

    contactDeleted = pyqtSignal()

    # The MIME types we accept in drop operations, in the order they should be handled
    accepted_mime_types = ['application/x-blink-session', 'text/uri-list']

    def __init__(self, parent=None):
        super(ContactDetailModel, self).__init__(parent)
        self.contact = None
        notification_center = NotificationCenter()
        notification_center.add_observer(self, name='BlinkContactDetailDidChange')
        notification_center.add_observer(self, name='BlinkContactURIDidChange')
        notification_center.add_observer(self, name='VirtualGroupDidRemoveContact')
        notification_center.add_observer(self, name='VirtualContactDidChange')

    @property
    def contact_detail(self):
        return self.items[0] if self.items else None

    def _get_contact(self):
        return self.__dict__['contact']

    def _set_contact(self, contact):
        old_contact = self.__dict__.get('contact', Null)
        if contact is old_contact:
            return
        notification_center = NotificationCenter()
        if old_contact:
            notification_center.remove_observer(self, sender=old_contact)
        if contact is not None:
            notification_center.add_observer(self, sender=contact)
        self.__dict__['contact'] = contact
        self.beginResetModel()
        if contact is None:
            self.items = []
        else:
            self.items = [ContactDetail(contact)] + [ContactURI(contact, uri) for uri in contact.uris]
        self.endResetModel()

    contact = property(_get_contact, _set_contact)
    del _get_contact, _set_contact

    def flags(self, index):
        if index.isValid():
            return QAbstractListModel.flags(self, index) | Qt.ItemIsDropEnabled | Qt.ItemIsDragEnabled
        else:
            return QAbstractListModel.flags(self, index) | Qt.ItemIsDropEnabled

    def rowCount(self, parent=QModelIndex()):
        return len(self.items)

    def data(self, index, role=Qt.DisplayRole):
        if not index.isValid():
            return None
        row = index.row()
        item = self.items[row]
        if role == Qt.UserRole:
            return item
        elif role == Qt.DisplayRole:
            return str(item)
        elif role == Qt.SizeHintRole:
            return item.size_hint
        elif role == Qt.CheckStateRole and row > 0:
            if item.uri is self.contact.uris.default:
                return Qt.Checked
            elif self.contact.uris.default is None and row == 1:
                return Qt.PartiallyChecked
            return Qt.Unchecked
        return None

    def supportedDropActions(self):
        return Qt.CopyAction

    def mimeTypes(self):
        return ['application/x-blink-contact-list', 'application/x-blink-contact-uri-list']

    def mimeData(self, indexes):
        mime_data = QMimeData()
        items = [self.items[index.row()] for index in indexes if index.isValid()]
        contact_list = [item for item in items if isinstance(item, ContactDetail)]
        contact_uris = [item for item in items if isinstance(item, ContactURI)]
        if contact_list:
            mime_data.setData('application/x-blink-contact-list', QByteArray(pickle.dumps(contact_list)))
        if contact_uris:
            mime_data.setData('application/x-blink-contact-uri-list', QByteArray(pickle.dumps((self.contact_detail, contact_uris))))
        return mime_data

    def dropMimeData(self, mime_data, action, row, column, parent_index):
        # this is here just to keep the default Qt DnD API happy
        # the custom handler is in handleDroppedData
        return False

    def handleDroppedData(self, mime_data, action, index):
        if action == Qt.IgnoreAction:
            return True

        for mime_type in self.accepted_mime_types:
            if mime_data.hasFormat(mime_type):
                name = mime_type.replace('/', ' ').replace('-', ' ').title().replace(' ', '')
                handler = getattr(self, '_DH_%s' % name)
                return handler(mime_data, action, index)
        else:
            return False

    def _DH_ApplicationXBlinkSession(self, mime_data, action, index):
        return False

    def _DH_TextUriList(self, mime_data, action, index):
        if not index.isValid():
            contact_uri = self.contact_detail.uri
        else:
            item = self.items[index.row()]
            if isinstance(item, ContactURI):
                contact_uri = item.uri
            else:
                contact_uri = self.contact_detail.uri

        # TODO: support directories? -Saul
        files = [url.toLocalFile() for url in mime_data.urls() if url.isLocalFile() and os.path.isfile(url.toLocalFile())]
        if not files:
            return False

        contact = self.contact_detail
        session_manager = SessionManager()
        for filename in files:
            session_manager.send_file(contact, contact_uri, filename)

        return True

    @run_in_gui_thread
    def handle_notification(self, notification):
        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    def _NH_AddressbookContactDidChange(self, notification):
        if notification.sender is self.contact and 'uris' in notification.data.modified:
            modified_uris = notification.data.modified['uris']
            for row in sorted((row for row, item in enumerate(self.items) if row > 0 and item.uri in modified_uris.removed), reverse=True):
                self.beginRemoveRows(QModelIndex(), row, row)
                del self.items[row]
                self.endRemoveRows()
            if modified_uris.added:
                position = len(self.items)
                self.beginInsertRows(QModelIndex(), position, position + len(modified_uris.added) - 1)
                self.items += [ContactURI(notification.sender, uri) for uri in modified_uris.added]
                self.endInsertRows()

    def _NH_VirtualContactDidChange(self, notification):
        if notification.sender is self.contact:
            old_uris = set(item.uri for item in self.items[1:])
            added_uris = [uri for uri in self.contact.uris if uri not in old_uris]
            removed_uris = old_uris.difference(self.contact.uris)
            modified_uris = old_uris.difference(removed_uris)
            for row in sorted((row for row, item in enumerate(self.items) if row > 0 and item.uri in removed_uris), reverse=True):
                self.beginRemoveRows(QModelIndex(), row, row)
                del self.items[row]
                self.endRemoveRows()
            if added_uris:
                position = len(self.items)
                self.beginInsertRows(QModelIndex(), position, position + len(added_uris) - 1)
                self.items += [ContactURI(self.contact, uri) for uri in added_uris]
                self.endInsertRows()
            for row in (row for row, item in enumerate(self.items) if row > 0 and item.uri in modified_uris):
                index = self.index(row)
                self.dataChanged.emit(index, index)

    def _NH_VirtualGroupDidRemoveContact(self, notification):
        if notification.data.contact is self.contact:
            self.contact = None
            self.contactDeleted.emit()

    def _NH_BlinkContactDetailDidChange(self, notification):
        if self.items and notification.sender is self.contact_detail:
            index = self.index(0)
            self.dataChanged.emit(index, index)

    def _NH_BlinkContactURIDidChange(self, notification):
        if notification.sender in self.items:
            index = self.index(self.items.index(notification.sender))
            self.dataChanged.emit(index, index)


@implementer(IObserver)
class ContactListView(QListView):

    def __init__(self, parent=None):
        super(ContactListView, self).__init__(parent)
        self.setItemDelegate(ContactDelegate(self))
        self.setDropIndicatorShown(False)
        self.detail_model = ContactDetailModel(self)
        self.detail_view = ContactDetailView(self)
        self.detail_view.setModel(self.detail_model)
        self.detail_view.hide()
        self.context_menu = QMenu(self)
        self.actions = ContextMenuActions()
        self.actions.add_group = QAction(translate("contact_list", "Add Group"), self, triggered=self._AH_AddGroup)
        self.actions.add_contact = QAction(translate("contact_list", "Add Contact"), self, triggered=self._AH_AddContact)
        self.actions.edit_item = QAction(translate("contact_list", "Edit"), self, triggered=self._AH_EditItem)
        self.actions.delete_item = QAction(translate("contact_list", "Delete"), self, triggered=self._AH_DeleteSelection)
        self.actions.delete_selection = QAction(translate("contact_list", "Delete Selection"), self, triggered=self._AH_DeleteSelection)
        self.actions.undo_last_delete = QAction(translate("contact_list", "Undo Last Delete"), self, triggered=self._AH_UndoLastDelete)
        self.actions.start_audio_call = QAction(translate("contact_list", "Start Audio Call"), self, triggered=self._AH_StartAudioCall)
        self.actions.start_video_call = QAction(translate("contact_list", "Start Video Call"), self, triggered=self._AH_StartVideoCall)
        self.actions.start_chat_session = QAction(translate("contact_list", "Start Real Time Chat Session"), self, triggered=self._AH_StartChatSession)
        self.actions.send_sms = QAction(translate("contact_list", "Send Messages"), self, triggered=self._AH_SendSMS)
        self.actions.send_files = QAction(translate("contact_list", "Send File(s)..."), self, triggered=self._AH_SendFiles)
        self.actions.request_screen = QAction(translate("contact_list", "Request Screen"), self, triggered=self._AH_RequestScreen)
        self.actions.share_my_screen = QAction(translate("contact_list", "Share My Screen"), self, triggered=self._AH_ShareMyScreen)
        self.actions.transfer_call = QAction(translate("contact_list", "Transfer Active Call"), self, triggered=self._AH_TransferCall)
        self.drop_indicator_index = QModelIndex()
        self.needs_restore = False
        self.doubleClicked.connect(self._SH_DoubleClicked)  # activated is emitted on single click
        notification_center = NotificationCenter()
        notification_center.add_observer(self, 'BlinkSessionDidChangeState')
        notification_center.add_observer(self, 'BlinkSessionDidRemoveStream')
        notification_center.add_observer(self, 'BlinkActiveSessionDidChange')

    def selectionChanged(self, selected, deselected):
        super(ContactListView, self).selectionChanged(selected, deselected)
        selection_model = self.selectionModel()
        selection = selection_model.selection()
        if selection_model.currentIndex() not in selection:
            index = selection.indexes()[0] if not selection.isEmpty() else self.model().index(-1)
            selection_model.setCurrentIndex(index, selection_model.Select)
        self.context_menu.hide()

    def contextMenuEvent(self, event):
        model = self.model()
        selected_items = [index.data(Qt.UserRole) for index in self.selectionModel().selectedIndexes()]
        if not model.deleted_items:
            undo_delete_text = translate("contact_list", "Undo Delete")
        elif len(model.deleted_items[-1]) == 1:
            operation = model.deleted_items[-1][0]
            if type(operation) is AddContactOperation:
                state = operation.contact.state
                name = state.get('name', 'Contact')
            elif type(operation) is AddGroupOperation:
                state = operation.group.state
                name = state.get('name', 'Group')
            else:
                addressbook_manager = addressbook.AddressbookManager()
                try:
                    contact = addressbook_manager.get_contact(operation.contact_id)
                except KeyError:
                    name = translate('contact_list', 'Contact')
                else:
                    name = contact.name or translate('contact_list', 'Contact')
            undo_delete_text = translate('contact_list', 'Undo Delete "%s"') % name
        else:
            undo_delete_text = translate('contact_list', "Undo Delete (%d items)") % len(model.deleted_items[-1])
        menu = self.context_menu
        menu.clear()
        if not selected_items:
            menu.addAction(self.actions.add_group)
            menu.addAction(self.actions.add_contact)
            menu.addAction(self.actions.undo_last_delete)
            self.actions.undo_last_delete.setText(undo_delete_text)
            self.actions.undo_last_delete.setEnabled(len(model.deleted_items) > 0)
        elif len(selected_items) > 1:
            menu.addAction(self.actions.add_group)
            menu.addAction(self.actions.add_contact)
            menu.addAction(self.actions.delete_selection)
            menu.addAction(self.actions.undo_last_delete)
            self.actions.undo_last_delete.setText(undo_delete_text)
            self.actions.delete_selection.setEnabled(any(item.deletable for item in selected_items))
            self.actions.undo_last_delete.setEnabled(len(model.deleted_items) > 0)
        elif isinstance(selected_items[0], Group):
            menu.addAction(self.actions.add_group)
            menu.addAction(self.actions.add_contact)
            menu.addAction(self.actions.edit_item)
            menu.addAction(self.actions.delete_item)
            menu.addAction(self.actions.undo_last_delete)
            self.actions.undo_last_delete.setText(undo_delete_text)
            self.actions.edit_item.setEnabled(selected_items[0].editable)
            self.actions.delete_item.setEnabled(selected_items[0].deletable)
            self.actions.undo_last_delete.setEnabled(len(model.deleted_items) > 0)
        else:
            contact = selected_items[0]
            account_manager = AccountManager()
            session_manager = SessionManager()
            can_call = account_manager.default_account is not None and contact.uri is not None
            can_transfer = contact.uri is not None and session_manager.active_session is not None and session_manager.active_session.state == 'connected'

            if len(contact.uris) > 1 and can_call:
                call_submenu = menu.addMenu(translate('contact_list', 'Start Audio Call'))
                for uri in contact.uris:
                    uri_text = '%s (%s)' % (uri.uri, uri.type) if uri.type not in ('SIP', 'Other') else uri.uri
                    call_item = QAction(uri_text, self)
                    call_item.triggered.connect(partial(self._AH_StartAudioCall, uri))
                    call_submenu.addAction(call_item)

                call_submenu = menu.addMenu(translate('contact_list', 'Start Video Call'))
                for uri in contact.uris:
                    uri_text = '%s (%s)' % (uri.uri, uri.type) if uri.type not in ('SIP', 'Other') else uri.uri
                    call_item = QAction(uri_text, self)
                    call_item.triggered.connect(partial(self._AH_StartVideoCall, uri))
                    call_submenu.addAction(call_item)

                call_submenu = menu.addMenu(translate('contact_list', 'Send Messages'))
                for uri in contact.uris:
                    uri_text = '%s (%s)' % (uri.uri, uri.type) if uri.type not in ('SIP', 'Other') else uri.uri
                    call_item = QAction(uri_text, self)
                    call_item.triggered.connect(partial(self._AH_SendSMS, uri))
                    call_submenu.addAction(call_item)

                call_submenu = menu.addMenu(translate('contact_list', 'Start Real Time Chat Session'))
                for uri in contact.uris:
                    uri_text = '%s (%s)' % (uri.uri, uri.type) if uri.type not in ('SIP', 'Other') else uri.uri
                    call_item = QAction(uri_text, self)
                    call_item.triggered.connect(partial(self._AH_StartChatSession, uri))
                    call_submenu.addAction(call_item)

                call_submenu = menu.addMenu(translate('contact_list', 'Send File(s)...'))
                for uri in contact.uris:
                    uri_text = '%s (%s)' % (uri.uri, uri.type) if uri.type not in ('SIP', 'Other') else uri.uri
                    call_item = QAction(uri_text, self)
                    call_item.triggered.connect(partial(self._AH_SendFiles, uri))
                    call_submenu.addAction(call_item)

                call_submenu = menu.addMenu(translate('contact_list', 'Request Screen'))
                for uri in contact.uris:
                    uri_text = '%s (%s)' % (uri.uri, uri.type) if uri.type not in ('SIP', 'Other') else uri.uri
                    call_item = QAction(uri_text, self)
                    call_item.triggered.connect(partial(self._AH_RequestScreen, uri))
                    call_submenu.addAction(call_item)

                call_submenu = menu.addMenu(translate('contact_list', 'Share My Screen'))
                for uri in contact.uris:
                    uri_text = '%s (%s)' % (uri.uri, uri.type) if uri.type not in ('SIP', 'Other') else uri.uri
                    call_item = QAction(uri_text, self)
                    call_item.triggered.connect(partial(self._AH_ShareMyScreen, uri))
                    call_submenu.addAction(call_item)

            else:
                menu.addAction(self.actions.start_audio_call)
                menu.addAction(self.actions.start_video_call)
                menu.addAction(self.actions.start_chat_session)
                menu.addAction(self.actions.send_sms)
                menu.addAction(self.actions.send_files)
                menu.addAction(self.actions.request_screen)
                menu.addAction(self.actions.share_my_screen)

                self.actions.start_audio_call.setEnabled(can_call)
                self.actions.start_video_call.setEnabled(can_call)
                self.actions.start_chat_session.setEnabled(can_call)
                self.actions.send_sms.setEnabled(can_call)
                self.actions.send_files.setEnabled(can_call)
                self.actions.request_screen.setEnabled(can_call)
                self.actions.share_my_screen.setEnabled(can_call)

            if len(contact.uris) > 1 and can_transfer:
                call_submenu = menu.addMenu(translate('contact_list', 'Transfer Call'))
                for uri in contact.uris:
                    uri_text = '%s (%s)' % (uri.uri, uri.type) if uri.type not in ('SIP', 'Other') else uri.uri
                    call_item = QAction(uri_text, self)
                    call_item.triggered.connect(lambda: self._AH_TransferCall(uri))
                    call_submenu.addAction(call_item)
            else:
                menu.addAction(self.actions.transfer_call)
                self.actions.transfer_call.setEnabled(can_transfer)

            menu.addSeparator()
            menu.addAction(self.actions.add_group)
            menu.addAction(self.actions.add_contact)
            menu.addAction(self.actions.edit_item)
            menu.addAction(self.actions.delete_item)
            menu.addAction(self.actions.undo_last_delete)
            self.actions.undo_last_delete.setText(undo_delete_text)

            self.actions.edit_item.setEnabled(contact.editable)
            self.actions.delete_item.setEnabled(contact.deletable)
            self.actions.undo_last_delete.setEnabled(len(model.deleted_items) > 0)
        menu.exec_(event.globalPos())

    def hideEvent(self, event):
        self.context_menu.hide()

    def keyPressEvent(self, event):
        if event.key() in (Qt.Key_Enter, Qt.Key_Return):
            selected_indexes = self.selectionModel().selectedIndexes()
            item = selected_indexes[0].data(Qt.UserRole) if len(selected_indexes) == 1 else None
            if isinstance(item, Contact):
                session_manager = SessionManager()
                session_manager.create_session(item, item.uri, item.preferred_media.stream_descriptions, connect=item.preferred_media.autoconnect)
        elif event.key() == Qt.Key_Space:
            selected_indexes = self.selectionModel().selectedIndexes()
            item = selected_indexes[0].data(Qt.UserRole) if len(selected_indexes) == 1 else None
            if isinstance(item, Contact) and self.detail_view.isHidden() and self.detail_view.animation.state() == QPropertyAnimation.Stopped:
                self.detail_model.contact = item.settings
                self.detail_view.animation.setDirection(QPropertyAnimation.Forward)
                self.detail_view.animation.setStartValue(self.visualRect(selected_indexes[0]))
                self.detail_view.animation.setEndValue(self.geometry())
                self.detail_view.raise_()
                self.detail_view.show()
                self.detail_view.animation.start()
        else:
            super(ContactListView, self).keyPressEvent(event)

    def paintEvent(self, event):
        super(ContactListView, self).paintEvent(event)
        if self.drop_indicator_index.isValid():
            rect = self.visualRect(self.drop_indicator_index)
            painter = QPainter(self.viewport())
            painter.setRenderHint(QPainter.Antialiasing, True)
            painter.setBrush(Qt.NoBrush)
            painter.setPen(QPen(QBrush(QColor('#dc3169')), 2.0))
            painter.drawRoundedRect(rect.adjusted(1, 1, -1, -1), 3, 3)
            painter.end()
        model = self.model()
        try:
            last_group = model.items[GroupList][-1]
        except IndexError:
            last_group = Null
        if last_group.widget.drop_indicator is self.BelowItem:
            # draw the bottom part of the drop indicator for the last group if we have one
            rect = self.visualRect(model.index(model.items.index(last_group)))
            line_rect = QRectF(rect.adjusted(18, rect.height(), 0, 5))
            arc_rect = line_rect.adjusted(-5, -3, -line_rect.width(), -3)
            path = QPainterPath(line_rect.topRight())
            path.lineTo(line_rect.topLeft())
            path.arcTo(arc_rect, 0, -180)
            painter = QPainter(self.viewport())
            painter.setRenderHint(QPainter.Antialiasing, True)
            painter.setPen(QPen(QBrush(QColor('#dc3169')), 2.0))
            painter.drawPath(path)
            painter.end()

    def startDrag(self, supported_actions):
        super(ContactListView, self).startDrag(supported_actions)
        if self.needs_restore:
            for group in self.model().items[GroupList]:
                group.restore_state()
            self.needs_restore = False
        main_window = QApplication.instance().main_window
        main_window.switch_view_button.dnd_active = False
        if not main_window.session_model.sessions:
            main_window.switch_view_button.view = SwitchViewButton.ContactView

    def dragEnterEvent(self, event):
        model = self.model()
        event_source = event.source()
        accepted_mime_types = set(model.accepted_mime_types)
        provided_mime_types = set(event.mimeData().formats())
        acceptable_mime_types = accepted_mime_types & provided_mime_types
        has_blink_contacts = 'application/x-blink-contact-list' in provided_mime_types
        has_blink_groups = 'application/x-blink-group-list' in provided_mime_types
        if not acceptable_mime_types:
            event.ignore()  # no acceptable mime types found
        elif has_blink_contacts and has_blink_groups:
            event.ignore()  # we can't handle drops for both groups and contacts at the same time
        elif event_source is not self and (has_blink_contacts or has_blink_groups):
            event.ignore()  # we don't handle drops for blink contacts or groups from other sources
        else:
            if event_source is self:
                event.setDropAction(Qt.MoveAction)
            if has_blink_contacts or has_blink_groups:
                if not self.needs_restore:
                    for group in model.items[GroupList]:
                        group.save_state()
                        group.collapse()
                    self.needs_restore = True
            if has_blink_contacts:
                QApplication.instance().main_window.switch_view_button.dnd_active = True
            event.accept()

    def dragLeaveEvent(self, event):
        super(ContactListView, self).dragLeaveEvent(event)
        self.viewport().update(self.visualRect(self.drop_indicator_index))
        self.drop_indicator_index = QModelIndex()
        for group in self.model().items[GroupList]:
            group.widget.drop_indicator = None

    def dragMoveEvent(self, event):
        super(ContactListView, self).dragMoveEvent(event)
        if event.source() is self:
            event.setDropAction(Qt.MoveAction)

        model = self.model()
        mime_data = event.mimeData()

        for mime_type in model.accepted_mime_types:
            if mime_data.hasFormat(mime_type):
                self.viewport().update(self.visualRect(self.drop_indicator_index))
                self.drop_indicator_index = QModelIndex()
                index = self.indexAt(event.pos())
                rect = self.visualRect(index)
                item = index.data(Qt.UserRole)
                name = mime_type.replace('/', ' ').replace('-', ' ').title().replace(' ', '')
                handler = getattr(self, '_DH_%s' % name)
                handler(event, index, rect, item)
                self.viewport().update(self.visualRect(self.drop_indicator_index))
                break
        else:
            event.ignore()

    def dropEvent(self, event):
        model = self.model()
        if event.source() is self:
            event.setDropAction(Qt.MoveAction)
        if model.handleDroppedData(event.mimeData(), event.dropAction(), self.indexAt(event.pos())):
            event.accept()
        for group in model.items[GroupList]:
            group.widget.drop_indicator = None
        super(ContactListView, self).dropEvent(event)
        self.viewport().update(self.visualRect(self.drop_indicator_index))
        self.drop_indicator_index = QModelIndex()

    def _AH_AddGroup(self):
        group = Group(addressbook.Group())
        group.settings.save = Null  # disable saving until the user provides the name
        model = self.model()
        selection_model = self.selectionModel()
        model.addGroup(group)
        self.scrollToTop()
        group.widget.edit()
        selection_model.select(model.index(model.items.index(group)), selection_model.ClearAndSelect)

    def _AH_AddContact(self):
        groups = set()
        for index in self.selectionModel().selectedIndexes():
            item = index.data(Qt.UserRole)
            if isinstance(item, Group) and not item.virtual:
                groups.add(item)
            elif isinstance(item, Contact) and not item.group.virtual:
                groups.add(item.group)
        preferred_group = groups.pop() if len(groups) == 1 else None
        main_window = QApplication.instance().main_window
        main_window.contact_editor_dialog.open_for_add(main_window.search_box.text(), preferred_group)

    def _AH_EditItem(self):
        index = self.selectionModel().selectedIndexes()[0]
        item = index.data(Qt.UserRole)
        if isinstance(item, Group):
            self.scrollTo(index)
            item.widget.edit()
        else:
            QApplication.instance().main_window.contact_editor_dialog.open_for_edit(item.settings)

    def _AH_DeleteSelection(self):
        self.model().removeItems(self.selectionModel().selectedIndexes())
        self.selectionModel().clearSelection()

    def _AH_UndoLastDelete(self):
        model = self.model()
        addressbook_manager = addressbook.AddressbookManager()
        icon_manager = IconManager()
        modified_settings = []
        for operation in model.deleted_items.pop():
            if type(operation) is AddContactOperation:
                contact = addressbook.Contact(operation.contact.id)
                contact.__setstate__(operation.contact.state)
                modified_settings.append(contact)
                for group_id in operation.group_ids:
                    try:
                        group = addressbook_manager.get_group(group_id)
                    except KeyError:
                        pass
                    else:
                        group.contacts.add(contact)
                        modified_settings.append(group)
                if operation.icon is not None and contact.icon is not None:
                    icon_manager.store_data(contact.id, operation.icon)
                if operation.alternate_icon is not None and contact.alternate_icon is not None:
                    icon_manager.store_data(contact.id + '_alt', operation.alternate_icon)
            elif type(operation) is AddGroupOperation:
                group = addressbook.Group(operation.group.id)
                group.__setstate__(operation.group.state)
                modified_settings.append(group)
            elif type(operation) is AddGroupMemberOperation:
                try:
                    group = addressbook_manager.get_group(operation.group_id)
                    contact = addressbook_manager.get_contact(operation.contact_id)
                except KeyError:
                    pass
                else:
                    group.contacts.add(contact)
                    modified_settings.append(group)
        model._atomic_update(save=modified_settings)

    def _AH_StartAudioCall(self, uri=None):
        contact = self.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
        session_manager = SessionManager()
        session_manager.create_session(contact, uri or contact.uri, [StreamDescription('audio')])

    def _AH_StartVideoCall(self, uri=None):
        contact = self.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
        session_manager = SessionManager()
        session_manager.create_session(contact, uri or contact.uri, [StreamDescription('audio'), StreamDescription('video')])

    def _AH_StartChatSession(self, uri=None):
        contact = self.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
        session_manager = SessionManager()
        session_manager.create_session(contact, uri or contact.uri, [StreamDescription('chat')], connect=False)

    def _AH_SendSMS(self, uri=None):
        contact = self.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
        session_manager = MessageManager()
        try:
            uri = uri.uri
        except AttributeError:
            uri = uri

        session_manager.create_message_session(uri or contact.uri.uri)

    def _AH_SendFiles(self, uri=None):
        session_manager = SessionManager()
        contact = self.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
        for filename in QFileDialog.getOpenFileNames(self, 'Select File(s)', session_manager.send_file_directory, 'Any file (*.*)')[0]:
            session_manager.send_file(contact, uri or contact.uri, filename)

    def _AH_RequestScreen(self, uri=None):
        contact = self.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
        session_manager = SessionManager()
        session_manager.create_session(contact, uri or contact.uri, [StreamDescription('screen-sharing', mode='viewer'), StreamDescription('audio')])

    def _AH_ShareMyScreen(self, uri=None):
        contact = self.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
        session_manager = SessionManager()
        session_manager.create_session(contact, uri or contact.uri, [StreamDescription('screen-sharing', mode='server'), StreamDescription('audio')])

    def _AH_TransferCall(self):
        contact = self.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
        session_manager = SessionManager()
        session_manager.active_session.transfer(contact.uri)

    def _DH_ApplicationXBlinkGroupList(self, event, index, rect, item):
        model = self.model()
        groups = model.items[GroupList]
        for group in groups:
            group.widget.drop_indicator = None
        if not index.isValid():
            drop_groups = (groups[-1], Null)
            rect = self.viewport().rect()
            rect.setTop(self.visualRect(model.index(model.items.index(groups[-1]))).bottom())
        elif isinstance(item, Group):
            index = groups.index(item)
            rect.setHeight(rect.height() / 2)
            if rect.contains(event.pos()):
                drop_groups = (groups[index - 1], groups[index]) if index > 0 else (Null, groups[index])
            else:
                drop_groups = (groups[index], groups[index + 1]) if index < len(groups) - 1 else (groups[index], Null)
                rect.translate(0, rect.height())
        selected_rows = sorted(index.row() for index in self.selectionModel().selectedIndexes() if model.items[index.row()].movable)
        if selected_rows:
            first = groups.index(model.items[selected_rows[0]])
            last = groups.index(model.items[selected_rows[-1]])
            contiguous_selection = len(selected_rows) == last - first + 1
        else:
            contiguous_selection = False
        selected_groups = set(model.items[row] for row in selected_rows)
        overlapping_groups = len(selected_groups.intersection(drop_groups))
        allowed_overlapping = 0 if contiguous_selection else 1
        if event.source() is not self or overlapping_groups <= allowed_overlapping:
            drop_groups[0].widget.drop_indicator = self.BelowItem
            drop_groups[1].widget.drop_indicator = self.AboveItem
        if groups[-1] in drop_groups:
            self.viewport().update()
        event.accept(rect)

    def _DH_ApplicationXBlinkContactList(self, event, index, rect, item):
        model = self.model()
        groups = model.items[GroupList]
        for group in groups:
            group.widget.drop_indicator = None
        if not any(model.items[index.row()].movable for index in self.selectionModel().selectedIndexes()):
            event.accept(rect)
            return
        if not index.isValid():
            group = groups[-1]
            rect = self.viewport().rect()
            rect.setTop(self.visualRect(model.index(model.items.index(group))).bottom())
        elif isinstance(item, Group):
            group = item
        selected_groups = set(model.items[index.row()].group for index in self.selectionModel().selectedIndexes() if model.items[index.row()].movable)
        if not group.virtual and (event.source() is not self or len(selected_groups) > 1 or group not in selected_groups):
            group.widget.drop_indicator = self.OnItem
        event.accept(rect)

    def _DH_TextUriList(self, event, index, rect, item):
        model = self.model()
        if not index.isValid():
            rect = self.viewport().rect()
            rect.setTop(self.visualRect(model.index(len(model.items) - 1)).bottom())
        if isinstance(item, Contact):
            event.accept(rect)
            self.drop_indicator_index = index
        else:
            event.ignore(rect)

    def _SH_DoubleClicked(self, index):
        item = index.data(Qt.UserRole)
        if isinstance(item, Contact):
            session_manager = SessionManager()
            session_manager.create_session(item, item.uri, item.preferred_media.stream_descriptions, connect=item.preferred_media.autoconnect)

    @run_in_gui_thread
    def handle_notification(self, notification):
        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    def _NH_BlinkSessionDidChangeState(self, notification):
        session_manager = SessionManager()
        if notification.sender is session_manager.active_session and self.context_menu.isVisible():
            selected_items = [index.data(Qt.UserRole) for index in self.selectionModel().selectedIndexes()]
            if len(selected_items) == 1 and isinstance(selected_items[0], Contact):
                contact = selected_items[0]
                self.actions.transfer_call.setEnabled(contact.uri is not None and notification.sender.state == 'connected')

    def _NH_BlinkSessionDidRemoveStream(self, notification):
        session_manager = SessionManager()
        if notification.sender is session_manager.active_session and self.context_menu.isVisible():
            selected_items = [index.data(Qt.UserRole) for index in self.selectionModel().selectedIndexes()]
            if len(selected_items) == 1 and isinstance(selected_items[0], Contact):
                contact = selected_items[0]
                self.actions.transfer_call.setEnabled(contact.uri is not None and 'audio' in notification.sender.streams)

    def _NH_BlinkActiveSessionDidChange(self, notification):
        if self.context_menu.isVisible():
            selected_items = [index.data(Qt.UserRole) for index in self.selectionModel().selectedIndexes()]
            if len(selected_items) == 1 and isinstance(selected_items[0], Contact):
                contact = selected_items[0]
                active_session = notification.data.active_session
                self.actions.transfer_call.setEnabled(contact.uri is not None and active_session is not None and active_session.state == 'connected')


@implementer(IObserver)
class ContactSearchListView(QListView):

    def __init__(self, parent=None):
        super(ContactSearchListView, self).__init__(parent)
        self.setItemDelegate(ContactDelegate(self))
        self.setDropIndicatorShown(False)
        self.detail_model = ContactDetailModel(self)
        self.detail_view = ContactDetailView(self)
        self.detail_view.setModel(self.detail_model)
        self.detail_view.hide()
        self.context_menu = QMenu(self)
        self.actions = ContextMenuActions()
        self.actions.edit_item = QAction(translate("contact_list", "Edit"), self, triggered=self._AH_EditItem)
        self.actions.delete_item = QAction(translate("contact_list", "Delete"), self, triggered=self._AH_DeleteSelection)
        self.actions.delete_selection = QAction(translate("contact_list", "Delete Selection"), self, triggered=self._AH_DeleteSelection)
        self.actions.undo_last_delete = QAction(translate("contact_list", "Undo Last Delete"), self, triggered=self._AH_UndoLastDelete)
        self.actions.start_audio_call = QAction(translate("contact_list", "Start Audio Call"), self, triggered=self._AH_StartAudioCall)
        self.actions.start_video_call = QAction(translate("contact_list", "Start Video Call"), self, triggered=self._AH_StartVideoCall)
        self.actions.start_chat_session = QAction(translate("contact_list", "Start Real Time Chat Session"), self, triggered=self._AH_StartChatSession)
        self.actions.send_sms = QAction(translate("contact_list", "Send Messages"), self, triggered=self._AH_SendSMS)
        self.actions.send_files = QAction(translate("contact_list", "Send File(s)..."), self, triggered=self._AH_SendFiles)
        self.actions.request_screen = QAction(translate("contact_list", "Request Screen"), self, triggered=self._AH_RequestScreen)
        self.actions.share_my_screen = QAction(translate("contact_list", "Share My Screen"), self, triggered=self._AH_ShareMyScreen)
        self.actions.transfer_call = QAction(translate("contact_list", "Transfer Active Call"), self, triggered=self._AH_TransferCall)
        self.drop_indicator_index = QModelIndex()
        self.doubleClicked.connect(self._SH_DoubleClicked)  # activated is emitted on single click
        notification_center = NotificationCenter()
        notification_center.add_observer(self, 'BlinkSessionDidChangeState')
        notification_center.add_observer(self, 'BlinkSessionDidRemoveStream')
        notification_center.add_observer(self, 'BlinkActiveSessionDidChange')

    def selectionChanged(self, selected, deselected):
        super(ContactSearchListView, self).selectionChanged(selected, deselected)
        selection_model = self.selectionModel()
        selection = selection_model.selection()
        if selection_model.currentIndex() not in selection:
            index = selection.indexes()[0] if not selection.isEmpty() else self.model().index(-1, -1)
            selection_model.setCurrentIndex(index, selection_model.Select)
        self.context_menu.hide()

    def contextMenuEvent(self, event):
        model = self.model()
        source_model = model.sourceModel()
        selected_items = [index.data(Qt.UserRole) for index in self.selectionModel().selectedIndexes()]
        if not source_model.deleted_items:
            undo_delete_text = "Undo Delete"
        elif len(source_model.deleted_items[-1]) == 1:
            operation = source_model.deleted_items[-1][0]
            if type(operation) is AddContactOperation:
                state = operation.contact.state
                name = state.get('name', 'Contact')
            elif type(operation) is AddGroupOperation:
                state = operation.group.state
                name = state.get('name', 'Group')
            else:
                addressbook_manager = addressbook.AddressbookManager()
                try:
                    contact = addressbook_manager.get_contact(operation.contact_id)
                except KeyError:
                    name = translate('contact_list', 'Contact')
                else:
                    name = contact.name or translate('contact_list', 'Contact')
            undo_delete_text = translate('contact_list', 'Undo Delete "%s"') % name
        else:
            undo_delete_text = translate('contact_list', "Undo Delete (%d items)") % len(source_model.deleted_items[-1])
        menu = self.context_menu
        menu.clear()
        if not selected_items:
            menu.addAction(self.actions.undo_last_delete)
            self.actions.undo_last_delete.setText(undo_delete_text)
            self.actions.undo_last_delete.setEnabled(len(source_model.deleted_items) > 0)
        elif len(selected_items) > 1:
            menu.addAction(self.actions.delete_selection)
            menu.addAction(self.actions.undo_last_delete)
            self.actions.undo_last_delete.setText(undo_delete_text)
            self.actions.delete_selection.setEnabled(any(item.deletable for item in selected_items))
            self.actions.undo_last_delete.setEnabled(len(source_model.deleted_items) > 0)
        else:
            contact = selected_items[0]
            menu.addAction(self.actions.start_audio_call)
            menu.addAction(self.actions.start_video_call)
            menu.addAction(self.actions.start_chat_session)
            menu.addAction(self.actions.send_sms)
            menu.addAction(self.actions.send_files)
            menu.addAction(self.actions.request_screen)
            menu.addAction(self.actions.share_my_screen)
            menu.addAction(self.actions.transfer_call)
            menu.addSeparator()
            menu.addAction(self.actions.edit_item)
            menu.addAction(self.actions.delete_item)
            menu.addAction(self.actions.undo_last_delete)
            self.actions.undo_last_delete.setText(undo_delete_text)
            account_manager = AccountManager()
            session_manager = SessionManager()
            can_call = account_manager.default_account is not None and contact.uri is not None
            can_transfer = contact.uri is not None and session_manager.active_session is not None and session_manager.active_session.state == 'connected'
            self.actions.start_audio_call.setEnabled(can_call)
            self.actions.start_video_call.setEnabled(can_call)
            self.actions.start_chat_session.setEnabled(can_call)
            self.actions.send_sms.setEnabled(can_call)
            self.actions.send_files.setEnabled(can_call)
            self.actions.request_screen.setEnabled(can_call)
            self.actions.share_my_screen.setEnabled(can_call)
            self.actions.transfer_call.setEnabled(can_transfer)
            self.actions.edit_item.setEnabled(contact.editable)
            self.actions.delete_item.setEnabled(contact.deletable)
            self.actions.undo_last_delete.setEnabled(len(source_model.deleted_items) > 0)
        menu.exec_(event.globalPos())

    def focusInEvent(self, event):
        super(ContactSearchListView, self).focusInEvent(event)
        model = self.model()
        selection_model = self.selectionModel()
        if not selection_model.selectedIndexes() and model.rowCount() > 0:
            selection_model.setCurrentIndex(model.index(-1, -1), selection_model.NoUpdate)

    def hideEvent(self, event):
        self.context_menu.hide()

    def keyPressEvent(self, event):
        if event.key() in (Qt.Key_Enter, Qt.Key_Return):
            selected_indexes = self.selectionModel().selectedIndexes()
            item = selected_indexes[0].data(Qt.UserRole) if len(selected_indexes) == 1 else None
            if isinstance(item, Contact):
                session_manager = SessionManager()
                session_manager.create_session(item, item.uri, item.preferred_media.stream_descriptions, connect=item.preferred_media.autoconnect)
        elif event.key() == Qt.Key_Escape:
            QApplication.instance().main_window.search_box.clear()
        elif event.key() == Qt.Key_Space:
            selected_indexes = self.selectionModel().selectedIndexes()
            item = selected_indexes[0].data(Qt.UserRole) if len(selected_indexes) == 1 else None
            if isinstance(item, Contact) and self.detail_view.isHidden() and self.detail_view.animation.state() == QPropertyAnimation.Stopped:
                self.detail_model.contact = item.settings
                self.detail_view.animation.setDirection(QPropertyAnimation.Forward)
                self.detail_view.animation.setStartValue(self.visualRect(selected_indexes[0]))
                self.detail_view.animation.setEndValue(self.geometry())
                self.detail_view.raise_()
                self.detail_view.show()
                self.detail_view.animation.start()
        else:
            super(ContactSearchListView, self).keyPressEvent(event)

    def paintEvent(self, event):
        super(ContactSearchListView, self).paintEvent(event)
        if self.drop_indicator_index.isValid():
            rect = self.visualRect(self.drop_indicator_index)
            painter = QPainter(self.viewport())
            painter.setRenderHint(QPainter.Antialiasing, True)
            painter.setBrush(Qt.NoBrush)
            painter.setPen(QPen(QBrush(QColor('#dc3169')), 2.0))
            painter.drawRoundedRect(rect.adjusted(1, 1, -1, -1), 3, 3)
            painter.end()

    def startDrag(self, supported_actions):
        super(ContactSearchListView, self).startDrag(supported_actions)
        main_window = QApplication.instance().main_window
        main_window.switch_view_button.dnd_active = False
        if not main_window.session_model.sessions:
            main_window.switch_view_button.view = SwitchViewButton.ContactView

    def dragEnterEvent(self, event):
        accepted_mime_types = set(self.model().accepted_mime_types)
        provided_mime_types = set(event.mimeData().formats())
        acceptable_mime_types = accepted_mime_types & provided_mime_types
        if event.source() is self:
            event.ignore()
            QApplication.instance().main_window.switch_view_button.dnd_active = True
        elif not acceptable_mime_types:
            event.ignore()
        else:
            event.accept()

    def dragLeaveEvent(self, event):
        super(ContactSearchListView, self).dragLeaveEvent(event)
        self.viewport().update(self.visualRect(self.drop_indicator_index))
        self.drop_indicator_index = QModelIndex()

    def dragMoveEvent(self, event):
        super(ContactSearchListView, self).dragMoveEvent(event)

        mime_data = event.mimeData()

        for mime_type in self.model().accepted_mime_types:
            if mime_data.hasFormat(mime_type):
                self.viewport().update(self.visualRect(self.drop_indicator_index))
                self.drop_indicator_index = QModelIndex()
                index = self.indexAt(event.pos())
                rect = self.visualRect(index)
                item = index.data(Qt.UserRole)
                name = mime_type.replace('/', ' ').replace('-', ' ').title().replace(' ', '')
                handler = getattr(self, '_DH_%s' % name)
                handler(event, index, rect, item)
                self.viewport().update(self.visualRect(self.drop_indicator_index))
                break
        else:
            event.ignore()

    def dropEvent(self, event):
        model = self.model()
        if model.handleDroppedData(event.mimeData(), event.dropAction(), self.indexAt(event.pos())):
            event.accept()
        super(ContactSearchListView, self).dropEvent(event)
        self.viewport().update(self.visualRect(self.drop_indicator_index))
        self.drop_indicator_index = QModelIndex()

    def _AH_EditItem(self):
        contact = self.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
        QApplication.instance().main_window.contact_editor_dialog.open_for_edit(contact.settings)

    def _AH_DeleteSelection(self):
        model = self.model()
        model.sourceModel().removeItems(model.mapToSource(index) for index in self.selectionModel().selectedIndexes())

    def _AH_UndoLastDelete(self):
        model = self.model().sourceModel()
        addressbook_manager = addressbook.AddressbookManager()
        icon_manager = IconManager()
        modified_settings = []
        for operation in model.deleted_items.pop():
            if type(operation) is AddContactOperation:
                contact = addressbook.Contact(operation.contact.id)
                contact.__setstate__(operation.contact.state)
                modified_settings.append(contact)
                for group_id in operation.group_ids:
                    try:
                        group = addressbook_manager.get_group(group_id)
                    except KeyError:
                        pass
                    else:
                        group.contacts.add(contact)
                        modified_settings.append(group)
                if operation.icon is not None and contact.icon is not None:
                    icon_manager.store_data(contact.id, operation.icon)
                if operation.alternate_icon is not None and contact.alternate_icon is not None:
                    icon_manager.store_data(contact.id + '_alt', operation.alternate_icon)
            elif type(operation) is AddGroupOperation:
                group = addressbook.Group(operation.group.id)
                group.__setstate__(operation.group.state)
                modified_settings.append(group)
            elif type(operation) is AddGroupMemberOperation:
                try:
                    group = addressbook_manager.get_group(operation.group_id)
                    contact = addressbook_manager.get_contact(operation.contact_id)
                except KeyError:
                    pass
                else:
                    group.contacts.add(contact)
                    modified_settings.append(group)
        model._atomic_update(save=modified_settings)

    def _AH_StartAudioCall(self, uri=None):
        contact = self.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
        session_manager = SessionManager()
        session_manager.create_session(contact, uri or contact.uri, [StreamDescription('audio')])

    def _AH_StartVideoCall(self, uri=None):
        contact = self.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
        session_manager = SessionManager()
        session_manager.create_session(contact, uri or contact.uri, [StreamDescription('audio'), StreamDescription('video')])

    def _AH_StartChatSession(self, uri=None):
        contact = self.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
        session_manager = SessionManager()
        session_manager.create_session(contact, uri or contact.uri, [StreamDescription('chat')], connect=False)

    def _AH_SendSMS(self, uri=None):
        contact = self.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
        session_manager = MessageManager()
        session_manager.create_message_session(uri or contact.uri.uri)

    def _AH_SendFiles(self, uri=None):
        session_manager = SessionManager()
        contact = self.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
        for filename in QFileDialog.getOpenFileNames(self, translate('contact_list', 'Select File(s)'), session_manager.send_file_directory, 'Any file (*.*)')[0]:
            session_manager.send_file(contact, uri or contact.uri, filename)

    def _AH_RequestScreen(self, uri=None):
        contact = self.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
        session_manager = SessionManager()
        session_manager.create_session(contact, uri or contact.uri, [StreamDescription('screen-sharing', mode='viewer'), StreamDescription('audio')])

    def _AH_ShareMyScreen(self, uri=None):
        contact = self.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
        session_manager = SessionManager()
        session_manager.create_session(contact, uri or contact.uri, [StreamDescription('screen-sharing', mode='server'), StreamDescription('audio')])

    def _AH_TransferCall(self, uri=None):
        contact = self.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
        session_manager = SessionManager()
        session_manager.active_session.transfer(uri or contact.uri)

    def _DH_TextUriList(self, event, index, rect, item):
        if index.isValid():
            event.accept(rect)
            self.drop_indicator_index = index
        else:
            model = self.model()
            rect = self.viewport().rect()
            rect.setTop(self.visualRect(model.index(model.rowCount() - 1, 0)).bottom())
            event.ignore(rect)

    def _SH_DoubleClicked(self, index):
        item = index.data(Qt.UserRole)
        if isinstance(item, Contact):
            session_manager = SessionManager()
            session_manager.create_session(item, item.uri, item.preferred_media.stream_descriptions, connect=item.preferred_media.autoconnect)

    @run_in_gui_thread
    def handle_notification(self, notification):
        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    def _NH_BlinkSessionDidChangeState(self, notification):
        session_manager = SessionManager()
        if notification.sender is session_manager.active_session and self.context_menu.isVisible():
            selected_items = [index.data(Qt.UserRole) for index in self.selectionModel().selectedIndexes()]
            if len(selected_items) == 1 and isinstance(selected_items[0], Contact):
                contact = selected_items[0]
                self.actions.transfer_call.setEnabled(contact.uri is not None and notification.sender.state == 'connected')

    def _NH_BlinkSessionDidRemoveStream(self, notification):
        session_manager = SessionManager()
        if notification.sender is session_manager.active_session and self.context_menu.isVisible():
            selected_items = [index.data(Qt.UserRole) for index in self.selectionModel().selectedIndexes()]
            if len(selected_items) == 1 and isinstance(selected_items[0], Contact):
                contact = selected_items[0]
                self.actions.transfer_call.setEnabled(contact.uri is not None and 'audio' in notification.sender.streams)

    def _NH_BlinkActiveSessionDidChange(self, notification):
        if self.context_menu.isVisible():
            selected_items = [index.data(Qt.UserRole) for index in self.selectionModel().selectedIndexes()]
            if len(selected_items) == 1 and isinstance(selected_items[0], Contact):
                contact = selected_items[0]
                active_session = notification.data.active_session
                self.actions.transfer_call.setEnabled(contact.uri is not None and active_session is not None and active_session.state == 'connected')


@implementer(IObserver)
class ContactDetailView(QListView):

    def __init__(self, contact_list):
        super(ContactDetailView, self).__init__(contact_list.parent())
        palette = self.palette()
        palette.setColor(QPalette.AlternateBase, QColor('#eeeeee'))
        self.setPalette(palette)
        self.contact_list = contact_list
        self.setItemDelegate(ContactDetailDelegate(self))
        self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        self.setDragEnabled(True)
        self.setDragDropMode(QListView.DragDrop)
        self.setAlternatingRowColors(True)
        self.setSelectionMode(QListView.SingleSelection)
        self.setDropIndicatorShown(False)
        self.animation = QPropertyAnimation(self, b'geometry')
        self.animation.setDuration(250)
        self.animation.setEasingCurve(QEasingCurve.Linear)
        self.animation.finished.connect(self._SH_AnimationFinished)
        self.context_menu = QMenu(self)
        self.actions = ContextMenuActions()
        self.actions.delete_contact = QAction(translate("contact_list", "Delete Contact"), self, triggered=self._AH_DeleteContact)
        self.actions.edit_contact = QAction(translate("contact_list", "Edit Contact"), self, triggered=self._AH_EditContact)
        self.actions.make_uri_default = QAction(translate("contact_list", "Set Address As Default"), self, triggered=self._AH_MakeURIDefault)
        self.actions.start_audio_call = QAction(translate("contact_list", "Start Audio Call"), self, triggered=self._AH_StartAudioCall)
        self.actions.start_video_call = QAction(translate("contact_list", "Start Video Call"), self, triggered=self._AH_StartVideoCall)
        self.actions.start_chat_session = QAction(translate("contact_list", "Start Real Time Chat Session"), self, triggered=self._AH_StartChatSession)
        self.actions.send_sms = QAction(translate("contact_list", "Send Messages"), self, triggered=self._AH_SendSMS)
        self.actions.send_files = QAction(translate("contact_list", "Send File(s)..."), self, triggered=self._AH_SendFiles)
        self.actions.request_screen = QAction(translate("contact_list", "Request Screen"), self, triggered=self._AH_RequestScreen)
        self.actions.share_my_screen = QAction(translate("contact_list", "Share My Screen"), self, triggered=self._AH_ShareMyScreen)
        self.actions.transfer_call = QAction(translate("contact_list", "Transfer Active Call"), self, triggered=self._AH_TransferCall)
        self.drop_indicator_index = QModelIndex()
        self.doubleClicked.connect(self._SH_DoubleClicked)  # activated is emitted on single click
        contact_list.installEventFilter(self)
        notification_center = NotificationCenter()
        notification_center.add_observer(self, 'BlinkSessionDidChangeState')
        notification_center.add_observer(self, 'BlinkSessionDidRemoveStream')
        notification_center.add_observer(self, 'BlinkActiveSessionDidChange')

    def setModel(self, model):
        old_model = self.model() or Null
        old_model.contactDeleted.disconnect(self._SH_ModelContactDeleted)
        super(ContactDetailView, self).setModel(model)
        model.contactDeleted.connect(self._SH_ModelContactDeleted)

    def selectionChanged(self, selected, deselected):
        super(ContactDetailView, self).selectionChanged(selected, deselected)
        selection_model = self.selectionModel()
        selection = selection_model.selection()
        if selection_model.currentIndex() not in selection:
            index = selection.indexes()[0] if not selection.isEmpty() else self.model().index(-1)
            selection_model.setCurrentIndex(index, selection_model.Select)

    def eventFilter(self, watched, event):
        if event.type() == QEvent.Resize:
            new_size = event.size()
            geometry = self.animation.endValue()
            if geometry is not None:
                old_size = geometry.size()
                geometry.setSize(new_size)
                self.animation.setEndValue(geometry)
                geometry = self.animation.startValue()
                geometry.setWidth(geometry.width() + new_size.width() - old_size.width())
                self.animation.setStartValue(geometry)
            self.resize(new_size)
        return False

    def contextMenuEvent(self, event):
        account_manager = AccountManager()
        session_manager = SessionManager()
        model = self.model()
        selected_indexes = self.selectionModel().selectedIndexes()
        selected_item = selected_indexes[0].data(Qt.UserRole) if selected_indexes else None
        contact_has_uris = model.rowCount() > 1
        menu = self.context_menu
        menu.clear()
        menu.addAction(self.actions.start_audio_call)
        menu.addAction(self.actions.start_video_call)
        menu.addAction(self.actions.start_chat_session)
        menu.addAction(self.actions.send_sms)
        menu.addAction(self.actions.send_files)
        menu.addAction(self.actions.request_screen)
        menu.addAction(self.actions.share_my_screen)
        menu.addAction(self.actions.transfer_call)
        menu.addSeparator()
        if isinstance(selected_item, ContactURI) and model.contact_detail.editable:
            menu.addAction(self.actions.make_uri_default)
            self.actions.make_uri_default.setEnabled(selected_item.uri is not model.contact.uris.default)
        menu.addAction(self.actions.edit_contact)
        menu.addAction(self.actions.delete_contact)
        can_call = account_manager.default_account is not None and contact_has_uris
        can_transfer = contact_has_uris and session_manager.active_session is not None and session_manager.active_session.state == 'connected'
        self.actions.start_audio_call.setEnabled(can_call)
        self.actions.start_video_call.setEnabled(can_call)
        self.actions.start_chat_session.setEnabled(can_call)
        self.actions.send_sms.setEnabled(can_call)
        self.actions.send_files.setEnabled(can_call)
        self.actions.request_screen.setEnabled(can_call)
        self.actions.share_my_screen.setEnabled(can_call)
        self.actions.transfer_call.setEnabled(can_transfer)
        self.actions.edit_contact.setEnabled(model.contact_detail.editable)
        self.actions.delete_contact.setEnabled(model.contact_detail.deletable)
        menu.exec_(event.globalPos())

    def hideEvent(self, event):
        self.context_menu.hide()

    def keyPressEvent(self, event):
        if event.key() in (Qt.Key_Enter, Qt.Key_Return):
            contact = self.contact_list.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
            selected_indexes = self.selectionModel().selectedIndexes()
            item = selected_indexes[0].data(Qt.UserRole) if selected_indexes else None
            if isinstance(item, ContactURI):
                selected_uri = item.uri
            else:
                selected_uri = contact.uri
            session_manager = SessionManager()
            session_manager.create_session(contact, selected_uri, contact.preferred_media.stream_descriptions, connect=contact.preferred_media.autoconnect)
        elif event.key() == Qt.Key_Escape:
            self.animation.setDirection(QPropertyAnimation.Backward)
            self.animation.start()
        else:
            super(ContactDetailView, self).keyPressEvent(event)

    def paintEvent(self, event):
        super(ContactDetailView, self).paintEvent(event)
        if self.drop_indicator_index.isValid():
            rect = self.visualRect(self.drop_indicator_index)
            painter = QPainter(self.viewport())
            painter.setRenderHint(QPainter.Antialiasing, True)
            painter.setBrush(Qt.NoBrush)
            painter.setPen(QPen(QBrush(QColor('#dc3169')), 2.0))
            painter.drawRoundedRect(rect.adjusted(1, 1, -1, -1), 3, 3)
            painter.end()

    def startDrag(self, supported_actions):
        super(ContactDetailView, self).startDrag(supported_actions)
        main_window = QApplication.instance().main_window
        main_window.switch_view_button.dnd_active = False
        if not main_window.session_model.sessions:
            main_window.switch_view_button.view = SwitchViewButton.ContactView

    def dragEnterEvent(self, event):
        if event.source() is self:
            QApplication.instance().main_window.switch_view_button.dnd_active = True
        if set(event.mimeData().formats()).isdisjoint(self.model().accepted_mime_types):
            event.ignore()
        else:
            event.accept()

    def dragLeaveEvent(self, event):
        super(ContactDetailView, self).dragLeaveEvent(event)
        self.viewport().update(self.visualRect(self.drop_indicator_index))
        self.drop_indicator_index = QModelIndex()

    def dragMoveEvent(self, event):
        super(ContactDetailView, self).dragMoveEvent(event)

        model = self.model()
        mime_data = event.mimeData()

        for mime_type in model.accepted_mime_types:
            if mime_data.hasFormat(mime_type):
                self.viewport().update(self.visualRect(self.drop_indicator_index))
                self.drop_indicator_index = QModelIndex()
                index = self.indexAt(event.pos())
                rect = self.visualRect(index)
                item = index.data(Qt.UserRole)
                name = mime_type.replace('/', ' ').replace('-', ' ').title().replace(' ', '')
                handler = getattr(self, '_DH_%s' % name)
                handler(event, index, rect, item)
                self.viewport().update(self.visualRect(self.drop_indicator_index))
                break
        else:
            event.ignore()

    def dropEvent(self, event):
        model = self.model()
        if model.handleDroppedData(event.mimeData(), event.dropAction(), self.indexAt(event.pos())):
            event.accept()
        super(ContactDetailView, self).dropEvent(event)
        self.viewport().update(self.visualRect(self.drop_indicator_index))
        self.drop_indicator_index = QModelIndex()

    def _AH_DeleteContact(self):
        self.contact_list._AH_DeleteSelection()

    def _AH_EditContact(self):
        QApplication.instance().main_window.contact_editor_dialog.open_for_edit(self.model().contact)

    def _AH_MakeURIDefault(self):
        model = self.model()
        contact_uri = self.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
        model.contact.uris.default = contact_uri.uri
        model.contact.save()

    def _AH_StartAudioCall(self, uri=None):
        contact = self.contact_list.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
        selected_indexes = self.selectionModel().selectedIndexes()
        item = selected_indexes[0].data(Qt.UserRole) if selected_indexes else None
        if isinstance(item, ContactURI):
            selected_uri = item.uri
        else:
            selected_uri = uri or contact.uri
        session_manager = SessionManager()
        session_manager.create_session(contact, selected_uri, [StreamDescription('audio')])

    def _AH_StartVideoCall(self, uri=None):
        contact = self.contact_list.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
        selected_indexes = self.selectionModel().selectedIndexes()
        item = selected_indexes[0].data(Qt.UserRole) if selected_indexes else None
        if isinstance(item, ContactURI):
            selected_uri = item.uri
        else:
            selected_uri = uri or contact.uri
        session_manager = SessionManager()
        session_manager.create_session(contact, selected_uri, [StreamDescription('audio'), StreamDescription('video')])

    def _AH_StartChatSession(self, uri=None):
        contact = self.contact_list.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
        selected_indexes = self.selectionModel().selectedIndexes()
        item = selected_indexes[0].data(Qt.UserRole) if selected_indexes else None
        if isinstance(item, ContactURI):
            selected_uri = item.uri
        else:
            selected_uri = uri or contact.uri
        session_manager = SessionManager()
        session_manager.create_session(contact, selected_uri, [StreamDescription('chat')], connect=False)

    def _AH_SendSMS(self, uri=None):
        contact = self.contact_list.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
        selected_indexes = self.selectionModel().selectedIndexes()
        item = selected_indexes[0].data(Qt.UserRole) if selected_indexes else None
        if isinstance(item, ContactURI):
            selected_uri = item.uri
        else:
            selected_uri = uri or contact.uri.uri
        session_manager = MessageManager()
        session_manager.create_message_session(selected_uri)

    def _AH_SendFiles(self, uri=None):
        session_manager = SessionManager()
        contact = self.contact_list.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
        selected_indexes = self.selectionModel().selectedIndexes()
        item = selected_indexes[0].data(Qt.UserRole) if selected_indexes else None
        if isinstance(item, ContactURI):
            selected_uri = item.uri
        else:
            selected_uri = uri or contact.uri
        for filename in QFileDialog.getOpenFileNames(self, translate('contact_list', 'Select File(s)'), session_manager.send_file_directory, 'Any file (*.*)')[0]:
            session_manager.send_file(contact, selected_uri, filename)

    def _AH_RequestScreen(self, uri=None):
        contact = self.contact_list.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
        selected_indexes = self.selectionModel().selectedIndexes()
        item = selected_indexes[0].data(Qt.UserRole) if selected_indexes else None
        if isinstance(item, ContactURI):
            selected_uri = item.uri
        else:
            selected_uri = uri or contact.uri
        session_manager = SessionManager()
        session_manager.create_session(contact, selected_uri, [StreamDescription('screen-sharing', mode='viewer'), StreamDescription('audio')])

    def _AH_ShareMyScreen(self, uri=None):
        contact = self.contact_list.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
        selected_indexes = self.selectionModel().selectedIndexes()
        item = selected_indexes[0].data(Qt.UserRole) if selected_indexes else None
        if isinstance(item, ContactURI):
            selected_uri = item.uri
        else:
            selected_uri = uri or contact.uri
        session_manager = SessionManager()
        session_manager.create_session(contact, selected_uri, [StreamDescription('screen-sharing', mode='server'), StreamDescription('audio')])

    def _AH_TransferCall(self, uri=None):
        contact = self.contact_list.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
        selected_indexes = self.selectionModel().selectedIndexes()
        item = selected_indexes[0].data(Qt.UserRole) if selected_indexes else None
        if isinstance(item, ContactURI):
            selected_uri = item.uri
        else:
            selected_uri = uri or contact.uri
        session_manager = SessionManager()
        session_manager.active_session.transfer(selected_uri)

    def _DH_ApplicationXBlinkSession(self, event, index, rect, item):
        event.ignore(rect)

    def _DH_TextUriList(self, event, index, rect, item):
        if index.isValid():
            event.accept(rect)
            self.drop_indicator_index = index
        else:
            model = self.model()
            rect = self.viewport().rect()
            rect.setTop(self.visualRect(model.index(model.rowCount() - 1, 0)).bottom())
            event.accept(rect)

    def _SH_AnimationFinished(self):
        if self.animation.direction() == QPropertyAnimation.Forward:
            self.setFocus(Qt.OtherFocusReason)
        else:
            self.hide()
            self.contact_list.setFocus(Qt.OtherFocusReason)

    def _SH_ModelContactDeleted(self):
        if self.isVisible():
            if self.animation.state() == QPropertyAnimation.Running:
                self.animation.pause()
                self.animation.setDirection(QPropertyAnimation.Backward)
                self.animation.resume()
            else:
                self.animation.setDirection(QPropertyAnimation.Backward)
                self.animation.start()

    def _SH_DoubleClicked(self, index):
        contact = self.contact_list.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
        item = index.data(Qt.UserRole)
        if isinstance(item, ContactURI):
            selected_uri = item.uri
        else:
            selected_uri = contact.uri
        session_manager = SessionManager()
        session_manager.create_session(contact, selected_uri, contact.preferred_media.stream_descriptions, connect=contact.preferred_media.autoconnect)

    @run_in_gui_thread
    def handle_notification(self, notification):
        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    def _NH_BlinkSessionDidChangeState(self, notification):
        session_manager = SessionManager()
        if notification.sender is session_manager.active_session and self.context_menu.isVisible():
            contact_has_uris = self.model().rowCount() > 1
            self.actions.transfer_call.setEnabled(contact_has_uris and notification.sender.state == 'connected')

    def _NH_BlinkSessionDidRemoveStream(self, notification):
        session_manager = SessionManager()
        if notification.sender is session_manager.active_session and self.context_menu.isVisible():
            contact_has_uris = self.model().rowCount() > 1
            self.actions.transfer_call.setEnabled(contact_has_uris and 'audio' in notification.sender.streams)

    def _NH_BlinkActiveSessionDidChange(self, notification):
        if self.context_menu.isVisible():
            contact_has_uris = self.model().rowCount() > 1
            active_session = notification.data.active_session
            self.actions.transfer_call.setEnabled(contact_has_uris and active_session is not None and active_session.state == 'connected')


# The contact editor dialog
#

class ContactURIItem(object):
    def __init__(self, id, uri, type=None, default=False, ghost=False):
        self.id = id
        self.uri = uri
        self.type = type
        self.default = default
        self.ghost = ghost

    def __repr__(self):
        return "%s(%r, %r, type=%r, default=%r, ghost=%r)" % (self.__class__.__name__, self.id, self.uri, self.type, self.default, self.ghost)


class URITypeComboBox(QComboBox):
    builtin_types = (None,
                     QT_TRANSLATE_NOOP('contact_editor', "Mobile"),
                     QT_TRANSLATE_NOOP('contact_editor', "Home"),
                     QT_TRANSLATE_NOOP('contact_editor', "Work"),
                     QT_TRANSLATE_NOOP('contact_editor', "SIP"),
                     QT_TRANSLATE_NOOP('contact_editor', "XMPP"),
                     QT_TRANSLATE_NOOP('contact_editor', "Other"))

    def __init__(self, parent=None, types=()):
        super(URITypeComboBox, self).__init__(parent)
        self.setEditable(True)
        self.addItems((translate('contact_editor', item) for item in self.builtin_types))
        self.addItems(sorted(set(types) - set(self.builtin_types)))


class EmbeddedRadioButton(QRadioButton):
    """An embedded radio button that passes mouse events to its parent"""

    def mousePressEvent(self, event):
        super(EmbeddedRadioButton, self).mousePressEvent(event)
        self.parent().mousePressEvent(event)

    def mouseReleaseEvent(self, event):
        super(EmbeddedRadioButton, self).mouseReleaseEvent(event)
        self.parent().mouseReleaseEvent(event)


class DefaultURIButton(QWidget):
    def __init__(self, parent=None, button_group=Null):
        super(DefaultURIButton, self).__init__(parent)
        self.setContentsMargins(0, 0, 0, 0)
        self.setAutoFillBackground(False)
        self.button = EmbeddedRadioButton(self)
        self.button.installEventFilter(self)
        self.layout = QHBoxLayout(self)
        self.layout.setContentsMargins(0, 0, 0, 0)
        self.layout.setSpacing(0)
        self.layout.addWidget(self.button)
        self.layout.setAlignment(self.button, Qt.AlignCenter)
        button_group.addButton(self.button)

    def eventFilter(self, watched, event):
        if event.type() == QEvent.FocusIn:
            self.setFocus(Qt.OtherFocusReason)
        return False

    def isChecked(self):
        return self.button.isChecked()

    def setChecked(self, state):
        self.button.setChecked(state)


class ContactURIDelegate(QItemDelegate):
    def createEditor(self, parent, option, index):
        column = index.column()
        if column == ContactURIModel.TypeColumn:
            return URITypeComboBox(parent, types=index.model().uri_types)
        elif column == ContactURIModel.DefaultColumn:
            return DefaultURIButton(parent, index.model().button_group)
        return super(ContactURIDelegate, self).createEditor(parent, option, index)

    def setEditorData(self, widget, index):
        column = index.column()
        if column == ContactURIModel.TypeColumn:
            widget.setCurrentIndex(widget.findText(index.data(Qt.EditRole)))
        elif column == ContactURIModel.DefaultColumn:
            widget.setChecked(index.data(Qt.EditRole))
        else:
            super(ContactURIDelegate, self).setEditorData(widget, index)

    def setModelData(self, widget, model, index):
        column = index.column()
        if column == ContactURIModel.TypeColumn:
            model.setData(index, widget.currentText(), Qt.EditRole)
        elif column == ContactURIModel.DefaultColumn:
            model.setData(index, widget.isChecked(), Qt.EditRole)
        else:
            super(ContactURIDelegate, self).setModelData(widget, model, index)

    def updateEditorGeometry(self, editor, option, index):
        editor.setGeometry(option.rect)

    def drawDisplay(self, painter, option, rect, text):
        if option.fontMetrics.width(text) > rect.width():
            # draw elided text using a fading gradient
            color_group = QPalette.Disabled if not option.state & QStyle.State_Enabled else QPalette.Normal if option.state & QStyle.State_Active else QPalette.Inactive
            text_margin = option.widget.style().pixelMetric(QStyle.PM_FocusFrameHMargin, None, option.widget) + 1
            text_rect = rect.adjusted(text_margin, 0, -text_margin, 0)  # remove width padding
            width = text_rect.width()
            fade_start = 1 - 50.0 / width if width > 50 else 0.0
            gradient = QLinearGradient(0, 0, width, 0)
            gradient.setColorAt(fade_start, option.palette.color(color_group, QPalette.HighlightedText if option.state & QStyle.State_Selected else QPalette.Text))
            gradient.setColorAt(1.0, Qt.transparent)
            painter.save()
            painter.setPen(QPen(QBrush(gradient), 1.0))
            painter.setClipRect(text_rect)
            painter.drawText(text_rect, Qt.TextSingleLine | int(option.displayAlignment), text)
            painter.restore()
        else:
            super(ContactURIDelegate, self).drawDisplay(painter, option, rect, text)


class ContactURIModel(QAbstractTableModel):
    columns = (QT_TRANSLATE_NOOP('contact_editor', 'Address'),
               QT_TRANSLATE_NOOP('contact_editor', 'Type'),
               QT_TRANSLATE_NOOP('contact_editor', 'Default'))

    AddressColumn = 0
    TypeColumn    = 1
    DefaultColumn = 2

    default_uri_type = 'SIP'

    def __init__(self, parent=None):
        super(ContactURIModel, self).__init__(parent)
        self.table_view = parent.addresses_table
        self.items = []
        self.uri_types = []
        self.button_group = QButtonGroup(parent)

    def flags(self, index):
        if index.isValid():
            return QAbstractTableModel.flags(self, index) | Qt.ItemIsEditable
        else:
            return QAbstractTableModel.flags(self, index)

    def rowCount(self, parent=QModelIndex()):
        return len(self.items)

    def columnCount(self, parent=QModelIndex()):
        return len(self.columns)

    def data(self, index, role=Qt.DisplayRole):
        if not index.isValid():
            return None
        row, column = index.row(), index.column()
        item = self.items[row]
        if role == Qt.UserRole:
            return item
        elif role == Qt.DisplayRole:
            if column == ContactURIModel.AddressColumn:
                return translate('contact_list', 'Edit to add address') if item.ghost else str(item.uri or '')
        elif role == Qt.EditRole:
            if column == ContactURIModel.AddressColumn:
                return str(item.uri or '')
            elif column == ContactURIModel.TypeColumn:
                return item.type or ''
            elif column == ContactURIModel.DefaultColumn:
                return item.default
        elif role == Qt.ForegroundRole:
            if column == ContactURIModel.AddressColumn and item.ghost:
                return self.table_view.palette().brush(QPalette.Disabled, QPalette.Text).color()
        return None

    def setData(self, index, value, role=Qt.EditRole):
        if not index.isValid() or role != Qt.EditRole:
            return False
        row, column = index.row(), index.column()
        if column == ContactURIModel.AddressColumn:
            item = self.items[row]
            item.uri = value
            if item.ghost and value:
                item.ghost = False
                self._add_item(ContactURIItem(None, None, self.default_uri_type, False, ghost=True))
        elif column == ContactURIModel.TypeColumn:
            self.items[row].type = value or None
        elif column == ContactURIModel.DefaultColumn:
            if value:
                for position, item in enumerate(self.items):
                    item.default = position == row
            else:
                self.items[row].default = False
        else:
            return False
        return True

    def headerData(self, section, orientation, role=Qt.DisplayRole):
        if orientation == Qt.Horizontal and role == Qt.DisplayRole:
            return translate('contact_editor', self.columns[section])
        return super(ContactURIModel, self).headerData(section, orientation, role)

    def init_with_address(self, address=None):
        items = [ContactURIItem(None, address, self.default_uri_type, False)] if address else []
        items.append(ContactURIItem(None, None, self.default_uri_type, False, ghost=True))
        self.beginResetModel()
        self.items = items
        self.uri_types = []
        self.button_group = QButtonGroup(self.table_view)
        self.endResetModel()
        for row in range(len(items)):
            self.table_view.openPersistentEditor(self.index(row, ContactURIModel.TypeColumn))
            self.table_view.openPersistentEditor(self.index(row, ContactURIModel.DefaultColumn))
        self.table_view.horizontalHeader().setSectionResizeMode(ContactURIModel.AddressColumn, self.table_view.horizontalHeader().Stretch)

    def init_with_contact(self, contact):
        items = [ContactURIItem(uri.id, uri.uri, uri.type, default=uri is contact.uris.default) for uri in contact.uris]
        items.append(ContactURIItem(None, None, self.default_uri_type, False, ghost=True))
        self.beginResetModel()
        self.items = items
        self.uri_types = [uri.type for uri in contact.uris]
        self.button_group = QButtonGroup(self.table_view)
        self.endResetModel()
        for row in range(len(items)):
            self.table_view.openPersistentEditor(self.index(row, ContactURIModel.TypeColumn))
            self.table_view.openPersistentEditor(self.index(row, ContactURIModel.DefaultColumn))
        self.table_view.horizontalHeader().setSectionResizeMode(ContactURIModel.AddressColumn, self.table_view.horizontalHeader().Stretch)

    def update_from_contact(self, contact):
        added_items = [item for item in self.items if item.id is None and not item.ghost]
        try:
            default_item = next(item for item in self.items if item.default)
        except StopIteration:
            default_item = None
        else:
            if default_item not in added_items:
                default_item = None  # only care for the default URI if it was a newly added one, else use the one from the contact
        items = [ContactURIItem(uri.id, uri.uri, uri.type, default=default_item is None and uri is contact.uris.default) for uri in contact.uris]
        items.extend(added_items)
        items.append(ContactURIItem(None, None, self.default_uri_type, False, ghost=True))
        self.beginResetModel()
        self.items = items
        self.uri_types = [item.type for item in items]
        self.button_group = QButtonGroup(self.table_view)
        self.endResetModel()
        for row in range(len(items)):
            self.table_view.openPersistentEditor(self.index(row, ContactURIModel.TypeColumn))
            self.table_view.openPersistentEditor(self.index(row, ContactURIModel.DefaultColumn))
        self.table_view.horizontalHeader().setSectionResizeMode(ContactURIModel.AddressColumn, self.table_view.horizontalHeader().Stretch)

    def reset(self):
        self.beginResetModel()
        self.items = []
        self.uri_types = []
        self.button_group = QButtonGroup(self.table_view)
        self.endResetModel()

    def _add_item(self, item):
        position = len(self.items)
        self.beginInsertRows(QModelIndex(), position, position)
        self.items.insert(position, item)
        self.endInsertRows()
        self.table_view.openPersistentEditor(self.index(position, ContactURIModel.TypeColumn))
        self.table_view.openPersistentEditor(self.index(position, ContactURIModel.DefaultColumn))

    def _remove_items(self, indexes):
        for row in sorted(set(index.row() for index in indexes if index.isValid()), reverse=True):
            self.beginRemoveRows(QModelIndex(), row, row)
            del self.items[row]
            self.endRemoveRows()


class ContactURITableView(QTableView):
    def __init__(self, parent=None):
        super(ContactURITableView, self).__init__(parent)
        self.setItemDelegate(ContactURIDelegate(self))
        self.context_menu = QMenu(self)
        self.context_menu.addAction(translate('contact_editor', "Delete"), self._AH_DeleteSelection)
        self.horizontalHeader().setSectionResizeMode(self.horizontalHeader().ResizeToContents)

    def selectionChanged(self, selected, deselected):
        super(ContactURITableView, self).selectionChanged(selected, deselected)
        selection_model = self.selectionModel()
        selection = selection_model.selection()
        if selection_model.currentIndex() not in selection:
            index = selection.indexes()[0] if not selection.isEmpty() else self.model().index(-1, -1)
            selection_model.setCurrentIndex(index, selection_model.Select)

    def contextMenuEvent(self, event):
        selected_items = [item for item in (index.data(Qt.UserRole) for index in self.selectionModel().selectedIndexes()) if not item.ghost]
        if selected_items:
            self.context_menu.exec_(event.globalPos())

    def keyPressEvent(self, event):
        if event.key() in (Qt.Key_Backspace, Qt.Key_Delete):
            selected_items = [item for item in (index.data(Qt.UserRole) for index in self.selectionModel().selectedIndexes()) if not item.ghost]
            if selected_items:
                self._AH_DeleteSelection()
        else:
            super(ContactURITableView, self).keyPressEvent(event)

    def _AH_DeleteSelection(self):
        model = self.model()
        model._remove_items([index for index in self.selectionModel().selectedIndexes() if not index.data(Qt.UserRole).ghost])
        self.selectionModel().clearSelection()


ui_class, base_class = uic.loadUiType(Resources.get('contact_editor.ui'))


@implementer(IObserver)
class ContactEditorDialog(base_class, ui_class):

    def __init__(self, parent=None):
        super(ContactEditorDialog, self).__init__(parent)
        with Resources.directory:
            self.setupUi(self)
        self.contact_uri_model = ContactURIModel(self)
        self.addresses_table.setModel(self.contact_uri_model)
        self.edited_contact = None
        self.target_group = None
        self.name_editor.textChanged.connect(self._SH_NameEditorTextChanged)
        self.accepted.connect(self._SH_Accepted)
        self.rejected.connect(self._SH_Rejected)
        self.rejected.connect(self.contact_uri_model.reset)

    def setupUi(self, contact_editor):
        super(ContactEditorDialog, self).setupUi(contact_editor)
        self.preferred_media.setItemData(0, translate('contact_editor', 'audio'))
        self.preferred_media.setItemData(1, translate('contact_editor', 'video'))
        self.preferred_media.setItemData(2, translate('contact_editor', 'chat'))
        self.preferred_media.setItemData(3, translate('contact_editor', 'audio+chat'))
        self.preferred_media.setItemData(4, translate('contact_editor', 'messages'))
        self.addresses_table.verticalHeader().setDefaultSectionSize(URITypeComboBox().sizeHint().height())

    def open_for_add(self, sip_address='', target_group=None):
        self.edited_contact = None
        self.target_group = target_group
        self.contact_uri_model.init_with_address(sip_address)
        self.name_editor.setText('')
        self.icon_selector.init_with_contact(None)
        self.presence.setChecked(True)
        self.preferred_media.setCurrentIndex(0)
        self.accept_button.setText(translate('contact_editor', 'Add'))
        self.accept_button.setEnabled(False)
        self.show()

    def open_for_edit(self, contact):
        notification_center = NotificationCenter()
        notification_center.add_observer(self, sender=contact)
        self.edited_contact = contact
        self.contact_uri_model.init_with_contact(contact)
        self.name_editor.setText(contact.name)
        self.icon_selector.init_with_contact(contact)
        self.presence.setChecked(contact.presence.subscribe)
        self.auto_answer.setChecked(contact.auto_answer)
        self.preferred_media.setCurrentIndex(self.preferred_media.findData(contact.preferred_media))
        self.accept_button.setText(translate('contact_editor', 'Ok'))
        self.accept_button.setEnabled(True)
        self.show()

    def _SH_NameEditorTextChanged(self, text):
        self.accept_button.setEnabled(text != '')

    def _SH_Accepted(self):
        if self.edited_contact is not None:
            notification_center = NotificationCenter()
            notification_center.remove_observer(self, sender=self.edited_contact)

        contact_model = self.parent().contact_model
        icon_manager = IconManager()

        if self.edited_contact is None:
            contact = addressbook.Contact()
        else:
            contact = self.edited_contact

        for id in set(contact.uris.ids()).difference(item.id for item in self.contact_uri_model.items):
            contact.uris.remove(contact.uris[id])
        for item in (item for item in self.contact_uri_model.items if item.uri):
            try:
                contact_uri = contact.uris[item.id]
            except KeyError:
                contact_uri = addressbook.ContactURI()
                contact.uris.add(contact_uri)
            contact_uri.uri = item.uri
            contact_uri.type = item.type
            if item.default:
                contact.uris.default = contact_uri

        contact.name = self.name_editor.text()
        contact.preferred_media = self.preferred_media.itemData(self.preferred_media.currentIndex())
        if self.presence.isChecked():
            contact.presence.policy = 'allow'
            contact.presence.subscribe = True
        else:
            contact.presence.policy = 'block'
            contact.presence.subscribe = False

        if self.auto_answer.isChecked():
            contact.auto_answer = True
        else:
            contact.auto_answer = False

        if self.icon_selector.filename is self.icon_selector.NotSelected:
            pass
        elif self.icon_selector.filename is None:
            icon_manager.remove(contact.id + '_alt')
            contact.alternate_icon = None
        else:
            icon_descriptor = IconDescriptor(FileURL(self.icon_selector.filename), str(int(os.stat(self.icon_selector.filename).st_mtime)))
            if contact.alternate_icon != icon_descriptor:
                icon_manager.store_file(contact.id + '_alt', icon_descriptor.url.path)
                contact.alternate_icon = icon_descriptor

        modified_settings = [contact]
        if self.target_group is not None:
            self.target_group.settings.contacts.add(contact)
            modified_settings.append(self.target_group.settings)
        contact_model._atomic_update(save=modified_settings)

        self.contact_uri_model.reset()
        self.edited_contact = None
        self.target_group = None

    def _SH_Rejected(self):
        if self.edited_contact is not None:
            notification_center = NotificationCenter()
            notification_center.remove_observer(self, sender=self.edited_contact)
        self.contact_uri_model.reset()

    @run_in_gui_thread
    def handle_notification(self, notification):
        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    def _NH_AddressbookContactDidChange(self, notification):
        contact = notification.sender
        modified_attributes = set(notification.data.modified)
        if 'name' in modified_attributes:
            self.name_editor.setText(contact.name)
        if 'presence.subscribe' in modified_attributes:
            self.presence.setChecked(contact.presence.subscribe)
        if 'preferred_media' in modified_attributes:
            self.preferred_media.setCurrentIndex(self.preferred_media.findData(contact.preferred_media))
        if modified_attributes.intersection(('uris', 'uris.default')):
            self.contact_uri_model.update_from_contact(contact)
        if 'icon' in modified_attributes:
            self.icon_selector.update_from_contact(contact)


del ui_class, base_class


class URIUtils(object):
    number_trim_re = re.compile(r'\(\s?0\s?\)|[-()\s]')
    number_re = re.compile(r'^\s*\+?[-\d\s()]+$')

    @classmethod
    def is_number(cls, token):
        return cls.number_re.match(token) is not None

    @classmethod
    def trim_number(cls, token):
        return cls.number_trim_re.sub('', token)

    @classmethod
    def find_contact(cls, uri, display_name=None, exact=True):
        contact_model = QApplication.instance().main_window.contact_model
        if isinstance(uri, BaseSIPURI):
            uri = SIPURI.new(uri)
        else:
            if '@' not in uri:
                uri += '@' + AccountManager().default_account.id.domain
            if not uri.startswith(('sip:', 'sips:')):
                uri = 'sip:' + uri
            uri = SIPURI.parse(str(uri).translate(translation_table))

        if cls.is_number(uri.user.decode()):
            uri.user = cls.trim_number(uri.user.decode()).encode()
            is_number = True
        else:
            is_number = False

        # Exact URI matches
        for contact in (contact for contact in contact_model.iter_contacts() if contact.group.virtual):
            for contact_uri in contact.uris:
                if uri.matches(contact_uri.uri):
                    return contact, contact_uri

        if not exact and is_number:
            number = uri.user.decode().lstrip('0')
            counter = count()
            matched_numbers = []
            for contact in (contact for contact in contact_model.iter_contacts() if contact.group.virtual):
                for contact_uri in contact.uris:
                    uri_str = contact_uri.uri
                    if uri_str.startswith(('sip:', 'sips:')):
                        uri_str = uri_str.partition(':')[2]
                    contact_user = uri_str.partition('@')[0]
                    if cls.is_number(contact_user):
                        contact_user = cls.trim_number(contact_user)  # these could be expensive, maybe cache -Dan
                        if contact_user.endswith(number):
                            ratio = len(number) * 100 / len(contact_user)
                            if ratio >= 50:
                                heappush(matched_numbers, (100 - ratio, next(counter), contact, contact_uri))
            if matched_numbers:
                return matched_numbers[0][2:]  # ratio, index, contact, uri

        display_name = display_name or "%s@%s" % (uri.user.decode(), uri.host.decode())
        contact = Contact(DummyContact(display_name, [DummyContactURI(str(uri).partition(':')[2], default=True)]), None)
        return contact, contact.uri


