# Copyright (C) 2010-2013 AG Projects. See LICENSE for details.
#

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

import cPickle as pickle
import os
import re
import socket
import sys

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

from application import log
from application.notification import IObserver, NotificationCenter, NotificationData, ObserverWeakrefProxy
from application.python.descriptor import WriteOnceAttribute
from application.python.types import MarkerType, Singleton
from application.python import Null
from application.system import unlink
from collections import OrderedDict, deque
from datetime import datetime
from eventlib import coros, proc
from eventlib.green import httplib, urllib2
from functools import partial
from heapq import heappush
from itertools import count
from operator import attrgetter
from twisted.internet import reactor
from twisted.internet.error import ConnectionLost
from zope.interface import implements

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, run_in_twisted_thread
from sipsimple.threading.green import Command, call_in_green_thread, run_in_green_thread

from blink.configuration.datatypes import AuthorizationToken, InvalidToken, IconDescriptor, FileURL
from blink.resources import ApplicationData, Resources, IconManager
from blink.sessions import SessionManager, StreamDescription
from blink.util import QSingleton, run_in_gui_thread
from blink.widgets.buttons import SwitchViewButton
from blink.widgets.color import ColorHelperMixin
from blink.widgets.labels import Status
from blink.widgets.util import ContextMenuActions

from blink.google.gdata.client import CaptchaChallenge, RequestError, Unauthorized
from blink.google.gdata.contacts.client import ContactsClient
from blink.google.gdata.contacts.data import ContactsFeed
from blink.google.gdata.contacts.service import ContactsQuery
from blink.google.gdata.gauth import ClientLoginToken


class VirtualGroupManager(object):
    __metaclass__ = Singleton
    implements(IObserver)

    __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 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__, basestring)):
            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=unicode, 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, e:
            log.err()
            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


class AllContactsGroup(VirtualGroup):
    implements(IObserver)

    __id__ = 'all_contacts'

    name = Setting(type=unicode, 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)
        for contact in self.contacts:
            notification_center.post_notification('VirtualGroupDidAddContact', sender=self, data=NotificationData(contact=contact))

    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 BonjourNeighbourID(str):
    pass


class BonjourURI(unicode):
    def __new__(cls, value):
        instance = unicode.__new__(cls, unicode(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(self._uri_map.values())
    def __len__(self):
        return len(self._uri_map)
    __hash__ = None
    def get(self, key, default=None):
        return self._item_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, basestring) else id
        self.name = name
        self.hostname = hostname
        self.uris = BonjourNeighbourURIList(uris)
        self.presence = presence or BonjourPresence()
        self.preferred_media = '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(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)


class BonjourNeighboursManager(object):
    __metaclass__ = Singleton
    implements(IObserver)

    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))


class BonjourNeighboursGroup(VirtualGroup):
    implements(IObserver)

    __id__ = 'bonjour_neighbours'

    name = Setting(type=unicode, 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)

    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(unicode):
    pass


class GoogleContactIcon(object):
    def __init__(self, data, etag):
        self.data = data
        self.etag = etag


class GoogleContactURI(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)

    @classmethod
    def from_number(cls, number):
        return cls(re.sub('[\s()-]', '', number.text), cls._get_label(number), number.primary=='true')

    @classmethod
    def from_email(cls, email):
        return cls(re.sub('^sips?:', '', email.address), cls._get_label(email), False) # for now do not let email addresses become default URIs -Dan

    @staticmethod
    def _get_label(entry):
        if entry.label:
            return entry.label.strip()
        else:
            return entry.rel.rpartition('#')[2].replace('_', ' ').strip().title()


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(self._uri_map.values())
    def __len__(self):
        return len(self._uri_map)
    __hash__ = None
    def get(self, key, default=None):
        return self._item_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 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, company, icon, uris):
        self.id = GoogleContactID(id)
        self.name = name
        self.company = company
        self.icon = icon
        self.uris = GoogleContactURIList(uris)
        self.presence = GooglePresence()
        self.preferred_media = 'audio'

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


class GoogleContactsList(object):
    def __init__(self):
        self._contact_map = {}
        self.timestamp = None
    def __getitem__(self, id):
        return self._contact_map[id]
    def __contains__(self, id):
        return id in self._contact_map
    def __iter__(self):
        return iter(self._contact_map.values())
    def __len__(self):
        return len(self._contact_map)
    __hash__ = None
    def __setstate__(self, state):
        self.__dict__.update(state)
        self._contact_map # accessing this will fail if the pickle was created by an older version of the code, thus invalidating the unpickling
    def add(self, contact):
        self._contact_map[contact.id] = contact
    def pop(self, id, *args):
        return self._contact_map.pop(id, *args)


class GoogleContactsManager(object):
    __metaclass__ = Singleton
    implements(IObserver)

    contacts = WriteOnceAttribute()

    def __init__(self):
        self.client = ContactsClient()
        self.command_proc = None
        self.command_channel = coros.queue()
        self.last_fetch_time = datetime.fromtimestamp(0)
        self.not_executed_fetch = None
        self.active = False
        self.state = 'stopped'
        self.timer = None
        self.need_sync = True
        try:
            self.contacts = pickle.load(open(ApplicationData.get('google_contacts')))
        except Exception:
            self.contacts = GoogleContactsList()
        self._initialize()

    def _get_state(self):
        return self.__dict__['state']

    def _set_state(self, value):
        old_value = self.__dict__.get('state', Null)
        self.__dict__['state'] = value
        if old_value != value and old_value is not Null:
            notification_center = NotificationCenter()
            notification_center.post_notification('GoogleContactsManagerDidChangeState', sender=self, data=NotificationData(prev_state=old_value, state=value))

    state = property(_get_state, _set_state)
    del _get_state, _set_state

    @run_in_green_thread
    def _initialize(self):
        self.command_proc = proc.spawn(self._run)

    def _run(self):
        while True:
            command = self.command_channel.wait()
            try:
                handler = getattr(self, '_CH_%s' % command.name)
                handler(command)
            except:
                self.command_proc = None
                raise

    def start(self):
        """
        Starts the Google contacts manager. This method needs to be called in
        a green thread.
        """
        command = Command('start')
        self.command_channel.send(command)
        command.wait()

    def stop(self):
        """
        Stops the Google contacts manager. This method blocks until all the
        operations are stopped and needs to be called in a green thread.
        """
        command = Command('stop')
        self.command_channel.send(command)
        command.wait()

    # Command handlers
    #

    def _CH_start(self, command):
        if self.state != 'stopped':
            command.signal()
            return
        self.state = 'initializing'
        settings = SIPSimpleSettings()
        notification_center = NotificationCenter()
        notification_center.post_notification('GoogleContactsManagerWillStart', sender=self)
        notification_center.add_observer(self, sender=settings, name='CFGSettingsObjectDidChange')
        if settings.google_contacts.authorization_token is not None:
            self.active = True
            notification_center.post_notification('GoogleContactsManagerDidActivate', sender=self)
            for contact in self.contacts:
                notification_center.post_notification('GoogleContactsManagerDidAddContact', sender=self, data=NotificationData(contact=contact))
            self.command_channel.send(Command('initialize'))
        notification_center.post_notification('GoogleContactsManagerDidStart', sender=self)
        command.signal()

    def _CH_stop(self, command):
        if self.state == 'stopped':
            command.signal()
            return
        notification_center = NotificationCenter()
        notification_center.post_notification('GoogleContactsManagerWillEnd', sender=self)
        notification_center.remove_observer(self, sender=SIPSimpleSettings(), name='CFGSettingsObjectDidChange')
        if self.active:
            self.active = False
            notification_center.post_notification('GoogleContactsManagerDidDeactivate', sender=self)
        if self.timer is not None and self.timer.active():
            self.timer.cancel()
        self.timer = None
        self.client = None
        self.state = 'stopped'
        self._save_contacts()
        notification_center.post_notification('GoogleContactsManagerDidEnd', sender=self)
        command.signal()

    def _CH_initialize(self, command):
        self.state = 'initializing'
        if self.timer is not None and self.timer.active():
            self.timer.cancel()
        self.timer = None
        self.state = 'fetching'
        self.command_channel.send(Command('fetch'))

    def _CH_fetch(self, command):
        if self.state not in ('insync', 'fetching'):
            self.not_executed_fetch = command
            return
        self.not_executed_fetch = None
        self.state = 'fetching'
        if self.timer is not None and self.timer.active():
            self.timer.cancel()
        self.timer = None

        settings = SIPSimpleSettings()
        self.client.auth_token = ClientLoginToken(settings.google_contacts.authorization_token)

        try:
            group_id = next(entry.id.text for entry in self.client.get_groups().entry if entry.title.text=='System Group: My Contacts')
            if self.need_sync:
                query = ContactsQuery(feed=self.client.get_feed_uri(kind='contacts'), group=group_id, params={})
                feed = self.client.get_feed(query.ToUri(), desired_class=ContactsFeed)
                all_contact_ids = set()
                while feed:
                    all_contact_ids.update(entry.id.text for entry in feed.entry)
                    feed = self.client.get_next(feed) if feed.find_next_link() is not None else None
                deleted_contacts = [contact for contact in self.contacts if contact.id not in all_contact_ids]
                self.need_sync = False
            else:
                deleted_contacts = []
            query = ContactsQuery(feed=self.client.get_feed_uri(kind='contacts'), group=group_id, params={'showdeleted': 'true'})
            if self.contacts.timestamp is not None:
                query.updated_min = self.contacts.timestamp
            feed = self.client.get_feed(query.ToUri(), desired_class=ContactsFeed)
            update_timestamp = feed.updated.text if feed else None

            added_contacts = []
            updated_contacts = []

            while feed:
                deleted_contacts.extend(self.contacts[entry.id.text] for entry in feed.entry if entry.deleted and entry.id.text in self.contacts)
                for entry in (entry for entry in feed.entry if not entry.deleted):
                    name = entry.title.text
                    try:
                        company = entry.organization.name.text
                    except AttributeError:
                        company = None
                    uris = [GoogleContactURI.from_number(number) for number in entry.phone_number]
                    uris.extend(GoogleContactURI.from_email(email) for email in entry.email)
                    icon_url, icon_etag = entry.get_entry_photo_data()
                    try:
                        contact = self.contacts[entry.id.text]
                    except KeyError:
                        if icon_url:
                            try:
                                icon_data = self.client.Get(icon_url).read()
                            except Exception:
                                icon_data = icon_etag = None
                        else:
                            icon_data = icon_etag = None
                        icon = GoogleContactIcon(icon_data, icon_etag)
                        contact = GoogleContact(entry.id.text, name, company, icon, uris)
                        added_contacts.append(contact)
                    else:
                        contact.name = name
                        contact.company = company
                        contact.uris = uris
                        if icon_url and contact.icon.etag != icon_etag != None:
                            try:
                                contact.icon.data = self.client.Get(icon_url).read()
                            except Exception:
                                contact.icon.data = None
                                contact.icon.etag = None
                            else:
                                contact.icon.etag = icon_etag
                        updated_contacts.append(contact)
                feed = self.client.get_next(feed) if feed.find_next_link() is not None else None
        except Unauthorized:
            settings.google_contacts.authorization_token = InvalidToken
            settings.save()
        except (ConnectionLost, RequestError, httplib.HTTPException, socket.error):
            self.timer = self._schedule_command(60, Command('fetch', command.event))
        else:
            notification_center = NotificationCenter()
            for contact in deleted_contacts:
                self.contacts.pop(contact.id, None)
                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 updated_contacts:
                notification_center.post_notification('GoogleContactsManagerDidUpdateContact', sender=self, data=NotificationData(contact=contact))
            if update_timestamp is not None:
                self.contacts.timestamp = update_timestamp
            if added_contacts or updated_contacts or deleted_contacts:
                self._save_contacts()
            self.last_fetch_time = datetime.utcnow()
            self.state = 'insync'
            self.timer = self._schedule_command(60, Command('fetch'))
            command.signal()

    # Notification handlers
    #

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

    def _NH_CFGSettingsObjectDidChange(self, notification):
        if 'google_contacts.authorization_token' in notification.data.modified:
            if self.timer is not None and self.timer.active():
                self.timer.cancel()
            self.timer = None
            authorization_token = notification.sender.google_contacts.authorization_token
            if authorization_token is not None:
                if not self.active:
                    self.active = True
                    notification.center.post_notification('GoogleContactsManagerDidActivate', sender=self)
                    for contact in self.contacts:
                        notification.center.post_notification('GoogleContactsManagerDidAddContact', sender=self, data=NotificationData(contact=contact))
                self.need_sync = True
                self.command_channel.send(Command('initialize'))
            else:
                if self.active:
                    self.active = False
                    for contact in self.contacts:
                        notification.center.post_notification('GoogleContactsManagerDidRemoveContact', sender=self, data=NotificationData(contact=contact))
                    notification.center.post_notification('GoogleContactsManagerDidDeactivate', sender=self)

    def _save_contacts(self):
        contacts_filename = ApplicationData.get('google_contacts')
        contacts_tempname = contacts_filename + '.tmp'
        try:
            file = open(contacts_tempname, 'wb')
            pickle.dump(self.contacts, file)
            file.close()
            if sys.platform == 'win32':
                unlink(contacts_filename)
            os.rename(contacts_tempname, contacts_filename)
        except Exception, e:
            log.error("could not save google contacts: %s" % e)

    def _schedule_command(self, timeout, command):
        timer = reactor.callLater(timeout, self.command_channel.send, command)
        timer.command = command
        return timer


class GoogleContactsGroup(VirtualGroup):
    implements(IObserver)

    __id__ = 'google_contacts'

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

    def __init__(self):
        self.__manager__ = GoogleContactsManager()
        notification_center = NotificationCenter()
        notification_center.add_observer(self, name='SIPApplicationWillEnd')
        notification_center.add_observer(self, sender=self.__manager__)
        call_in_green_thread(self.__manager__.start)

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

    def _NH_SIPApplicationWillEnd(self, notification):
        call_in_green_thread(self.__manager__.stop)

    def _NH_GoogleContactsManagerDidActivate(self, notification):
        notification.center.post_notification('VirtualGroupWasActivated', sender=self)

    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=u'', 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(self._uri_map.values())
    def __len__(self):
        return len(self._uri_map)
    __hash__ = None
    def get(self, key, default=None):
        return self._item_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 = 'audio'

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


class Group(object):
    implements(IObserver)

    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.reference_group = 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, reference_group=self.reference_group))

    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, obj, objtype):
        if self.icon is None:
            self.icon = QIcon(self.filename)
            self.icon.filename = self.filename
        return self.icon
    def __set__(self, obj, value):
        raise AttributeError("attribute cannot be set")
    def __delete__(self, obj):
        raise AttributeError("attribute cannot be deleted")


class Contact(object):
    implements(IObserver)

    size_hint = QSize(200, 36)

    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 self.name > other.name
        return NotImplemented

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

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

    def __le__(self, other):
        if isinstance(other, Contact):
            return self.name <= other.name
        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 u''

    @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.company or u''
        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 u''

    @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 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':
                pixmap = QPixmap()
                if pixmap.loadFromData(self.settings.icon.data):
                    icon = QIcon(pixmap)
                    icon.filename = None  # TODO: cache icons to disk -Saul
                else:
                    icon = 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 set(['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)


class ContactDetail(object):
    implements(IObserver)

    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 u''

    @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.company
        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 u''

    @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 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':
                pixmap = QPixmap()
                if pixmap.loadFromData(self.settings.icon.data):
                    icon = QIcon(pixmap)
                else:
                    icon = 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 set(['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)


class ContactURI(object):
    implements(IObserver)

    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 u'%s (%s)' % (self.uri.uri, self.uri.type) if self.uri.type else unicode(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)


class CaptchaRequired:    __metaclass__ = MarkerType
class AuthorizationError: __metaclass__ = MarkerType
class ConnectionError:    __metaclass__ = MarkerType
class AuthorizationOk:    __metaclass__ = MarkerType


class GoogleAuthorizationData(object):
    def __init__(self, **kwargs):
        self.__dict__.update(kwargs)
    def __repr__(self):
        return '%s(%s)' % (self.__class__.__name__, ', '.join('%s=%r' % (name, value) for name, value in self.__dict__.iteritems()))


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

class GoogleContactsDialog(base_class, ui_class):
    __metaclass__ = QSingleton

    def __init__(self, parent=None):
        super(GoogleContactsDialog, self).__init__(parent)
        with Resources.directory:
            self.setupUi(self)

        self.authorize_button.clicked.connect(self._SH_AuthorizeButtonClicked)
        self.captcha_editor.statusChanged.connect(self._SH_ValidityStatusChanged)
        self.username_editor.statusChanged.connect(self._SH_ValidityStatusChanged)
        self.password_editor.statusChanged.connect(self._SH_ValidityStatusChanged)
        self.rejected.connect(self._SH_DialogRejected)

        self.captcha_editor.regexp = re.compile('^.+$')
        self.username_editor.regexp = re.compile('^.+$')
        self.password_editor.regexp = re.compile('^.+$')

        self.captcha_token = None

    def show_captcha(self, image_data):
        pixmap = QPixmap()
        if pixmap.loadFromData(image_data):
            pixmap = pixmap.scaled(200, 70, Qt.KeepAspectRatio, Qt.SmoothTransformation)
        self.captcha_label.setVisible(True)
        self.captcha_editor.setVisible(True)
        self.captcha_image_label.setVisible(True)
        self.captcha_image_label.setPixmap(pixmap)
        self.captcha_editor.setText(u'')
        self.captcha_editor.setFocus()

    def hide_captcha(self):
        self.captcha_label.setVisible(False)
        self.captcha_editor.setVisible(False)
        self.captcha_image_label.setVisible(False)

    def open(self):
        settings = SIPSimpleSettings()
        username = settings.google_contacts.username or u''
        self.username_editor.setEnabled(True)
        self.username_editor.setText(username)
        self.password_editor.setText(u'')
        self.hide_captcha()
        if username:
            self.password_editor.setFocus()
        else:
            self.username_editor.setFocus()
        self.show()

    def open_for_incorrect_password(self):
        red = '#cc0000'
        settings = SIPSimpleSettings()
        self.username_editor.setEnabled(False)
        self.username_editor.setText(settings.google_contacts.username)
        self.status_label.value = Status('Error authenticating with Google. Please enter your password:', color=red)
        self.hide_captcha()
        self.password_editor.setFocus()
        self.show()

    @run_in_green_thread
    def _authorize_google_account(self):
        captcha_response = self.captcha_editor.text() if self.captcha_token else None
        username = self.username_editor.text()
        password = self.password_editor.text()
        client = ContactsClient()
        try:
            client.client_login(email=username, password=password, source=QApplication.applicationName(), captcha_token=self.captcha_token, captcha_response=captcha_response)
        except CaptchaChallenge, e:
            try:
                captcha_data = urllib2.urlopen(e.captcha_url).read()
            except (urllib2.HTTPError, urllib2.URLError):
                self.captcha_token = None
                self._process_authorization_reply(ConnectionError)
            else:
                self.captcha_token = e.captcha_token
                self._process_authorization_reply(CaptchaRequired, data=GoogleAuthorizationData(captcha_image=captcha_data))
        except RequestError:
            self.captcha_token = None
            self._process_authorization_reply(AuthorizationError)
        except Exception:
            self.captcha_token = None
            self._process_authorization_reply(ConnectionError)
        else:
            self.captcha_token = None
            settings = SIPSimpleSettings()
            settings.google_contacts.authorization_token = AuthorizationToken(client.auth_token.token_string)
            settings.google_contacts.username = username
            settings.save()
            self._process_authorization_reply(AuthorizationOk)

    @run_in_gui_thread
    def _process_authorization_reply(self, reply, data=GoogleAuthorizationData()):
        red = '#cc0000'
        self.setEnabled(True)
        if reply is CaptchaRequired:
            self.username_editor.setEnabled(False)
            self.show_captcha(data.captcha_image)
            self.status_label.value = Status('Answer captcha to confirm authentication', color=red)
        elif reply is AuthorizationError:
            self.username_editor.setEnabled(True)
            self.hide_captcha()
            self.status_label.value = Status('Error authenticating with Google', color=red)
            self.password_editor.setFocus()
        elif reply is ConnectionError:
            self.username_editor.setEnabled(True)
            self.hide_captcha()
            self.status_label.value = Status('Error connecting with Google', color=red)
            self.password_editor.setFocus()
        elif reply is AuthorizationOk:
            self.accept()
        else:
            raise ValueError("Unknown reply: %r" % reply)

    def _SH_AuthorizeButtonClicked(self):
        self.status_label.value = Status('Contacting Google server...')
        self.setEnabled(False)
        self._authorize_google_account()

    @run_in_twisted_thread
    def _SH_DialogRejected(self):
        settings = SIPSimpleSettings()
        settings.google_contacts.authorization_token = None
        settings.save()
        self.captcha_token = None

    def _SH_ValidityStatusChanged(self):
        red = '#cc0000'
        if not self.username_editor.text_valid:
            self.status_label.value = Status('Please specify your Google account username', color=red)
        elif not self.password_editor.text_valid:
            self.status_label.value = Status('Please specify your Google account password', color=red)
        elif self.captcha_editor.isVisible() and not self.captcha_editor.text_valid:
            self.status_label.value = Status('Please insert the text in the image below', color=red)
        else:
            self.status_label.value = None
        self.authorize_button.setEnabled(self.username_editor.text_valid and self.password_editor.text_valid and (True if not self.captcha_editor.isVisible() else self.captcha_editor.text_valid))

del ui_class, base_class


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 _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 edit(self):
        self._start_editing()

    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 xrange(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)
            if option.fontMetrics.width(contact_uri.uri.uri) > text_rect.width():
                gradient = QLinearGradient(text_rect.x(), 0, text_rect.right(), 0)
                gradient.setColorAt(1-50.0/text_rect.width(), 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 = rect.center().x() - 3.5
        y = 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.iteritems():
            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.iteritems():
            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
class GroupElement:  __metaclass__ = MarkerType
class GroupContacts: __metaclass__ = MarkerType


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, long, 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(key)
            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))

    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)


class ContactModel(QAbstractListModel):
    implements(IObserver)

    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']

    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 unicode(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 = (group for group in reversed(groups) if group not in moved_groups).next()
            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 = (group for group in reversed(groups[:position]) if group not in moved_groups).next()
                drop_position = self.contact_list.BelowItem
            else:
                drop_group = (group for group in groups[position:] if group not in moved_groups).next()
                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.reference_group = 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'
        test_contacts = [{'id': 'test_audio',      'name': 'Test Call',         'preferred_media': 'audio', 'uri': '3333@sip2sip.info',            'icon': Resources.get('icons/test-call.png')},
                         {'id': 'test_microphone', 'name': 'Test Microphone',   'preferred_media': 'audio', 'uri': '4444@sip2sip.info',            'icon': Resources.get('icons/test-echo.png')},
                         {'id': 'test_conference', 'name': 'Test Conference',   'preferred_media': 'chat',  'uri': 'test@conference.sip2sip.info', 'icon': Resources.get('icons/test-conference.png')},
                         {'id': 'test_zipdx',      'name': 'VUC http://vuc.me', 'preferred_media': 'audio', 'uri': '200901@login.zipdx.com',       'icon': Resources.get('icons/vuc-conference.png')}]
        blink = Blink()
        icon_manager = IconManager()
        if blink.first_run:
            def make_contact(id, name, preferred_media, uri, icon):
                icon_manager.store_file(id, 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), unicode(int(os.stat(icon).st_mtime)))
                return contact
            test_group = addressbook.Group(id='test')
            test_group.name = 'Test'
            test_group.contacts = [make_contact(**entry) for entry in test_contacts]
            modified_settings = list(test_group.contacts) + [test_group]
            self._atomic_update(save=modified_settings)
        else:
            addressbook_manager = addressbook.AddressbookManager()
            for entry in test_contacts:
                try:
                    contact = addressbook_manager.get_contact(entry['id'])
                except KeyError:
                    continue
                icon = entry['icon']
                icon_descriptor = IconDescriptor(FileURL(icon), unicode(int(os.stat(icon).st_mtime)))
                if contact.icon != icon_descriptor:
                    icon_manager.store_file(contact.id, icon)
                    contact.icon = icon_descriptor
                    contact.save()

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

    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):
        self.addGroup(Group(notification.sender))

    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')

    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.reference_group = groups[groups.index(bonjour_group)+1]
            except IndexError:
                bonjour_group.reference_group = None
            if bonjour_group is not groups[0]:
                self.moveGroup(bonjour_group, 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.reference_group = groups[groups.index(bonjour_group)+1]
            except IndexError:
                bonjour_group.reference_group = None
            if bonjour_group is not groups[0]:
                self.moveGroup(bonjour_group, groups[0])
            bonjour_group.expand()
        elif old_account is BonjourAccount() and old_account.enabled:
            bonjour_group = self.bonjour_group
            if bonjour_group.reference_group is not None:
                self.moveGroup(bonjour_group, bonjour_group.reference_group)
                bonjour_group.reference_group = 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 _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.name <= contact.name
        next_ok = next_item is None or isinstance(next_item, Group) or next_item.name >= contact.name
        if prev_ok and next_ok:
            return None
        for position in xrange(self.items.index(contact.group)+1, len(self.items)):
            item = self.items[position]
            if isinstance(item, Group) or item.name > contact.name:
                break
        else:
            position = len(self.items)
        return position

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

    def _find_group_insertion_point(self, group):
        for item in self.items[GroupList]:
            if item.settings.position >= group.settings.position:
                position = self.items.index(item)
                break
        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.reference_group is not None:
            groups.pop(0)
            groups.insert(groups.index(bonjour_group.reference_group), 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, reference):
        groups = self.items[GroupList]
        if group not in groups or groups.index(group)+1 == (groups.index(reference) if reference in groups else len(groups)):
            return
        items = self._pop_group(group)
        position = self.items.index(reference) if reference 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_image(item.settings.id)
                alternate_icon = icon_manager.get_image(item.settings.id + '_alt')
                undo_operations.append(AddContactOperation(contact=RecallState(item.settings), group_ids=group_ids, icon=icon, alternate_icon=alternate_icon))
            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 = u' '.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


class ContactDetailModel(QAbstractListModel):
    implements(IObserver)

    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 unicode(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)


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("Add Group", self, triggered=self._AH_AddGroup)
        self.actions.add_contact = QAction("Add Contact", self, triggered=self._AH_AddContact)
        self.actions.edit_item = QAction("Edit", self, triggered=self._AH_EditItem)
        self.actions.delete_item = QAction("Delete", self, triggered=self._AH_DeleteSelection)
        self.actions.delete_selection = QAction("Delete Selection", self, triggered=self._AH_DeleteSelection)
        self.actions.undo_last_delete = QAction("Undo Last Delete", self, triggered=self._AH_UndoLastDelete)
        self.actions.start_audio_call = QAction("Start Audio Call", self, triggered=self._AH_StartAudioCall)
        self.actions.start_chat_session = QAction("Start Chat Session", self, triggered=self._AH_StartChatSession)
        self.actions.send_sms = QAction("Send SMS", self, triggered=self._AH_SendSMS)
        self.actions.send_files = QAction("Send File(s)...", self, triggered=self._AH_SendFiles)
        self.actions.request_screen = QAction("Request Screen", self, triggered=self._AH_RequestScreen)
        self.actions.share_my_screen = QAction("Share My Screen", self, triggered=self._AH_ShareMyScreen)
        self.drop_indicator_index = QModelIndex()
        self.needs_restore = False
        self.doubleClicked.connect(self._SH_DoubleClicked) # activated is emitted on single click

    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 = "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 = 'Contact'
                else:
                    name = contact.name or 'Contact'
            undo_delete_text = 'Undo Delete "%s"' % name
        else:
            undo_delete_text = "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]
            menu.addAction(self.actions.start_audio_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.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)
            account_manager = AccountManager()
            default_account = account_manager.default_account
            self.actions.start_audio_call.setEnabled(default_account is not None)
            self.actions.start_chat_session.setEnabled(default_account is not None)
            self.actions.send_sms.setEnabled(default_account is not None)
            self.actions.send_files.setEnabled(default_account is not None)
            self.actions.request_screen.setEnabled(default_account is not None)
            self.actions.share_my_screen.setEnabled(default_account is not None)
            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, [StreamDescription(media) for media in item.preferred_media.split('+')], connect=('audio' in item.preferred_media))
        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()
        for mime_type in model.accepted_mime_types:
            if event.provides(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):
        contact = self.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
        session_manager = SessionManager()
        session_manager.create_session(contact, contact.uri, [StreamDescription('audio')])

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

    def _AH_SendSMS(self):
        pass

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

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

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

    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, [StreamDescription(media) for media in item.preferred_media.split('+')], connect=('audio' in item.preferred_media))


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("Edit", self, triggered=self._AH_EditItem)
        self.actions.delete_item = QAction("Delete", self, triggered=self._AH_DeleteSelection)
        self.actions.delete_selection = QAction("Delete Selection", self, triggered=self._AH_DeleteSelection)
        self.actions.undo_last_delete = QAction("Undo Last Delete", self, triggered=self._AH_UndoLastDelete)
        self.actions.start_audio_call = QAction("Start Audio Call", self, triggered=self._AH_StartAudioCall)
        self.actions.start_chat_session = QAction("Start Chat Session", self, triggered=self._AH_StartChatSession)
        self.actions.send_sms = QAction("Send SMS", self, triggered=self._AH_SendSMS)
        self.actions.send_files = QAction("Send File(s)...", self, triggered=self._AH_SendFiles)
        self.actions.request_screen = QAction("Request Screen", self, triggered=self._AH_RequestScreen)
        self.actions.share_my_screen = QAction("Share My Screen", self, triggered=self._AH_ShareMyScreen)
        self.drop_indicator_index = QModelIndex()
        self.doubleClicked.connect(self._SH_DoubleClicked) # activated is emitted on single click

    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 = 'Contact'
                else:
                    name = contact.name or 'Contact'
            undo_delete_text = 'Undo Delete "%s"' % name
        else:
            undo_delete_text = "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_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.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()
            default_account = account_manager.default_account
            self.actions.start_audio_call.setEnabled(default_account is not None)
            self.actions.start_chat_session.setEnabled(default_account is not None)
            self.actions.send_sms.setEnabled(default_account is not None)
            self.actions.send_files.setEnabled(default_account is not None)
            self.actions.request_screen.setEnabled(default_account is not None)
            self.actions.share_my_screen.setEnabled(default_account is not None)
            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, [StreamDescription(media) for media in item.preferred_media.split('+')], connect=('audio' in item.preferred_media))
        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)

        for mime_type in self.model().accepted_mime_types:
            if event.provides(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):
        contact = self.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
        session_manager = SessionManager()
        session_manager.create_session(contact, contact.uri, [StreamDescription('audio')])

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

    def _AH_SendSMS(self):
        pass

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

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

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

    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, [StreamDescription(media) for media in item.preferred_media.split('+')], connect=('audio' in item.preferred_media))


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, '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("Delete Contact", self, triggered=self._AH_DeleteContact)
        self.actions.edit_contact = QAction("Edit Contact", self, triggered=self._AH_EditContact)
        self.actions.make_uri_default = QAction("Set Address As Default", self, triggered=self._AH_MakeURIDefault)
        self.actions.start_audio_call = QAction("Start Audio Call", self, triggered=self._AH_StartAudioCall)
        self.actions.start_chat_session = QAction("Start Chat Session", self, triggered=self._AH_StartChatSession)
        self.actions.send_sms = QAction("Send SMS", self, triggered=self._AH_SendSMS)
        self.actions.send_files = QAction("Send File(s)...", self, triggered=self._AH_SendFiles)
        self.actions.request_screen = QAction("Request Screen", self, triggered=self._AH_RequestScreen)
        self.actions.share_my_screen = QAction("Share My Screen", self, triggered=self._AH_ShareMyScreen)
        self.drop_indicator_index = QModelIndex()
        self.doubleClicked.connect(self._SH_DoubleClicked) # activated is emitted on single click
        contact_list.installEventFilter(self)

    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()
        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_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.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)
        self.actions.start_audio_call.setEnabled(account_manager.default_account is not None and contact_has_uris)
        self.actions.start_chat_session.setEnabled(account_manager.default_account is not None and contact_has_uris)
        self.actions.send_sms.setEnabled(account_manager.default_account is not None and contact_has_uris)
        self.actions.send_files.setEnabled(account_manager.default_account is not None and contact_has_uris)
        self.actions.request_screen.setEnabled(account_manager.default_account is not None and contact_has_uris)
        self.actions.share_my_screen.setEnabled(account_manager.default_account is not None and contact_has_uris)
        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):
            self._AH_StartAudioCall()
        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()
        for mime_type in model.accepted_mime_types:
            if event.provides(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):
        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, [StreamDescription('audio')])

    def _AH_StartChatSession(self):
        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, [StreamDescription('chat')], connect=False)

    def _AH_SendSMS(self):
        pass

    def _AH_SendFiles(self):
        session_manager = SessionManager()
        files = QFileDialog.getOpenFileNames(self, u'Select File(s)', session_manager.send_file_directory, u'Any file (*.*)')
        if files:
            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
            for filename in files:
                session_manager.send_file(contact, selected_uri, filename)

    def _AH_RequestScreen(self):
        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, [StreamDescription('screen-sharing', mode='viewer'), StreamDescription('audio')])

    def _AH_ShareMyScreen(self):
        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, [StreamDescription('screen-sharing', mode='server'), StreamDescription('audio')])

    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, [StreamDescription(media) for media in contact.preferred_media.split('+')], connect=('audio' in contact.preferred_media))


# 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, "Mobile", "Home", "Work", "SIP", "XMPP", "Other")

    def __init__(self, parent=None, types=[]):
        super(URITypeComboBox, self).__init__(parent)
        self.setEditable(True)
        self.addItems(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()
            gradient = QLinearGradient(0, 0, width, 0)
            gradient.setColorAt(1-50.0/width, 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 = ('Address', 'Type', '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 'Edit to add address' if item.ghost else unicode(item.uri or u'')
        elif role == Qt.EditRole:
            if column == ContactURIModel.AddressColumn:
                return unicode(item.uri or u'')
            elif column == ContactURIModel.TypeColumn:
                return item.type or u''
            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 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().setResizeMode(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().setResizeMode(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().setResizeMode(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(QAction("Delete", self, triggered=self._AH_DeleteSelection))
        self.horizontalHeader().setResizeMode(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'))

class ContactEditorDialog(base_class, ui_class):
    implements(IObserver)

    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, 'audio')
        self.preferred_media.setItemData(1, 'chat')
        self.preferred_media.setItemData(2, 'audio+chat')
        self.addresses_table.verticalHeader().setDefaultSectionSize(URITypeComboBox().sizeHint().height())

    def open_for_add(self, sip_address=u'', 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(u'')
        self.icon_selector.init_with_contact(None)
        self.presence.setChecked(True)
        self.preferred_media.setCurrentIndex(0)
        self.accept_button.setText(u'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.preferred_media.setCurrentIndex(self.preferred_media.findData(contact.preferred_media))
        self.accept_button.setText(u'Ok')
        self.accept_button.setEnabled(True)
        self.show()

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

    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.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), unicode(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(None, ' \t'))
        if cls.is_number(uri.user):
            uri.user = cls.trim_number(uri.user)
            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.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, uri.host)
        contact = Contact(DummyContact(display_name, [DummyContactURI(str(uri).partition(':')[2], default=True)]), None)
        return contact, contact.uri