"""
Implements a selection of the folders and folder items found in an Exchange account.

Exchange is very picky about things like the order of XML elements in SOAP requests, so we need to generate XML
automatically instead of taking advantage of Python SOAP libraries and the WSDL file.
"""

from logging import getLogger
from decimal import Decimal
import functools

from .credentials import DELEGATE
from .ewsdatetime import EWSDateTime, UTC_NOW
from .restriction import Restriction, Q
from .services import TNS, FindItem, IdOnly, SHALLOW, DEEP, DeleteItem, CreateItem, UpdateItem, FindFolder, GetFolder, \
    GetItem, MNS
from .util import create_element, add_xml_child, get_xml_attrs, get_xml_attr, set_xml_value, ElementType, peek
from .version import EXCHANGE_2010, EXCHANGE_2013

log = getLogger(__name__)


class Choice(str):
    # A helper class used for string enums
    pass


class Email(str):
    # A helper class used for email address string
    pass


class EWSElement:
    ELEMENT_NAME = None

    __slots__ = tuple()

    def to_xml(self, version):
        raise NotImplementedError()

    @classmethod
    def from_xml(cls, elem):
        raise NotImplementedError()

    @classmethod
    def request_tag(cls):
        return 't:%s' % cls.ELEMENT_NAME

    @classmethod
    def response_tag(cls):
        return '{%s}%s' % (TNS, cls.ELEMENT_NAME)


class ItemId(EWSElement):
    # 'id' and 'changekey' are UUIDs generated by Exchange
    ELEMENT_NAME = 'ItemId'

    __slots__ = ('id', 'changekey')

    def __init__(self, id, changekey):
        assert isinstance(id, str)
        assert isinstance(changekey, str)
        self.id = id
        self.changekey = changekey

    def to_xml(self, version):
        # Don't use create_element with extra args. It caches results and Id is always unique.
        elem = create_element(self.request_tag())
        elem.set('Id', self.id)
        elem.set('ChangeKey', self.changekey)
        return elem

    @classmethod
    def from_xml(cls, elem):
        if elem is None:
            return None
        assert elem.tag == cls.response_tag()
        return cls(id=elem.get('Id'), changekey=elem.get('ChangeKey'))

    def __eq__(self, other):
        if other is None:
            return False
        return self.id == other.id and self.changekey == other.changekey


class IndexedField(EWSElement):
    PARENT_ELEMENT_NAME = None
    ELEMENT_NAME = None
    LABELS = ()
    FIELD_URI = None

    @classmethod
    def field_uri_xml(cls, label):
        return create_element(
            't:IndexedFieldURI',
            FieldURI=cls.FIELD_URI,
            FieldIndex=label,
        )


class EmailAddress(IndexedField):
    # See https://msdn.microsoft.com/en-us/library/office/aa564757(v=exchg.150).aspx
    PARENT_ELEMENT_NAME = 'EmailAddresses'
    ELEMENT_NAME = 'Entry'
    LABELS = {'EmailAddress1', 'EmailAddress2', 'EmailAddress3'}
    FIELD_URI = 'contacts:EmailAddress'

    __slots__ = ('label', 'email')

    def __init__(self, email, label='EmailAddress1'):
        assert label in self.LABELS, label
        assert isinstance(email, str), email
        self.label = label
        self.email = email

    def to_xml(self, version):
        entry = create_element(self.request_tag(), Key=self.label)
        set_xml_value(entry, self.email, version)
        return entry

    @classmethod
    def from_xml(cls, elem):
        if elem is None:
            return None
        assert elem.tag == cls.response_tag()
        return cls(
            label=elem.get('Key'),
            email=elem.text or elem.get('Name'),  # Sometimes elem.text is empty. Exchange saves the same in 'Name' attr
        )

    def __eq__(self, other):
        return hash(self) == hash(other)

    def __hash__(self):
        return hash((self.label, self.email))

    def __repr__(self):
        return self.__class__.__name__ + repr((self.label, self.email))


class PhoneNumber(IndexedField):
    # See https://msdn.microsoft.com/en-us/library/office/aa565941(v=exchg.150).aspx
    PARENT_ELEMENT_NAME = 'PhoneNumbers'
    ELEMENT_NAME = 'Entry'
    LABELS = {
        'AssistantPhone', 'BusinessFax', 'BusinessPhone', 'BusinessPhone2', 'Callback', 'CarPhone', 'CompanyMainPhone',
        'HomeFax', 'HomePhone', 'HomePhone2', 'Isdn', 'MobilePhone', 'OtherFax', 'OtherTelephone', 'Pager',
        'PrimaryPhone', 'RadioPhone', 'Telex', 'TtyTddPhone',
    }
    FIELD_URI = 'contacts:PhoneNumber'

    __slots__ = ('label', 'phone_number')

    def __init__(self, phone_number, label='PrimaryPhone'):
        assert label in self.LABELS, label
        assert isinstance(phone_number, (int, str)), phone_number
        self.label = label
        self.phone_number = phone_number

    def to_xml(self, version):
        entry = create_element(self.request_tag(), Key=self.label)
        set_xml_value(entry, str(self.phone_number), version)
        return entry

    @classmethod
    def from_xml(cls, elem):
        if elem is None:
            return None
        assert elem.tag == cls.response_tag()
        return cls(
            label=elem.get('Key'),
            phone_number=elem.text,
        )

    def __eq__(self, other):
        return hash(self) == hash(other)

    def __hash__(self):
        return hash((self.label, self.phone_number))

    def __repr__(self):
        return self.__class__.__name__ + repr((self.label, self.phone_number))


class PhysicalAddress(IndexedField):
    PARENT_ELEMENT_NAME = 'PhysicalAddresses'
    ELEMENT_NAME = 'Entry'
    LABELS = {'Business', 'Home', 'Other'}
    FIELD_URI = 'contacts:PhysicalAddress'

    __slots__ = ('label', 'street', 'city', 'state', 'country', 'zipcode')

    def __init__(self, street, city, state, country, zipcode, label='Business'):
        assert label in self.LABELS, label
        assert isinstance(street, str), street
        assert isinstance(city, str), city
        assert isinstance(state, str), state
        assert isinstance(country, str), country
        assert isinstance(zipcode, (str, int)), zipcode
        self.label = label
        self.street = street  # Street *and* house number (and other labels)
        self.city = city
        self.state = state
        self.country = country
        self.zipcode = zipcode

    def to_xml(self, version):
        entry = create_element(self.request_tag(), Key=self.label)
        add_xml_child(entry, 't:Street', self.street)
        add_xml_child(entry, 't:City', self.city)
        add_xml_child(entry, 't:State', self.state)
        add_xml_child(entry, 't:CountryOrRegion', self.country)
        add_xml_child(entry, 't:PostalCode', str(self.zipcode))
        return entry

    @classmethod
    def from_xml(cls, elem):
        if elem is None:
            return None
        assert elem.tag == cls.response_tag()
        return cls(
            label=elem.get('Key'),
            street=get_xml_attr(elem, '{%s}Street' % TNS),
            city=get_xml_attr(elem, '{%s}City' % TNS),
            state=get_xml_attr(elem, '{%s}State' % TNS),
            country=get_xml_attr(elem, '{%s}CountryOrRegion' % TNS),
            zipcode=get_xml_attr(elem, '{%s}PostalCode' % TNS),
        )

    def __eq__(self, other):
        return hash(self) == hash(other)

    def __hash__(self):
        return hash((self.label, self.street, self.city, self.state, self.country, self.zipcode))

    def __repr__(self):
        return self.__class__.__name__ + \
               repr((self.label, self.street, self.city, self.state, self.country, self.zipcode))


class Mailbox(EWSElement):
    ELEMENT_NAME = 'Mailbox'
    MAILBOX_TYPES = {'Mailbox', 'PublicDL', 'PrivateDL', 'Contact', 'PublicFolder', 'Unknown', 'OneOff'}

    __slots__ = ('name', 'email_address', 'mailbox_type', 'item_id')

    def __init__(self, name=None, email_address=None, mailbox_type=None, item_id=None):
        # There's also the 'RoutingType' element, but it's optional and must have value "SMTP"
        if name is not None:
            assert isinstance(name, str)
        if email_address is not None:
            assert isinstance(email_address, str)
        if mailbox_type is not None:
            assert mailbox_type in self.MAILBOX_TYPES
        if item_id is not None:
            assert isinstance(item_id, ItemId)
        self.name = name
        self.email_address = email_address
        self.mailbox_type = mailbox_type
        self.item_id = item_id

    def to_xml(self, version):
        if not self.email_address and not self.item_id:
            # See "Remarks" section of https://msdn.microsoft.com/en-us/library/office/aa565036(v=exchg.150).aspx
            raise AttributeError('Mailbox must have either email_address or item_id')
        mailbox = create_element(self.request_tag())
        if self.name:
            add_xml_child(mailbox, 't:Name', self.name)
        if self.email_address:
            add_xml_child(mailbox, 't:EmailAddress', self.email_address)
        if self.mailbox_type:
            add_xml_child(mailbox, 't:MailboxType', self.mailbox_type)
        if self.item_id:
            set_xml_value(mailbox, self.item_id, version)
        return mailbox

    @classmethod
    def from_xml(cls, elem):
        if elem is None:
            return None
        assert elem.tag == cls.response_tag(), (elem.tag, cls.response_tag())
        return cls(
            name=get_xml_attr(elem, '{%s}Name' % TNS),
            email_address=get_xml_attr(elem, '{%s}EmailAddress' % TNS),
            mailbox_type=get_xml_attr(elem, '{%s}MailboxType' % TNS),
            item_id=ItemId.from_xml(elem.find(ItemId.response_tag())),
        )

    def __eq__(self, other):
        return hash(self) == hash(other)

    def __hash__(self):
        # Exchange may add 'mailbox_type' and 'name' on insert. We're satisfied if the item_id or email address matches.
        if self.item_id:
            return hash(self.item_id)
        return hash(self.email_address.lower())

    def __repr__(self):
        return self.__class__.__name__ + repr((self.name, self.email_address, self.mailbox_type, self.item_id))


class RoomList(Mailbox):
    ELEMENT_NAME = 'RoomList'

    @classmethod
    def request_tag(cls):
        return 'm:%s' % cls.ELEMENT_NAME


    @classmethod
    def response_tag(cls):
        return '{%s}%s' % (MNS, cls.ELEMENT_NAME)


class Room(Mailbox):
    ELEMENT_NAME = 'Room'

    @classmethod
    def from_xml(cls, elem):
        if elem is None:
            return None
        assert elem.tag == cls.response_tag(), (elem.tag, cls.response_tag())
        id_elem = elem.find('{%s}Id' % TNS)
        return cls(
            name=get_xml_attr(id_elem, '{%s}Name' % TNS),
            email_address=get_xml_attr(id_elem, '{%s}EmailAddress' % TNS),
            mailbox_type=get_xml_attr(id_elem, '{%s}MailboxType' % TNS),
            item_id=ItemId.from_xml(id_elem.find(ItemId.response_tag())),
        )


class ExtendedProperty(EWSElement):
    ELEMENT_NAME = 'ExtendedProperty'

    property_id = None
    property_name = None
    property_type = None

    __slots__ = ('value',)

    def __init__(self, value):
        assert isinstance(value, str)
        self.value = value

    @classmethod
    def field_uri_xml(cls):
        return create_element(
            't:ExtendedFieldURI',
            PropertySetId=cls.property_id,
            PropertyName=cls.property_name,
            PropertyType=cls.property_type
        )

    def to_xml(self, version):
        extended_property = create_element(self.request_tag())
        set_xml_value(extended_property, self.field_uri_xml(), version)
        add_xml_child(extended_property, 't:Value', self.value)
        return extended_property

    @classmethod
    def get_value(cls, elem):
        # Gets value of this specific ExtendedProperty from a list of 'ExtendedProperty' XML elements
        extended_field_value = None
        for e in elem:
            extended_field_uri = e.find('{%s}ExtendedFieldURI' % TNS)
            match = True

            for k, v in (
                    ('PropertySetId', cls.property_id),
                    ('PropertyName', cls.property_name),
                    ('PropertyType', cls.property_type),
            ):
                if extended_field_uri.get(k) != v:
                    match = False
                    break
            if match:
                extended_field_value = get_xml_attr(e, '{%s}Value' % TNS) or ''
                break
        return extended_field_value

    def __repr__(self):
        return self.__class__.__name__ + repr((self.value,))


class ExternId(ExtendedProperty):
    # 'c11ff724-aa03-4555-9952-8fa248a11c3e' is arbirtary. We just want a unique UUID.
    property_id = 'c11ff724-aa03-4555-9952-8fa248a11c3e'
    property_name = 'External ID'
    property_type = 'String'

    __slots__ = ('value',)

    def __init__(self, extern_id):
        super().__init__(value=extern_id)


class Attendee(EWSElement):
    ELEMENT_NAME = 'Attendee'
    RESPONSE_TYPES = {'Unknown', 'Organizer', 'Tentative', 'Accept', 'Decline', 'NoResponseReceived'}

    __slots__ = ('mailbox', 'response_type', 'last_response_time')

    def __init__(self, mailbox, response_type, last_response_time=None):
        assert isinstance(mailbox, Mailbox)
        assert response_type in self.RESPONSE_TYPES
        if last_response_time is not None:
            assert isinstance(last_response_time, EWSDateTime)
        self.mailbox = mailbox
        self.response_type = response_type
        self.last_response_time = last_response_time

    def to_xml(self, version):
        attendee = create_element(self.request_tag())
        set_xml_value(attendee, self.mailbox, version)
        add_xml_child(attendee, 't:ResponseType', self.response_type)
        if self.last_response_time:
            add_xml_child(attendee, 't:LastResponseTime', self.last_response_time)
        return attendee

    @classmethod
    def from_xml(cls, elem):
        if elem is None:
            return None
        assert elem.tag == cls.response_tag()
        last_response_time = get_xml_attr(elem, '{%s}LastResponseTime' % TNS)
        return cls(
            mailbox=Mailbox.from_xml(elem.find(Mailbox.response_tag())),
            response_type=get_xml_attr(elem, '{%s}ResponseType' % TNS) or 'Unknown',
            last_response_time=EWSDateTime.from_string(last_response_time) if last_response_time else None,
        )

    def __eq__(self, other):
        return hash(self) == hash(other)

    def __hash__(self):
        # TODO: maybe take 'response_type' and 'last_response_time' into account?
        return hash(self.mailbox)

    def __repr__(self):
        return self.__class__.__name__ + repr((self.mailbox, self.response_type, self.last_response_time))


class Item(EWSElement):
    ELEMENT_NAME = 'Item'
    FIELDURI_PREFIX = 'item'

    # ITEM_FIELDS is a mapping from Python attribute name to a 2-tuple containing XML element name and value type.
    # Not all attributes are supported. See full list at
    # https://msdn.microsoft.com/en-us/library/office/aa580790(v=exchg.150).aspx

    # 'extern_id' is not a native EWS Item field. We use it for identification when item originates in an external
    # system. The field is implemented as an extended property on the Item.
    ITEM_FIELDS = {
        'item_id': ('Id', str),
        'changekey': ('ChangeKey', str),
        # 'mime_content': ('MimeContent', str),
        'sensitivity': ('Sensitivity', Choice),
        'importance': ('Importance', Choice),
        'is_draft': ('IsDraft', bool),
        'subject': ('Subject', str),
        'body': ('Body', str),
        'reminder_is_set': ('ReminderIsSet', bool),
        'categories': ('Categories', [str]),
        'extern_id': (ExternId, ExternId),
    }
    # These are optional fields that we don't normally request, for performance reasons.
    EXTRA_ITEM_FIELDS = {
        'datetime_created': ('DateTimeCreated', EWSDateTime),
        'datetime_sent': ('DateTimeSent', EWSDateTime),
        'datetime_received': ('DateTimeReceived', EWSDateTime),
        'last_modified_name': ('LastModifiedName', str),
        'last_modified_time': ('LastModifiedTime', EWSDateTime),
    }
    # Possible values for string enums
    CHOICES = {
        'sensitivity': {'Normal', 'Personal', 'Private', 'Confidential'},
        'importance': {'Low', 'Normal', 'High'},
    }
    # The order in which fields must be added to the XML output
    ORDERED_FIELDS = ()
    # Item fields that are necessary to create an item
    REQUIRED_FIELDS = {'sensitivity', 'importance', 'reminder_is_set'}
    # Fields that are read-only in Exchange. Put mime_content here until it's properly supported
    READONLY_FIELDS = {'is_draft'}

    __slots__ = tuple(ITEM_FIELDS) + tuple(EXTRA_ITEM_FIELDS)

    def __init__(self, **kwargs):
        for k in Item.__slots__:
            default = False if k == 'reminder_is_set' else None
            v = kwargs.pop(k, default)
            if v is not None:
                # Test if arguments have the correct type. 'extern_id' is special because we implement it internally as
                # the ExternId class but want to keep the attribute as a simple str for simplicity and ease of use.
                # 'field_type' may be a list with a single type. In that case we want to check all list members
                field_type = self.type_for_field(k)
                if isinstance(field_type, list):
                    elem_type = field_type[0]
                    assert isinstance(v, list)
                    for item in v:
                        if not isinstance(item, elem_type):
                            raise TypeError('Field %s value "%s" must be of type %s' % (k, v, field_type))
                elif k != 'extern_id' and field_type != Choice and not isinstance(v, field_type):
                    raise TypeError('Field %s value "%s" must be of type %s' % (k, v, field_type))
            setattr(self, k, v)
        for k, v in kwargs.items():
            raise TypeError("'%s' is an invalid keyword argument for this function" % k)

    @classmethod
    def fieldnames(cls, with_extra=False):
        # Return non-ID field names
        base_fields = tuple(f for f in cls.ITEM_FIELDS if f not in ('item_id', 'changekey'))
        if with_extra:
            return base_fields + tuple(cls.EXTRA_ITEM_FIELDS)
        return base_fields

    @classmethod
    def uri_for_field(cls, fieldname):
        try:
            return cls.ITEM_FIELDS[fieldname][0]
        except KeyError:
            return cls.EXTRA_ITEM_FIELDS[fieldname][0]

    @classmethod
    def fielduri_for_field(cls, fieldname):
        # See all valid FieldURI values at https://msdn.microsoft.com/en-us/library/office/aa494315(v=exchg.150).aspx
        try:
            field_uri = cls.uri_for_field(fieldname)
            if isinstance(field_uri, str):
                return '%s:%s' % (cls.FIELDURI_PREFIX, field_uri)
            return field_uri
        except KeyError:
            raise ValueError("No fielduri defined for fieldname '%s'" % fieldname)

    @classmethod
    def elem_for_field(cls, fieldname):
        assert isinstance(fieldname, str)
        try:
            if fieldname == 'body':
                return create_element('t:%s' % cls.uri_for_field(fieldname), BodyType='Text')
            return create_element('t:%s' % cls.uri_for_field(fieldname))
        except KeyError:
            raise ValueError("No fielduri defined for fieldname '%s'" % fieldname)

    @classmethod
    def response_xml_elem_for_field(cls, fieldname):
        try:
            uri = cls.uri_for_field(fieldname)
        except KeyError:
            raise ValueError("No fielduri defined for fieldname '%s'" % fieldname)
        if isinstance(uri, str):
            return '{%s}%s' % (TNS, uri)
        if issubclass(uri, IndexedField):
            return '{%s}%s' % (TNS, uri.PARENT_ELEMENT_NAME)
        assert False, 'Unknown uri for fieldname %s: %s' % (fieldname, uri)

    @classmethod
    def type_for_field(cls, fieldname):
        try:
            return cls.ITEM_FIELDS[fieldname][1]
        except KeyError:
            try:
                return cls.EXTRA_ITEM_FIELDS[fieldname][1]
            except KeyError:
                raise ValueError("No type defined for fieldname '%s'" % fieldname)

    @classmethod
    def additional_property_elems(cls, with_extra=False):
        fields = []
        for f in cls.fieldnames(with_extra=with_extra):
            field_uri = cls.fielduri_for_field(f)
            if isinstance(field_uri, str):
                fields.append(create_element('t:FieldURI', FieldURI=field_uri))
            elif issubclass(field_uri, IndexedField):
                for l in field_uri.LABELS:
                    fields.append(field_uri.field_uri_xml(label=l))
            elif issubclass(field_uri, ExtendedProperty):
                fields.append(field_uri.field_uri_xml())
            else:
                assert False, 'Unknown field_uri type: %s' % field_uri
        return fields

    @classmethod
    def id_from_xml(cls, elem):
        id_elem = elem.find(ItemId.response_tag())
        return id_elem.get('Id'), id_elem.get('ChangeKey')

    @classmethod
    def from_xml(cls, elem, with_extra=False):
        assert elem.tag == cls.response_tag()
        item_id, changekey = cls.id_from_xml(elem)
        kwargs = {}
        extended_properties = elem.findall(ExtendedProperty.response_tag())
        for fieldname in cls.fieldnames(with_extra=with_extra):
            field_type = cls.type_for_field(fieldname)
            if field_type == EWSDateTime:
                val = get_xml_attr(elem, cls.response_xml_elem_for_field(fieldname))
                if val is not None:
                    kwargs[fieldname] = EWSDateTime.from_string(val)
            elif field_type == bool:
                val = get_xml_attr(elem, cls.response_xml_elem_for_field(fieldname))
                if val is not None:
                    kwargs[fieldname] = True if val == 'true' else False
            elif field_type in (str, Choice, Email):
                val = get_xml_attr(elem, cls.response_xml_elem_for_field(fieldname))
                if val is not None:
                    kwargs[fieldname] = val
            elif field_type == int:
                val = get_xml_attr(elem, cls.response_xml_elem_for_field(fieldname))
                if val:
                    try:
                        kwargs[fieldname] = int(val)
                    except ValueError:
                        pass
            elif field_type == Decimal:
                val = get_xml_attr(elem, cls.response_xml_elem_for_field(fieldname))
                if val is not None:
                    try:
                        kwargs[fieldname] = Decimal(val)
                    except ValueError:
                        pass
            elif isinstance(field_type, list):
                list_type = field_type[0]
                if list_type == str:
                    iter_elem = elem.find(cls.response_xml_elem_for_field(fieldname))
                    if iter_elem is not None:
                        kwargs[fieldname] = get_xml_attrs(iter_elem, '{%s}String' % TNS)
                elif issubclass(list_type, EWSElement):
                    iter_elem = elem.find(cls.response_xml_elem_for_field(fieldname))
                    if iter_elem is not None:
                        kwargs[fieldname] = [list_type.from_xml(e) for e in iter_elem.findall(list_type.response_tag())]
                else:
                    assert False, 'Field %s type %s not supported' % (fieldname, field_type)
            elif issubclass(field_type, ExtendedProperty):
                kwargs[fieldname] = field_type.get_value(extended_properties)
            elif issubclass(field_type, EWSElement):
                sub_elem = elem.find(cls.response_xml_elem_for_field(fieldname))
                if sub_elem is not None:
                    if fieldname in ('organizer', 'sender', 'from'):
                        # We want the nested Mailbox, not the wrapper element
                        kwargs[fieldname] = field_type.from_xml(sub_elem.find(Mailbox.response_tag()))
                    else:
                        kwargs[fieldname] = field_type.from_xml(sub_elem)
            else:
                assert False, 'Field %s type %s not supported' % (fieldname, field_type)
        return cls(item_id=item_id, changekey=changekey, **kwargs)

    def __repr__(self):
        return self.__class__.__name__ + '(%s)' % ', '.join(
            '%s=%s' % (k, repr(getattr(self, k))) for k in self.fieldnames()
        )


class Folder:
    DISTINGUISHED_FOLDER_ID = None  # Must be lowercase
    CONTAINER_CLASS = None  # See http://msdn.microsoft.com/en-us/library/hh354773(v=exchg.80).aspx
    item_model = Item

    def __init__(self, account, name=None, folder_class=None, folder_id=None, changekey=None):
        self.account = account
        self.name = name or self.DISTINGUISHED_FOLDER_ID
        self.folder_class = folder_class
        self.folder_id = folder_id
        self.changekey = changekey
        if not self.is_distinguished:
            assert self.folder_id
        if self.folder_id:
            assert self.changekey
        log.debug('%s created for %s', self.__class__.__name__, account)

    @property
    def is_distinguished(self):
        return self.name.lower() == self.DISTINGUISHED_FOLDER_ID

    @classmethod
    def attr_to_fielduri(cls, fieldname):
        return cls.item_model.fielduri_for_field(fieldname)

    @classmethod
    def attr_to_response_xml_elem(cls, fieldname):
        return cls.item_model.response_xml_elem_for_field(fieldname)

    def find_items(self, *args, **kwargs):
        """
        Finds items in the folder.

        'shape' controls the exact fields returned are governed by. Be aware that the 'body' element can only be fetched
        with get_items().
        'depth' controls the search depth into sub-folders.

        Non-keyword args may be a search expression as supported by Restriction.from_source(), or a list of Q instances.

        Optional extra keyword arguments follow a Django-like QuerySet filter syntax (see
           https://docs.djangoproject.com/en/1.10/ref/models/querysets/#field-lookups).

        We don't support '__year' and other data-related lookups. We also don't support '__endswith' or '__iendswith'.

        We support the additional '__not' lookup in place of Django's exclude() for simple cases. For more complicated
        cases you need to create a Q object and use ~Q().

        Examples:

            my_account.inbox.find_items(datetime_received__gt=EWSDateTime(2016, 1, 1))
            my_account.calendar.find_items(start__range=(EWSDateTime(2016, 1, 1), EWSDateTime(2017, 1, 1)))
            my_account.tasks.find_items(subject='Hi mom')
            my_account.tasks.find_items(subject__not='Hi mom')
            my_account.tasks.find_items(subject__contains='Foo')
            my_account.tasks.find_items(subject__icontains='foo')

        """
        # 'endswith' and 'iendswith' could be implemented by searching with 'contains' or 'icontains' and then
        # post-processing items. Fetch the field in question with additional_fields and remove items where the search
        # string is not a postfix.

        shape = IdOnly if 'shape' not in kwargs else kwargs.pop('shape')
        depth = SHALLOW if 'depth' not in kwargs else kwargs.pop('depth')

        # Define the extra properties we want on the return objects. 'body' field can only be fetched with GetItem.
        additional_fields = None

        # Build up any restrictions
        q = None
        if args:
            q_args = []
            for arg in args:
                # Convert all search expressions to q objects
                if isinstance(arg, str):
                    q_args.append(Restriction.from_source(args[0], item_model=self.item_model).q)
                else:
                    if not isinstance(arg, Q):
                        raise ValueError("Non-keyword arg '%s' must be a Q object" % arg)
                    q_args.append(arg)
            # AND all the given Q objects together
            q = functools.reduce(lambda a, b: a & b, q_args)
        if kwargs:
            kwargs_q = q or Q()
            for key, value in kwargs.items():
                if '__' in key:
                    field, lookup = key.rsplit('__')
                else:
                    field, lookup = key, None
                # Filtering by category is a bit quirky. The only lookup type I have found to work is:
                #
                #     item:Categories == 'foo' AND item:Categories == 'bar' AND ...
                #
                #     item:Categories == 'foo' OR item:Categories == 'bar' OR ...
                #
                # The former returns items that have these categories, but maybe also others. The latter returns
                # items that have at least one of these categories. This translates to the 'contains' and 'in' lookups.
                # Both versions are case-insensitive.
                #
                # Exact matching and case-sensitive or partial-string matching is not possible since that requires the
                # 'Contains' element which only supports matching on string elements, not arrays.
                #
                # Exact matching of categories (i.e. match ['a', 'b'] but not ['a', 'b', 'c']) could be implemented by
                # post-processing items. Fetch 'item:Categories' with additional_fields and remove the items that don't
                # have an exact match, after the call to FindItems.
                if field == 'categories':
                    if lookup not in (Q.LOOKUP_CONTAINS, Q.LOOKUP_IN):
                        raise ValueError(
                            "Categories can only be filtered using 'categories__contains=['a', 'b', ...]' and "
                            "'categories__in=['a', 'b', ...]'")
                    if isinstance(value, str):
                        kwargs_q &= Q(categories=value)
                    else:
                        children = [Q(categories=v) for v in value]
                        if lookup == Q.LOOKUP_CONTAINS:
                            kwargs_q &= Q(*children, conn_type=Q.AND)
                        elif lookup == Q.LOOKUP_IN:
                            kwargs_q &= Q(*children, conn_type=Q.OR)
                        else:
                            assert False
                    continue
                kwargs_q &= Q(**{key: value})
            q = kwargs_q
        if q:
            restriction = Restriction(q.translate_fields(item_model=self.item_model))
        else:
            restriction = None
        log.debug(
            'Finding %s items for %s (shape: %s, depth: %s, extra fields: %s, restriction: %s)',
            self.DISTINGUISHED_FOLDER_ID,
            self.account,
            shape,
            depth,
            additional_fields,
            restriction.q,
        )
        xml_func = self.item_model.id_from_xml if shape == IdOnly else self.item_model.from_xml
        items = FindItem(self.account.protocol).call(folder=self, additional_fields=additional_fields,
                                                     restriction=restriction, shape=shape, depth=depth)
        log.debug('Found %s items', len(items))
        return list(map(xml_func, items))

    def add_items(self, items):
        """
        Creates new items in the folder. 'items' is an iterable of Item objects. Returns a list of (id, changekey)
        tuples in the same order as the input.
        """
        is_empty, items = peek(items)
        if is_empty:
            # We accept generators, so it's not always convenient for caller to know up-front if 'items' is empty. Allow
            # empty 'items' and return early.
            return []
        return list(map(self.item_model.id_from_xml, CreateItem(self.account.protocol).call(folder=self, items=items)))

    def delete_items(self, ids, all_occurrences=False):
        """
        Deletes items in the folder. 'ids' is an iterable of either (item_id, changekey) tuples or Item objects.
        'all_occurrences' is only applicable for recurring Task items.
        """
        is_empty, ids = peek(ids)
        if is_empty:
            # We accept generators, so it's not always convenient for caller to know up-front if 'items' is empty. Allow
            # empty 'items' and return early.
            return []
        return DeleteItem(self.account.protocol).call(folder=self, ids=ids, all_occurrences=all_occurrences)

    def update_items(self, items):
        """
        Updates items in the folder. 'items' is an iterable of tuples containing two elements:

            1. either an (item_id, changekey) tuple or an Item object
            2. a dict containing the Item attributes to change

        """
        is_empty, items = peek(items)
        if is_empty:
            # We accept generators, so it's not always convenient for caller to know up-front if 'items' is empty. Allow
            # empty 'items' and return early.
            return []
        return list(map(self.item_model.id_from_xml, UpdateItem(self.account.protocol).call(folder=self, items=items)))

    def get_items(self, ids, with_extra=True):
        # 'with_extra' determines whether to get the extra fields defined in Item.EXTRA_ITEM_FIELDS. This is still a
        # kludge - instead, the user should be able to specify the exact fields to get or ignore. See also find_items()
        if hasattr(self, 'with_extra_fields'):
            raise DeprecationWarning(
                "'%(cls)s.with_extra_fields' is deprecated. Use '%(cls)s.get_items(ids, with_extra=True)' instead"
                % dict(cls=self.__class__.__name__))
        is_empty, ids = peek(ids)
        if is_empty:
            # We accept generators, so it's not always convenient for caller to know up-front if 'items' is empty. Allow
            # empty 'items' and return early.
            return []
        return list(map(
            lambda i: self.item_model.from_xml(i, with_extra=with_extra),
            GetItem(self.account.protocol).call(folder=self, ids=ids, with_extra=with_extra)
        ))

    def test_access(self):
        """
        Does a simple FindItem to test (read) access to the folder. Maybe the account doesn't exist, maybe the
        service user doesn't have access to the calendar. This will throw the most common errors.
        """
        self.find_items(subject='DUMMY')
        return True

    def folderid_xml(self):
        if self.folder_id:
            assert self.changekey
            return create_element('t:FolderId', Id=self.folder_id, ChangeKey=self.changekey)
        else:
            # Only use distinguished ID if we don't have the folder ID
            distinguishedfolderid = create_element('t:DistinguishedFolderId', Id=self.DISTINGUISHED_FOLDER_ID)
            if self.account.access_type == DELEGATE:
                mailbox = Mailbox(email_address=self.account.primary_smtp_address)
                set_xml_value(distinguishedfolderid, mailbox, self.account.version)
            return distinguishedfolderid

    def get_xml(self, ids, with_extra):
        # The 'additional_properties' list should be configurable. 'body' element can only be fetched with GetItem.
        # CalendarItem.from_xml() specifies the items we currently expect. For full list, see
        # https://msdn.microsoft.com/en-us/library/office/aa494315(v=exchg.150).aspx
        log.debug(
            'Getting %s items for %s',
            self.DISTINGUISHED_FOLDER_ID,
            self.account
        )
        getitem = create_element('m:%s' % GetItem.SERVICE_NAME)
        itemshape = create_element('m:ItemShape')
        add_xml_child(itemshape, 't:BaseShape', IdOnly)
        additional_properties = self.item_model.additional_property_elems(with_extra=with_extra)
        if additional_properties:
            add_xml_child(itemshape, 't:AdditionalProperties', additional_properties)
        getitem.append(itemshape)
        item_ids = create_element('m:ItemIds')
        n = 0
        for item in ids:
            n += 1
            item_id = ItemId(*item) if isinstance(item, tuple) else ItemId(item.item_id, item.changekey)
            set_xml_value(item_ids, item_id, self.account.version)
        if not n:
            raise AttributeError('"ids" must not be empty')
        getitem.append(item_ids)
        return getitem

    def create_xml(self, items):
        # Takes an account name, a folder name, a list of Calendar.Item obejcts and a function to convert items to XML
        # Elements
        if isinstance(self, Calendar):
            createitem = create_element('m:%s' % CreateItem.SERVICE_NAME, SendMeetingInvitations='SendToNone')
        elif isinstance(self, Messages):
            createitem = create_element('m:%s' % CreateItem.SERVICE_NAME, MessageDisposition='SaveOnly')
        else:
            createitem = create_element('m:%s' % CreateItem.SERVICE_NAME)
        add_xml_child(createitem, 'm:SavedItemFolderId', self.folderid_xml())
        item_elems = [i.to_xml(self.account.version) for i in items]
        if not item_elems:
            raise AttributeError('"items" must not be empty')
        add_xml_child(createitem, 'm:Items', item_elems)
        return createitem

    def delete_xml(self, ids, all_occurrences=True):
        # Prepare reuseable Element objects.
        if isinstance(self, Calendar):
            deleteitem = create_element(
                'm:%s' % DeleteItem.SERVICE_NAME, DeleteType='HardDelete', SendMeetingCancellations='SendToNone')
        elif isinstance(self, Tasks):
            deleteitem = create_element(
                'm:%s' % DeleteItem.SERVICE_NAME, DeleteType='HardDelete',
                AffectedTaskOccurrences='AllOccurrences' if all_occurrences else 'SpecifiedOccurrenceOnly')
        else:
            deleteitem = create_element('m:%s' % DeleteItem.SERVICE_NAME, DeleteType='HardDelete')
        if self.account.version.build >= EXCHANGE_2013:
            deleteitem.set('SuppressReadReceipts', 'true')

        item_ids = create_element('m:ItemIds')
        n = 0
        for item in ids:
            n += 1
            item_id = ItemId(*item) if isinstance(item, tuple) else ItemId(item.item_id, item.changekey)
            set_xml_value(item_ids, item_id, self.account.version)
        if not n:
            raise AttributeError('"ids" must not be empty')
        deleteitem.append(item_ids)
        return deleteitem

    def update_xml(self, items):
        # Prepare reuseable Element objects
        if isinstance(self, Calendar):
            updateitem = create_element('m:%s' % UpdateItem.SERVICE_NAME, ConflictResolution='AutoResolve',
                                        SendMeetingInvitationsOrCancellations='SendToNone')
        elif isinstance(self, Messages):
            updateitem = create_element('m:%s' % UpdateItem.SERVICE_NAME, ConflictResolution='AutoResolve',
                                        MessageDisposition='SaveOnly')
        else:
            updateitem = create_element('m:%s' % UpdateItem.SERVICE_NAME, ConflictResolution='AutoResolve')
        if self.account.version.build >= EXCHANGE_2013:
            updateitem.set('SuppressReadReceipts', 'true')

        itemchanges = create_element('m:ItemChanges')
        n = 0
        for item, update_dict in items:
            n += 1
            if not update_dict:
                raise AttributeError('"update_dict" must not be empty')
            itemchange = create_element('t:ItemChange')
            item_id = ItemId(*item) if isinstance(item, tuple) else ItemId(item.item_id, item.changekey)
            set_xml_value(itemchange, item_id, self.account.version)
            updates = create_element('t:Updates')
            meeting_timezone_added = False
            for fieldname, val in update_dict.items():
                if fieldname in self.item_model.readonly_fields():
                    log.warning('%s is a read-only field. Skipping', fieldname)
                    continue
                if fieldname == 'extern_id' and val is not None:
                    val = ExternId(val)
                field_uri = self.attr_to_fielduri(fieldname)
                if isinstance(field_uri, str):
                    fielduri = create_element('t:FieldURI', FieldURI=field_uri)
                elif issubclass(field_uri, IndexedField):
                    log.warning("Skipping update on fieldname '%s' (not supported yet)", fieldname)
                    continue
                    # TODO: we need to create a SetItemField for every item in the list, and possibly DeleteItemField
                    # for every label not on the list
                    # fielduri = field_uri.field_uri_xml(label=val.label)
                elif issubclass(field_uri, ExtendedProperty):
                    fielduri = field_uri.field_uri_xml()
                else:
                    assert False, 'Unknown field_uri type: %s' % field_uri
                if val is None:
                    # A value of None means we want to remove this field from the item
                    if fieldname in self.item_model.required_fields():
                        log.warning('%s is a required field and may not be deleted. Skipping', fieldname)
                        continue
                    add_xml_child(updates, 't:DeleteItemField', fielduri)
                    continue
                setitemfield = create_element('t:SetItemField')
                setitemfield.append(fielduri)
                folderitem = create_element(self.item_model.request_tag())

                if isinstance(val, EWSElement):
                    set_xml_value(folderitem, val, self.account.version)
                else:
                    folderitem.append(
                        set_xml_value(self.item_model.elem_for_field(fieldname), val, self.account.version)
                    )
                setitemfield.append(folderitem)
                updates.append(setitemfield)

                if isinstance(val, EWSDateTime):
                    # Always set timezone explicitly when updating date fields. Exchange 2007 wants "MeetingTimeZone"
                    # instead of explicit timezone on each datetime field.
                    setitemfield_tz = create_element('t:SetItemField')
                    folderitem_tz = create_element(self.item_model.request_tag())
                    if self.account.version.build < EXCHANGE_2010:
                        if meeting_timezone_added:
                            # Let's hope that we're not changing timezone, or that both 'start' and 'end' are supplied.
                            # Exchange 2007 doesn't support different timezone on start and end.
                            continue
                        fielduri_tz = create_element('t:FieldURI', FieldURI='calendar:MeetingTimeZone')
                        timezone = create_element('t:MeetingTimeZone', TimeZoneName=val.tzinfo.ms_id)
                        meeting_timezone_added = True
                    else:
                        if fieldname == 'start':
                            fielduri_tz = create_element('t:FieldURI', FieldURI='calendar:StartTimeZone')
                            timezone = create_element('t:StartTimeZone', Id=val.tzinfo.ms_id, Name=val.tzinfo.ms_name)
                        elif fieldname == 'end':
                            fielduri_tz = create_element('t:FieldURI', FieldURI='calendar:EndTimeZone')
                            timezone = create_element('t:EndTimeZone', Id=val.tzinfo.ms_id, Name=val.tzinfo.ms_name)
                        else:
                            log.warning("Skipping timezone for field '%s'", fieldname)
                            continue
                    setitemfield_tz.append(fielduri_tz)
                    folderitem_tz.append(timezone)
                    setitemfield_tz.append(folderitem_tz)
                    updates.append(setitemfield_tz)
            itemchange.append(updates)
            itemchanges.append(itemchange)
        if not n:
            raise AttributeError('"items" must not be empty')
        updateitem.append(itemchanges)
        return updateitem

    @classmethod
    def from_xml(cls, account, elem):
        # fld_type = re.sub('{.*}', '', elem.tag)
        fld_id_elem = elem.find('{%s}FolderId' % TNS)
        fld_id = fld_id_elem.get('Id')
        changekey = fld_id_elem.get('ChangeKey')
        display_name = get_xml_attr(elem, '{%s}DisplayName' % TNS)
        folder_class = get_xml_attr(elem, '{%s}FolderClass' % TNS)
        fld_class = FOLDER_CLASS_MAP.get(folder_class, GenericFolder)
        return fld_class(account=account, name=display_name, folder_class=folder_class, folder_id=fld_id,
                         changekey=changekey)

    def get_folders(self, shape=IdOnly, depth=DEEP):
        folders = []
        for elem in FindFolder(self.account.protocol).call(
                folder=self,
                additional_fields=['folder:DisplayName', 'folder:FolderClass'],
                shape=shape,
                depth=depth
        ):
            folders.append(self.from_xml(self.account, elem))
        return folders

    def get_folder(self, shape=IdOnly):
        folders = []
        for elem in GetFolder(self.account.protocol).call(
                folder=self,
                additional_fields=['folder:DisplayName', 'folder:FolderClass'],
                shape=shape
        ):
            folders.append(self.from_xml(self.account, elem))
        assert len(folders) == 1
        return folders[0]

    def __repr__(self):
        return self.__class__.__name__ + \
               repr((self.account, self.name, self.folder_class, self.folder_id, self.changekey))

    def __str__(self):
        return '%s (%s)' % (self.__class__.__name__, self.name)


class ItemMixIn(Item):
    def to_xml(self, version):
        # WARNING: The order of addition of XML elements is VERY important. Exchange expects XML elements in a
        # specific, non-documented order and will fail with meaningless errors if the order is wrong.
        assert self.ORDERED_FIELDS
        i = create_element(self.request_tag())
        for f in self.ORDERED_FIELDS:
            assert f not in self.readonly_fields(), (f, self.readonly_fields())
            field_uri = self.fielduri_for_field(f)
            v = getattr(self, f)
            if v is not None:
                if isinstance(field_uri, str):
                    i.append(set_xml_value(self.elem_for_field(f), v, version))
                elif issubclass(field_uri, IndexedField):
                    i.append(set_xml_value(create_element('t:%s' % field_uri.PARENT_ELEMENT_NAME), v, version))
                elif issubclass(field_uri, ExtendedProperty):
                    set_xml_value(i, ExternId(getattr(self, f)), version)
                else:
                    assert False, 'Unknown field_uri type: %s' % field_uri
        return i

    @classmethod
    def fieldnames(cls, with_extra=False):
        return tuple(cls.ITEM_FIELDS) + Item.fieldnames(with_extra=with_extra)

    @classmethod
    def fielduri_for_field(cls, fieldname):
        try:
            field_uri = cls.ITEM_FIELDS[fieldname][0]
            if isinstance(field_uri, str):
                return '%s:%s' % (cls.FIELDURI_PREFIX, field_uri)
            return field_uri
        except KeyError:
            return Item.fielduri_for_field(fieldname)

    @classmethod
    def elem_for_field(cls, fieldname):
        assert isinstance(fieldname, str)
        try:
            return create_element('t:%s' % cls.uri_for_field(fieldname))
        except KeyError:
            return Item.elem_for_field(fieldname)

    @classmethod
    def response_xml_elem_for_field(cls, fieldname):
        try:
            uri = cls.uri_for_field(fieldname)
        except KeyError:
            return Item.response_xml_elem_for_field(fieldname)
        if isinstance(uri, str):
            return '{%s}%s' % (TNS, uri)
        if issubclass(uri, IndexedField):
            return '{%s}%s' % (TNS, uri.PARENT_ELEMENT_NAME)
        assert False, 'Unknown uri for fieldname %s: %s' % (fieldname, uri)

    @classmethod
    def required_fields(cls):
        return cls.REQUIRED_FIELDS | Item.REQUIRED_FIELDS

    @classmethod
    def readonly_fields(cls):
        return cls.READONLY_FIELDS | Item.READONLY_FIELDS

    @classmethod
    def choices_for_field(cls, fieldname):
        try:
            return cls.CHOICES[fieldname]
        except KeyError:
            return Item.CHOICES[fieldname]

    @classmethod
    def type_for_field(cls, fieldname):
        try:
            return cls.ITEM_FIELDS[fieldname][1]
        except KeyError:
            return Item.type_for_field(fieldname)


class Root(Folder):
    DISTINGUISHED_FOLDER_ID = 'root'


class CalendarItem(ItemMixIn):
    """
    Models a calendar item. Not all attributes are supported. See full list at
    https://msdn.microsoft.com/en-us/library/office/aa564765(v=exchg.150).aspx
    """
    ELEMENT_NAME = 'CalendarItem'
    SUBJECT_MAXLENGTH = 255
    LOCATION_MAXLENGTH = 255
    FIELDURI_PREFIX = 'calendar'
    CHOICES = {
        # TODO: The 'WorkingElsewhere' status was added in Exchange2015 but we don't support versioned choices yet
        'legacy_free_busy_status': {'Free', 'Tentative', 'Busy', 'OOF', 'NoData'},
    }
    ITEM_FIELDS = {
        'start': ('Start', EWSDateTime),
        'end': ('End', EWSDateTime),
        'location': ('Location', str),
        'organizer': ('Organizer', Mailbox),
        'legacy_free_busy_status': ('LegacyFreeBusyStatus', Choice),
        'required_attendees': ('RequiredAttendees', [Attendee]),
        'optional_attendees': ('OptionalAttendees', [Attendee]),
        'resources': ('Resources', [Attendee]),
    }
    ORDERED_FIELDS = (
        'subject', 'sensitivity', 'body', 'categories', 'importance', 'reminder_is_set', 'extern_id',
        'start', 'end',
        'legacy_free_busy_status', 'location', 'required_attendees', 'optional_attendees', 'resources'
    )
    REQUIRED_FIELDS = {'subject', 'start', 'end', 'legacy_free_busy_status'}
    READONLY_FIELDS = {'organizer'}

    __slots__ = tuple(ITEM_FIELDS) + tuple(Item.ITEM_FIELDS) + tuple(Item.EXTRA_ITEM_FIELDS)

    def __init__(self, **kwargs):
        for k in self.ITEM_FIELDS:
            field_type = self.ITEM_FIELDS[k][1]
            default = 'Busy' if k == 'legacy_free_busy_status' \
                else False if (k in self.required_fields() and field_type == bool) else None
            v = kwargs.pop(k, default)
            if k in ('start', 'end') and v and not getattr(v, 'tzinfo'):
                raise ValueError("'%s' must be timezone aware")
            if field_type == Choice:
                assert v is None or v in self.choices_for_field(k), (v, self.choices_for_field(k))
            setattr(self, k, v)
        super().__init__(**kwargs)

    def to_xml(self, version):
        # WARNING: The order of addition of XML elements is VERY important. Exchange expects XML elements in a
        # specific, non-documented order and will fail with meaningless errors if the order is wrong.
        i = super().to_xml(version=version)
        if version.build < EXCHANGE_2010:
            i.append(create_element('t:MeetingTimeZone', TimeZoneName=self.start.tzinfo.ms_id))
        else:
            i.append(create_element('t:StartTimeZone', Id=self.start.tzinfo.ms_id, Name=self.start.tzinfo.ms_name))
            i.append(create_element('t:EndTimeZone', Id=self.end.tzinfo.ms_id, Name=self.end.tzinfo.ms_name))
        return i

    def __str__(self):
        return '''\
ItemId: %(item_id)s
Changekey: %(changekey)s
Subject: %(subject)s
Start: %(start)s
End: %(end)s
Location: %(location)s
Body: %(body)s
Has reminder: %(reminder_is_set)s
Categories: %(categories)s
Extern ID: %(extern_id)s''' % {k: getattr(self, k) for k in self.__slots__}


class Calendar(Folder):
    """
    An interface for the Exchange calendar
    """
    DISTINGUISHED_FOLDER_ID = 'calendar'
    CONTAINER_CLASS = 'IPF.Appointment'
    item_model = CalendarItem

    # These must be capitalized
    LOCALIZED_NAMES = {
        'da_DK': ('Kalender',)
    }


class Message(ItemMixIn):
    # Supported attrs: see https://msdn.microsoft.com/en-us/library/office/aa494306(v=exchg.150).aspx
    ELEMENT_NAME = 'Message'
    FIELDURI_PREFIX = 'message'
    # TODO: This list is incomplete
    ITEM_FIELDS = {
        'is_read': ('IsRead', bool),
        'is_delivery_receipt_requested': ('IsDeliveryReceiptRequested', bool),
        'is_read_receipt_requested': ('IsReadReceiptRequested', bool),
        'is_response_requested': ('IsResponseRequested', bool),
        'from': ('From', Mailbox),
        'sender': ('Sender', Mailbox),
        'reply_to': ('ReplyTo', [Mailbox]),
        'to_recipients': ('ToRecipients', [Mailbox]),
        'cc_recipients': ('CcRecipients', [Mailbox]),
        'bcc_recipients': ('BccRecipients', [Mailbox]),
    }
    ORDERED_FIELDS = (
        'subject', 'sensitivity', 'body', 'categories', 'importance', 'reminder_is_set', 'extern_id',
        # 'sender',
        'to_recipients', 'cc_recipients', 'bcc_recipients',
        'is_read_receipt_requested', 'is_delivery_receipt_requested',
        'from', 'is_read', 'is_response_requested', 'reply_to',
    )
    REQUIRED_FIELDS = {'subject', 'is_read', 'is_delivery_receipt_requested', 'is_read_receipt_requested',
                       'is_response_requested'}
    READONLY_FIELDS = {'sender'}

    __slots__ = tuple(ITEM_FIELDS) + tuple(Item.ITEM_FIELDS) + tuple(Item.EXTRA_ITEM_FIELDS)

    def __init__(self, **kwargs):
        for k in self.ITEM_FIELDS:
            field_type = self.ITEM_FIELDS[k][1]
            default = False if (k in self.required_fields() and field_type == bool) else None
            v = kwargs.pop(k, default)
            if field_type == Choice:
                assert v is None or v in self.choices_for_field(k), (v, self.choices_for_field(k))
            setattr(self, k, v)
        super().__init__(**kwargs)


class Messages(Folder):
    DISTINGUISHED_FOLDER_ID = 'inbox'
    CONTAINER_CLASS = 'IPF.Note'
    item_model = Message

    # These must be capitalized
    LOCALIZED_NAMES = {
        'da_DK': ('Indbakke',)
    }


class Task(ItemMixIn):
    # Supported attrs: see https://msdn.microsoft.com/en-us/library/office/aa563930(v=exchg.150).aspx
    ELEMENT_NAME = 'Task'
    FIELDURI_PREFIX = 'task'
    NOT_STARTED = 'NotStarted'
    COMPLETED = 'Completed'
    CHOICES = {
        'status': {NOT_STARTED, 'InProgress', COMPLETED, 'WaitingOnOthers', 'Deferred'},
        'delegation_state': {'NoMatch', 'OwnNew', 'Owned', 'Accepted', 'Declined', 'Max'},
    }
    # TODO: This list is incomplete
    ITEM_FIELDS = {
        'actual_work': ('ActualWork', int),
        'assigned_time': ('AssignedTime', EWSDateTime),
        'billing_information': ('BillingInformation', str),
        'change_count': ('ChangeCount', int),
        'companies': ('Companies', [str]),
        'contacts': ('Contacts', [str]),
        'complete_date': ('CompleteDate', EWSDateTime),
        'is_complete': ('IsComplete', bool),
        'due_date': ('DueDate', EWSDateTime),
        'delegator': ('Delegator', str),
        'delegation_state': ('DelegationState', Choice),
        'is_recurring': ('IsRecurring', bool),
        'is_team_task': ('IsTeamTask', bool),
        'mileage': ('Mileage', str),
        'owner': ('Owner', str),
        'percent_complete': ('PercentComplete', Decimal),
        'start_date': ('StartDate', EWSDateTime),
        'status': ('Status', Choice),
        'status_description': ('StatusDescription', str),
        'total_work': ('TotalWork', int),
    }
    REQUIRED_FIELDS = {'subject', 'status'}
    ORDERED_FIELDS = (
        'subject', 'sensitivity', 'body', 'categories', 'importance', 'reminder_is_set', 'extern_id',
        'actual_work',  # 'assigned_time',
        'billing_information',  # 'change_count',
        'companies',  # 'complete_date',
        'contacts',  # 'delegation_state', 'delegator',
        'due_date',  # 'is_complete', 'is_team_task',
        'mileage',  # 'owner',
        'percent_complete', 'start_date', 'status',  # 'status_description',
        'total_work',
    )
    # 'complete_date' can be set, but is ignored by the server, which sets it to now()
    READONLY_FIELDS = {'is_recurring', 'is_complete', 'is_team_task', 'assigned_time', 'change_count',
                       'delegation_state', 'delegator', 'owner', 'status_description', 'complete_date'}

    __slots__ = tuple(ITEM_FIELDS) + tuple(Item.ITEM_FIELDS) + tuple(Item.EXTRA_ITEM_FIELDS)

    def __init__(self, **kwargs):
        for k in self.ITEM_FIELDS:
            field_type = self.ITEM_FIELDS[k][1]
            default = False if (k in self.required_fields() and field_type == bool) else None
            v = kwargs.pop(k, default)
            if field_type == Choice:
                assert v is None or v in self.choices_for_field(k), (v, self.choices_for_field(k))
            setattr(self, k, v)
        if self.due_date and self.start_date and self.due_date < self.start_date:
            log.warning("'due_date' must be greater than 'start_date' (%s vs %s). Resetting 'due_date'",
                        self.due_date, self.start_date)
            self.due_date = self.start_date
        if self.complete_date:
            if self.status != self.COMPLETED:
                log.warning("'status' must be '%s' when 'complete_date' is set (%s). Resetting",
                            self.COMPLETED, self.status)
                self.status = self.COMPLETED
            now = UTC_NOW()
            if (self.complete_date - now).total_seconds() > 120:
                # 'complete_date' can be set automatically by the server. Allow some grace between local and server time
                log.warning("'complete_date' must be in the past (%s vs %s). Resetting", self.complete_date, now)
                self.complete_date = now
            if self.start_date and self.complete_date < self.start_date:
                log.warning("'complete_date' must be greater than 'start_date' (%s vs %s). Resetting",
                            self.complete_date, self.start_date)
                self.complete_date = self.start_date
        if self.percent_complete is not None:
            assert isinstance(self.percent_complete, Decimal)
            assert Decimal(0) <= self.percent_complete <= Decimal(100), self.percent_complete
            if self.status == self.COMPLETED and self.percent_complete != Decimal(100):
                # percent_complete must be 100% if task is complete
                log.warning("'percent_complete' must be 100 when 'status' is '%s' (%s). Resetting",
                            self.COMPLETED, self.percent_complete)
                self.percent_complete = Decimal(100)
            elif self.status == self.NOT_STARTED and self.percent_complete != Decimal(0):
                # percent_complete must be 0% if task is not started
                log.warning("'percent_complete' must be 0 when 'status' is '%s' (%s). Resetting",
                            self.NOT_STARTED, self.percent_complete)
                self.percent_complete = Decimal(0)
        super().__init__(**kwargs)


class Tasks(Folder):
    DISTINGUISHED_FOLDER_ID = 'tasks'
    CONTAINER_CLASS = 'IPF.Task'
    item_model = Task

    # These must be capitalized
    LOCALIZED_NAMES = {
        'da_DK': ('Opgaver',)
    }


class Contact(ItemMixIn):
    # Supported attrs: see https://msdn.microsoft.com/en-us/library/office/aa581315(v=exchg.150).aspx
    ELEMENT_NAME = 'Contact'
    FIELDURI_PREFIX = 'contacts'
    CHOICES = {
        'file_as_mapping': {
            'None', 'LastCommaFirst', 'FirstSpaceLast', 'Company', 'LastCommaFirstCompany', 'CompanyLastFirst',
            'LastFirst', 'LastFirstCompany', 'CompanyLastCommaFirst', 'LastFirstSuffix', 'LastSpaceFirstCompany',
            'CompanyLastSpaceFirst', 'LastSpaceFirst', 'DisplayName', 'FirstName', 'LastFirstMiddleSuffix', 'LastName',
            'Empty',
        }
    }
    # TODO: This list is incomplete
    ITEM_FIELDS = {
        'file_as': ('FileAs', str),
        'file_as_mapping': ('FileAsMapping', Choice),
        'display_name': ('DisplayName', str),
        'given_name': ('GivenName', str),
        'initials': ('Initials', str),
        'middle_name': ('MiddleName', str),
        'nickname': ('Nickname', str),
        'company_name': ('CompanyName', str),
        'email_addresses': (EmailAddress, [EmailAddress]),
        # 'physical_addresses': (PhysicalAddress, [PhysicalAddress]),
        'phone_numbers': (PhoneNumber, [PhoneNumber]),
        'assistant_name': ('AssistantName', str),
        'birthday': ('Birthday', EWSDateTime),
        'business_homepage': ('BusinessHomePage', str),
        'companies': ('Companies', [str]),
        'department': ('Department', str),
        'generation': ('Generation', str),
        # 'im_addresses': ('ImAddresses', [ImAddress]),
        'job_title': ('JobTitle', str),
        'manager': ('Manager', str),
        'mileage': ('Mileage', str),
        'office': ('OfficeLocation', str),
        'profession': ('Profession', str),
        'surname': ('Surname', str),
        # 'email_alias': ('Alias', Email),
        # 'notes': ('Notes', str),  # Only available from Exchange 2010 SP2
    }
    REQUIRED_FIELDS = {'display_name'}
    ORDERED_FIELDS = (
        'subject', 'sensitivity', 'body', 'categories', 'importance', 'reminder_is_set', 'extern_id',
        'file_as', 'file_as_mapping',
        'display_name', 'given_name',  'initials', 'middle_name', 'nickname', 'company_name',
        'email_addresses',  # 'physical_addresses',
        'phone_numbers',
        'assistant_name', 'birthday', 'business_homepage', 'companies', 'department',
        'generation', 'job_title', 'manager', 'mileage', 'office', 'profession', 'surname',  # 'email_alias', 'notes',
    )

    __slots__ = tuple(ITEM_FIELDS) + tuple(Item.ITEM_FIELDS) + tuple(Item.EXTRA_ITEM_FIELDS)

    def __init__(self, **kwargs):
        for k in self.ITEM_FIELDS:
            field_type = self.ITEM_FIELDS[k][1]
            default = False if (k in self.required_fields() and field_type == bool) else None
            v = kwargs.pop(k, default)
            if field_type == Choice:
                assert v is None or v in self.choices_for_field(k), (v, self.choices_for_field(k))
            setattr(self, k, v)
        super().__init__(**kwargs)


class Contacts(Folder):
    DISTINGUISHED_FOLDER_ID = 'contacts'
    CONTAINER_CLASS = 'IPF.Contact'
    item_model = Contact

    # These must be capitalized
    LOCALIZED_NAMES = {
        'da_DK': ('Kontaktpersoner',)
    }


class GenericFolder(Folder):
    pass


class WellknownFolder(Folder):
    # Use this class until we have specific folder implementations
    pass


# See http://msdn.microsoft.com/en-us/library/microsoft.exchange.webservices.data.wellknownfoldername(v=exchg.80).aspx
WELLKNOWN_FOLDERS = dict([
    ('Calendar', Calendar),
    ('Contacts', Contacts),
    ('DeletedItems', Messages),
    ('Drafts', Messages),
    ('Inbox', Messages),
    ('Journal', WellknownFolder),
    ('Notes', WellknownFolder),
    ('Outbox', Messages),
    ('SentItems', Messages),
    ('Tasks', Tasks),
    ('MsgFolderRoot', WellknownFolder),
    ('PublicFoldersRoot', WellknownFolder),
    ('Root', Root),
    ('JunkEmail', Messages),
    ('Search', WellknownFolder),
    ('VoiceMail', WellknownFolder),
    ('RecoverableItemsRoot', WellknownFolder),
    ('RecoverableItemsDeletions', WellknownFolder),
    ('RecoverableItemsVersions', WellknownFolder),
    ('RecoverableItemsPurges', WellknownFolder),
    ('ArchiveRoot', WellknownFolder),
    ('ArchiveMsgFolderRoot', WellknownFolder),
    ('ArchiveDeletedItems', WellknownFolder),
    ('ArchiveRecoverableItemsRoot', Folder),
    ('ArchiveRecoverableItemsDeletions', WellknownFolder),
    ('ArchiveRecoverableItemsVersions', WellknownFolder),
    ('ArchiveRecoverableItemsPurges', WellknownFolder),
    ('SyncIssues', WellknownFolder),
    ('Conflicts', WellknownFolder),
    ('LocalFailures', WellknownFolder),
    ('ServerFailures', WellknownFolder),
    ('RecipientCache', WellknownFolder),
    ('QuickContacts', WellknownFolder),
    ('ConversationHistory', WellknownFolder),
    ('ToDoSearch', WellknownFolder),
    ('', GenericFolder),
])

FOLDER_CLASS_MAP = dict()
for folder_model in WELLKNOWN_FOLDERS.values():
    if folder_model.CONTAINER_CLASS:
        FOLDER_CLASS_MAP[folder_model.CONTAINER_CLASS] = folder_model
