# -*- test-case-name: twext.enterprise.dal.test.test_record -*-
##
# Copyright (c) 2015-2017 Apple Inc. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
##

from collections import namedtuple
from pycalendar.datetime import DateTime

from twext.enterprise.dal.syntax import Insert, Parameter, Update, Delete, \
    Select
from twext.python.clsprop import classproperty
from twext.python.log import Logger

from twisted.internet.defer import inlineCallbacks, returnValue, succeed

from txdav.base.propertystore.base import PropertyName
from txdav.common.datastore.sql_tables import _BIND_MODE_OWN, _BIND_MODE_DIRECT, \
    _BIND_MODE_INDIRECT, _BIND_STATUS_ACCEPTED, _BIND_STATUS_DECLINED, \
    _BIND_STATUS_INVITED, _BIND_STATUS_INVALID, _BIND_STATUS_DELETED, \
    _HOME_STATUS_EXTERNAL, _HOME_STATUS_DISABLED
from txdav.common.icommondatastore import ExternalShareFailed, \
    HomeChildNameAlreadyExistsError, AllRetriesFailed
from txdav.idav import ChangeCategory
from txdav.xml import element

from uuid import uuid4


log = Logger()

"""
Classes and methods that relate to sharing in the SQL store.
"""


class SharingHomeMixIn(object):
    """
    Common class for CommonHome to implement sharing operations
    """

    @inlineCallbacks
    def acceptShare(self, shareUID, summary=None):
        """
        This share is being accepted.
        """

        shareeView = yield self.anyObjectWithShareUID(shareUID)
        if shareeView is not None:
            yield shareeView.acceptShare(summary)

        returnValue(shareeView)

    @inlineCallbacks
    def declineShare(self, shareUID):
        """
        This share is being declined.
        """

        shareeView = yield self.anyObjectWithShareUID(shareUID)
        if shareeView is not None:
            yield shareeView.declineShare()

        returnValue(shareeView is not None)

    #
    # External (cross-pod) sharing - entry point is the sharee's home collection.
    #
    @inlineCallbacks
    def processExternalInvite(
        self, ownerUID, ownerName, shareUID, bindMode, bindUID, summary,
        copy_invite_properties, supported_components=None
    ):
        """
        External invite received.
        """

        # Get the owner home - create external one if not present
        ownerHome = yield self._txn.homeWithUID(
            self._homeType, ownerUID, status=_HOME_STATUS_EXTERNAL, create=True
        )
        if ownerHome is None or not ownerHome.external():
            raise ExternalShareFailed("Invalid owner UID: {}".format(ownerUID))

        # Try to find owner calendar via its external id
        ownerView = yield ownerHome.childWithBindUID(bindUID)
        if ownerView is None:
            ownerView = yield ownerHome.createCollectionForExternalShare(ownerName, bindUID, supported_components)

        # Now carry out the share operation
        if bindMode == _BIND_MODE_DIRECT:
            shareeView = yield ownerView.directShareWithUser(
                self.uid(), shareName=shareUID
            )
        else:
            shareeView = yield ownerView.inviteUIDToShare(
                self.uid(), bindMode, summary, shareName=shareUID
            )

        shareeView.setInviteCopyProperties(copy_invite_properties)

    @inlineCallbacks
    def processExternalUninvite(self, ownerUID, bindUID, shareUID):
        """
        External invite received.
        """

        # Get the owner home
        ownerHome = yield self._txn.homeWithUID(self._homeType, ownerUID, status=_HOME_STATUS_EXTERNAL)
        if ownerHome is None or not ownerHome.external():
            raise ExternalShareFailed("Invalid owner UID: {}".format(ownerUID))

        # Try to find owner calendar via its external id
        ownerView = yield ownerHome.childWithBindUID(bindUID)
        if ownerView is None:
            raise ExternalShareFailed("Invalid share ID: {}".format(shareUID))

        # Now carry out the share operation
        yield ownerView.uninviteUIDFromShare(self.uid())

        # See if there are any references to the external share. If not,
        # remove it
        yield ownerView.cleanExternalShare()

    @inlineCallbacks
    def processExternalReply(
        self, ownerUID, shareeUID, shareUID, bindStatus, summary=None
    ):
        """
        External invite received.
        """

        # Make sure the shareeUID and shareUID match

        # Get the owner home - create external one if not present
        shareeHome = yield self._txn.homeWithUID(self._homeType, shareeUID, status=_HOME_STATUS_EXTERNAL)
        if shareeHome is None or not shareeHome.external():
            raise ExternalShareFailed(
                "Invalid sharee UID: {}".format(shareeUID)
            )

        # Try to find owner calendar via its external id
        shareeView = yield shareeHome.anyObjectWithShareUID(shareUID)
        if shareeView is None:
            raise ExternalShareFailed("Invalid share UID: {}".format(shareUID))

        # Now carry out the share operation
        if bindStatus == _BIND_STATUS_ACCEPTED:
            yield shareeHome.acceptShare(shareUID, summary)
        elif bindStatus == _BIND_STATUS_DECLINED:
            if shareeView.direct():
                yield shareeView.deleteShare()
            else:
                yield shareeHome.declineShare(shareUID)

    @inlineCallbacks
    def createCollectionForExternalShare(self, name, bindUID, supported_components):
        """
        Create the L{CommonHomeChild} object that used as a "stub" to represent the external
        object on the other pod for the sharer.

        @param name: name of the collection
        @type name: L{str}
        @param bindUID: id on other pod
        @type bindUID: L{str}
        @param supported_components: optional set of support components
        @type supported_components: L{str}
        """
        try:
            ownerView = yield self.createChildWithName(
                name, bindUID=bindUID
            )
        except HomeChildNameAlreadyExistsError:
            # This is odd - it means we possibly have a left over sharer
            # collection which the sharer likely removed and re-created
            # with the same name but now it has a different bindUID and
            # is not found by the initial query. What we do is check to see
            # whether any shares still reference the old ID - if they do we
            # are hosed. If not, we can remove the old item and create a new one.
            oldOwnerView = yield self.childWithName(name)
            invites = yield oldOwnerView.sharingInvites()
            if len(invites) != 0:
                log.error(
                    "External invite collection name is present with a "
                    "different bindUID and still has shares"
                )
                raise
            log.error(
                "External invite collection name is present with a "
                "different bindUID - trying to fix"
            )
            yield self.removeExternalChild(oldOwnerView)
            ownerView = yield self.createChildWithName(
                name, bindUID=bindUID
            )

        if (
            supported_components is not None and
            hasattr(ownerView, "setSupportedComponents")
        ):
            yield ownerView.setSupportedComponents(supported_components)

        returnValue(ownerView)

    @inlineCallbacks
    def sharedToBindRecords(self):
        """
        Return an L{dict} that maps home/directory uid to a sharing bind record for collections shared to this user.
        """

        # Get shared to bind records
        records = yield self._childClass._bindRecordClass.query(
            self._txn,
            (getattr(self._childClass._bindRecordClass, self._childClass._bindHomeIDAttributeName) == self.id()).And(
                self._childClass._bindRecordClass.bindMode != _BIND_MODE_OWN
            )
        )
        records = dict([(getattr(record, self._childClass._bindResourceIDAttributeName), record) for record in records])
        if not records:
            returnValue({})

        # Look up the owner records for each of the shared to records
        ownerRecords = yield self._childClass._bindRecordClass.query(
            self._txn,
            (getattr(self._childClass._bindRecordClass, self._childClass._bindResourceIDAttributeName).In(records.keys())).And(
                self._childClass._bindRecordClass.bindMode == _BIND_MODE_OWN
            )
        )

        # Important - this method is called when migrating shared-to records to some other pod. For that to work all the
        # owner records must have a bindUID assigned to them. Normally bindUIDs are assigned the first time an external
        # share is created, but migration will implicitly create the external share
        for ownerRecord in ownerRecords:
            if not ownerRecord.bindUID:
                yield ownerRecord.update(bindUID=str(uuid4()))

        ownerRecords = dict([(getattr(record, self._childClass._bindResourceIDAttributeName), record) for record in ownerRecords])

        # Look up the metadata records for each of the shared to records
        metadataRecords = yield self._childClass._metadataRecordClass.query(
            self._txn,
            self._childClass._metadataRecordClass.resourceID.In(records.keys()),
        )
        metadataRecords = dict([(record.resourceID, record) for record in metadataRecords])

        # Map the owner records to home ownerUIDs
        homeIDs = dict([(
            getattr(record, self._childClass._bindHomeIDAttributeName), getattr(record, self._childClass._bindResourceIDAttributeName)
        ) for record in ownerRecords.values()])
        homes = yield self._childClass._homeRecordClass.query(
            self._txn,
            self._childClass._homeRecordClass.resourceID.In(homeIDs.keys()),
        )
        homeMap = dict((homeIDs[home.resourceID], home.ownerUID,) for home in homes)

        returnValue(dict([(homeMap[calendarID], (records[calendarID], ownerRecords[calendarID], metadataRecords[calendarID],),) for calendarID in records]))


SharingInvitation = namedtuple(
    "SharingInvitation",
    ["uid", "ownerUID", "ownerHomeID", "shareeUID", "shareeHomeID", "shareeHomeStatus", "mode", "status", "summary"]
)


class SharingMixIn(object):
    """
    Common class for CommonHomeChild and AddressBookObject
    """

    @classproperty
    def _bindInsertQuery(cls, **kw):
        """
        DAL statement to create a bind entry that connects a collection to its
        home.
        """
        bind = cls._bindSchema
        return Insert({
            bind.HOME_RESOURCE_ID: Parameter("homeID"),
            bind.RESOURCE_ID: Parameter("resourceID"),
            bind.RESOURCE_NAME: Parameter("name"),
            bind.BIND_MODE: Parameter("mode"),
            bind.BIND_STATUS: Parameter("bindStatus"),
            bind.BIND_UID: Parameter("bindUID"),
            bind.MESSAGE: Parameter("message"),
        })

    @classmethod
    def _updateBindColumnsQuery(cls, columnMap):
        bind = cls._bindSchema
        return Update(
            columnMap,
            Where=(bind.RESOURCE_ID == Parameter("resourceID")).And(
                bind.HOME_RESOURCE_ID == Parameter("homeID")),
        )

    @classproperty
    def _deleteBindForResourceIDAndHomeID(cls):
        bind = cls._bindSchema
        return Delete(
            From=bind,
            Where=(bind.RESOURCE_ID == Parameter("resourceID")).And(
                bind.HOME_RESOURCE_ID == Parameter("homeID")),
        )

    @classmethod
    def _bindFor(cls, condition):
        bind = cls._bindSchema
        columns = cls.bindColumns() + cls.additionalBindColumns()
        return Select(
            columns,
            From=bind,
            Where=condition
        )

    @classmethod
    def _bindInviteFor(cls, condition):
        home = cls._homeSchema
        bind = cls._bindSchema
        return Select(
            [
                home.OWNER_UID,
                bind.HOME_RESOURCE_ID,
                home.STATUS,
                bind.RESOURCE_ID,
                bind.RESOURCE_NAME,
                bind.BIND_MODE,
                bind.BIND_STATUS,
                bind.MESSAGE,
            ],
            From=bind.join(home, on=(bind.HOME_RESOURCE_ID == home.RESOURCE_ID)),
            Where=condition
        )

    @classproperty
    def _sharedInvitationBindForResourceID(cls):
        bind = cls._bindSchema
        return cls._bindInviteFor(
            (bind.RESOURCE_ID == Parameter("resourceID")).And
            (bind.BIND_MODE != _BIND_MODE_OWN)
        )

    @classproperty
    def _acceptedBindForHomeID(cls):
        bind = cls._bindSchema
        return cls._bindFor((bind.HOME_RESOURCE_ID == Parameter("homeID"))
                            .And(bind.BIND_STATUS == _BIND_STATUS_ACCEPTED))

    @classproperty
    def _bindForResourceIDAndHomeID(cls):
        """
        DAL query that looks up home bind rows by home child
        resource ID and home resource ID.
        """
        bind = cls._bindSchema
        return cls._bindFor((bind.RESOURCE_ID == Parameter("resourceID"))
                            .And(bind.HOME_RESOURCE_ID == Parameter("homeID")))

    @classproperty
    def _bindForBindUIDAndHomeID(cls):
        """
        DAL query that looks up home bind rows by home child
        resource ID and home resource ID.
        """
        bind = cls._bindSchema
        return cls._bindFor((bind.BIND_UID == Parameter("bindUID"))
                            .And(bind.HOME_RESOURCE_ID == Parameter("homeID")))

    @classproperty
    def _bindForNameAndHomeID(cls):
        """
        DAL query that looks up any bind rows by home child
        resource ID and home resource ID.
        """
        bind = cls._bindSchema
        return cls._bindFor((bind.RESOURCE_NAME == Parameter("name"))
                            .And(bind.HOME_RESOURCE_ID == Parameter("homeID")))

    #
    # Higher level API
    #
    @inlineCallbacks
    def inviteUIDToShare(self, shareeUID, mode, summary=None, shareName=None):
        """
        Invite a user to share this collection - either create the share if it does not exist, or
        update the existing share with new values. Make sure a notification is sent as well.

        @param shareeUID: UID of the sharee
        @type shareeUID: C{str}
        @param mode: access mode
        @type mode: C{int}
        @param summary: share message
        @type summary: C{str}
        """

        # Look for existing invite and update its fields or create new one
        shareeView = yield self.shareeView(shareeUID)
        if shareeView is not None:
            status = _BIND_STATUS_INVITED if shareeView.shareStatus() in (_BIND_STATUS_DECLINED, _BIND_STATUS_INVALID) else None
            yield self.updateShare(shareeView, mode=mode, status=status, summary=summary)
        else:
            shareeView = yield self.createShare(shareeUID=shareeUID, mode=mode, summary=summary, shareName=shareName)

        # Check for external
        if shareeView.viewerHome().external():
            yield self._sendExternalInvite(shareeView)
        else:
            # Send invite notification
            yield self._sendInviteNotification(shareeView)
        returnValue(shareeView)

    @inlineCallbacks
    def directShareWithUser(self, shareeUID, shareName=None, displayName=None):
        """
        Create a direct share with the specified user. Note it is currently up to the app layer
        to enforce access control - this is not ideal as we really should have control of that in
        the store. Once we do, this api will need to verify that access is allowed for a direct share.

        NB no invitations are used with direct sharing.

        @param shareeUID: UID of the sharee
        @type shareeUID: C{str}
        """

        # Ignore if it already exists
        shareeView = yield self.shareeView(shareeUID)
        if shareeView is None:
            shareeView = yield self.createShare(shareeUID=shareeUID, mode=_BIND_MODE_DIRECT, shareName=shareName)
            yield shareeView.newShare(displayname=displayName)

            # Check for external
            if shareeView.viewerHome().external():
                yield self._sendExternalInvite(shareeView)

        returnValue(shareeView)

    @inlineCallbacks
    def uninviteUIDFromShare(self, shareeUID):
        """
        Remove a user from a share. Make sure a notification is sent as well.

        @param shareeUID: UID of the sharee
        @type shareeUID: C{str}
        """
        # Cancel invites - we'll just use whatever userid we are given

        shareeView = yield self.shareeView(shareeUID)
        if shareeView is not None:
            if shareeView.viewerHome().external():
                try:
                    yield self._sendExternalUninvite(shareeView)
                except Exception as e:
                    # If the cross-pod request fails for some reason, ignore the exception and go ahead
                    # and remove the share on this pod. It is up to the other pod to "heal" itself
                    # by  detecting an invalid share when it is running properly again.
                    log.error("Could not send sharing uninvite '{userid}': {ex}", userid=shareeUID, ex=e)

            else:
                # If current user state is accepted then we send an invite with the new state, otherwise
                # we cancel any existing invites for the user. Also, if the ownerHome is disabled, we assume
                # that no sharing invites are sent.
                if not shareeView.direct() and shareeView.ownerHome().status() != _HOME_STATUS_DISABLED:
                    if shareeView.shareStatus() != _BIND_STATUS_ACCEPTED:
                        yield self._removeInviteNotification(shareeView)
                    else:
                        yield self._sendInviteNotification(shareeView, notificationState=_BIND_STATUS_DELETED)

            # Remove the bind
            yield self.removeShare(shareeView)

    @inlineCallbacks
    def acceptShare(self, summary=None):
        """
        This share is being accepted.
        """

        if not self.direct() and self.shareStatus() != _BIND_STATUS_ACCEPTED:
            if self.external():
                yield self._replyExternalInvite(_BIND_STATUS_ACCEPTED, summary)
            ownerView = yield self.ownerView()
            yield ownerView.updateShare(self, status=_BIND_STATUS_ACCEPTED)
            yield self.newShare(displayname=summary)
            if not ownerView.external():
                yield self._sendReplyNotification(ownerView, summary)

    @inlineCallbacks
    def declineShare(self):
        """
        This share is being declined.
        """

        if not self.direct() and self.shareStatus() != _BIND_STATUS_DECLINED:
            if self.external():
                yield self._replyExternalInvite(_BIND_STATUS_DECLINED)
            ownerView = yield self.ownerView()
            yield ownerView.updateShare(self, status=_BIND_STATUS_DECLINED)
            if not ownerView.external():
                yield self._sendReplyNotification(ownerView)

    @inlineCallbacks
    def deleteShare(self):
        """
        This share is being deleted (by the sharee) - either decline or remove (for direct shares).
        """

        ownerView = yield self.ownerView()
        if self.direct():
            yield ownerView.removeShare(self)
            if ownerView.external():
                yield self._replyExternalInvite(_BIND_STATUS_DECLINED)
        else:
            yield self.declineShare()

    @inlineCallbacks
    def ownerDeleteShare(self):
        """
        This share is being deleted (by the owner) - either decline or remove (for direct shares).
        """

        # Change status on store object
        yield self.setShared(False)

        # Remove all sharees (direct and invited)
        for invitation in (yield self.sharingInvites()):
            yield self.uninviteUIDFromShare(invitation.shareeUID)

    @inlineCallbacks
    def cleanExternalShare(self):
        """
        Called when an external share is removed. This method will remove the reference to
        the external shared calendar when there are no more sharees on this pod.
        """

        # See if there are any references to the external share. If not,
        # remove it
        invites = yield self.sharingInvites()
        if len(invites) == 0:
            yield self._home.removeExternalChild(self)

    def newShare(self, displayname=None):
        """
        Override in derived classes to do any specific operations needed when a share
        is first accepted.
        """
        return succeed(None)

    @inlineCallbacks
    def notifyExternalShare(self, category=ChangeCategory.default):
        """
        Send a cross-pod message to all external shares to ensure change notifications are sent
        to external sharees' clients subscribed to notifications on the other pods.
        """

        # We need to resolve the ownerView which may be this object, or if this is
        # a shared object, may be something else. The ownerView is the one that
        # knows about all the sharing invites and the bindUID (which is the identifier
        # used to identify it on another pod).
        if not self.owned():
            ownerView = yield self.ownerView()
        else:
            ownerView = self

        # Get all invites and figure out if any are external
        externalUIDs = set()
        for invitation in (yield ownerView.sharingInvites()):
            if invitation.shareeHomeStatus == _HOME_STATUS_EXTERNAL:
                externalUIDs.add(invitation.shareeUID)

        if externalUIDs and ownerView.bindUID() is not None:
            yield self._txn.store().conduit.send_sharenotification(
                self._txn,
                self.ownerHome()._homeType,
                self.ownerHome().uid(),
                ownerView.bindUID(),
                externalUIDs,
                category,
            )

    @inlineCallbacks
    def allInvitations(self):
        """
        Get list of all invitations (non-direct) to this object.
        """
        invitations = yield self.sharingInvites()

        # remove direct shares as those are not "real" invitations
        invitations = filter(lambda x: x.mode != _BIND_MODE_DIRECT, invitations)
        invitations.sort(key=lambda invitation: invitation.shareeUID)
        returnValue(invitations)

    @inlineCallbacks
    def _sendInviteNotification(self, shareeView, notificationState=None):
        """
        Called on the owner's resource.
        """
        # When deleting the message is the sharee's display name
        displayname = shareeView.shareMessage()
        if notificationState == _BIND_STATUS_DELETED:
            displayname = str(shareeView.properties().get(PropertyName.fromElement(element.DisplayName), displayname))

        notificationtype = {
            "notification-type": "invite-notification",
            "shared-type": shareeView.sharedResourceType(),
        }
        notificationdata = {
            "notification-type": "invite-notification",
            "shared-type": shareeView.sharedResourceType(),
            "dtstamp": DateTime.getNowUTC().getText(),
            "owner": shareeView.ownerHome().uid(),
            "sharee": shareeView.viewerHome().uid(),
            "uid": shareeView.shareUID(),
            "status": shareeView.shareStatus() if notificationState is None else notificationState,
            "access": (yield shareeView.effectiveShareMode()),
            "ownerName": self.shareName(),
            "summary": displayname,
        }
        if hasattr(self, "getSupportedComponents"):
            notificationdata["supported-components"] = self.getSupportedComponents()

        # Add to sharee's collection
        notifications = yield self._txn.notificationsWithUID(shareeView.viewerHome().uid(), create=True)
        yield notifications.writeNotificationObject(shareeView.shareUID(), notificationtype, notificationdata)

    @inlineCallbacks
    def _sendReplyNotification(self, ownerView, summary=None):
        """
        Create a reply notification based on the current state of this shared resource.
        """

        # Generate invite XML
        notificationUID = "%s-reply" % (self.shareUID(),)

        notificationtype = {
            "notification-type": "invite-reply",
            "shared-type": self.sharedResourceType(),
        }

        notificationdata = {
            "notification-type": "invite-reply",
            "shared-type": self.sharedResourceType(),
            "dtstamp": DateTime.getNowUTC().getText(),
            "owner": self.ownerHome().uid(),
            "sharee": self.viewerHome().uid(),
            "status": self.shareStatus(),
            "ownerName": ownerView.shareName(),
            "in-reply-to": self.shareUID(),
            "summary": summary,
        }

        # Add to owner notification collection
        notifications = yield self._txn.notificationsWithUID(self.ownerHome().uid(), create=True)
        yield notifications.writeNotificationObject(notificationUID, notificationtype, notificationdata)

    @inlineCallbacks
    def _removeInviteNotification(self, shareeView):
        """
        Called on the owner's resource.
        """

        # Remove from sharee's collection
        notifications = yield self._txn.notificationsWithUID(shareeView.viewerHome().uid())
        yield notifications.removeNotificationObjectWithUID(shareeView.shareUID())

    #
    # External/cross-pod API
    #
    @inlineCallbacks
    def _sendExternalInvite(self, shareeView):

        # Must make sure this collection has a BIND_UID assigned
        if not self._bindUID:
            self._bindUID = str(uuid4())
            yield self._updateBindColumnsQuery({self._bindSchema.BIND_UID: self._bindUID}).on(
                self._txn,
                resourceID=self.id(), homeID=self.ownerHome().id()
            )

        # Now send the invite
        yield self._txn.store().conduit.send_shareinvite(
            self._txn,
            shareeView.ownerHome()._homeType,
            shareeView.ownerHome().uid(),
            self.shareName(),
            shareeView.viewerHome().uid(),
            shareeView.shareUID(),
            shareeView.shareMode(),
            self.bindUID(),
            shareeView.shareMessage(),
            self.getInviteCopyProperties(),
            supported_components=self.getSupportedComponents() if hasattr(self, "getSupportedComponents") else None,
        )

    @inlineCallbacks
    def _sendExternalUninvite(self, shareeView):

        yield self._txn.store().conduit.send_shareuninvite(
            self._txn,
            shareeView.ownerHome()._homeType,
            shareeView.ownerHome().uid(),
            self.bindUID(),
            shareeView.viewerHome().uid(),
            shareeView.shareUID(),
        )

    @inlineCallbacks
    def _replyExternalInvite(self, status, summary=None):

        # If a reply to an external share fails, then assume the external share
        # is broken and remove it from the local pod
        try:
            yield self._txn.store().conduit.send_sharereply(
                self._txn,
                self.viewerHome()._homeType,
                self.ownerHome().uid(),
                self.viewerHome().uid(),
                self.shareUID(),
                status,
                summary,
            )
        except ExternalShareFailed:
            yield self.fixNonExistentExternalShare()
            raise ExternalShareFailed("External share does not exist")

    #
    # Lower level API
    #
    @inlineCallbacks
    def ownerView(self):
        """
        Return the owner resource counterpart of this shared resource.

        Note we have to play a trick with the property store to coerce it to match
        the per-user properties for the owner.
        """
        # Get the child of the owner home that has the same resource id as the owned one
        ownerView = yield self.ownerHome().childWithID(self.id())
        returnValue(ownerView)

    @inlineCallbacks
    def shareeView(self, shareeUID):
        """
        Return the shared resource counterpart of this owned resource for the specified sharee.

        Note we have to play a trick with the property store to coerce it to match
        the per-user properties for the sharee.
        """

        # Never return the owner's own resource
        if self._home.uid() == shareeUID:
            returnValue(None)

        # Get the child of the sharee home that has the same resource id as the owned one
        shareeHome = yield self._txn.homeWithUID(self._home._homeType, shareeUID, authzUID=shareeUID)
        shareeView = (yield shareeHome.allChildWithID(self.id())) if shareeHome is not None else None
        returnValue(shareeView)

    @inlineCallbacks
    def shareWithUID(self, shareeUID, mode, status=None, summary=None, shareName=None):
        """
        Share this (owned) L{CommonHomeChild} with another principal.

        @param shareeUID: The UID of the sharee.
        @type: L{str}

        @param mode: The sharing mode; L{_BIND_MODE_READ} or
            L{_BIND_MODE_WRITE} or L{_BIND_MODE_DIRECT}
        @type mode: L{str}

        @param status: The sharing status; L{_BIND_STATUS_INVITED} or
            L{_BIND_STATUS_ACCEPTED}
        @type: L{str}

        @param summary: The proposed message to go along with the share, which
            will be used as the default display name.
        @type: L{str}

        @return: the name of the shared calendar in the new calendar home.
        @rtype: L{str}
        """
        shareeHome = yield self._txn.homeWithUID(self._home._homeType, shareeUID, create=True)
        returnValue(
            (yield self.shareWith(shareeHome, mode, status, summary, shareName))
        )

    @inlineCallbacks
    def shareWith(self, shareeHome, mode, status=None, summary=None, shareName=None):
        """
        Share this (owned) L{CommonHomeChild} with another home.

        @param shareeHome: The home of the sharee.
        @type: L{CommonHome}

        @param mode: The sharing mode; L{_BIND_MODE_READ} or
            L{_BIND_MODE_WRITE} or L{_BIND_MODE_DIRECT}
        @type: L{str}

        @param status: The sharing status; L{_BIND_STATUS_INVITED} or
            L{_BIND_STATUS_ACCEPTED}
        @type: L{str}

        @param summary: The proposed message to go along with the share, which
            will be used as the default display name.
        @type: L{str}

        @param shareName: The proposed name of the new share.
        @type: L{str}

        @return: the name of the shared calendar in the new calendar home.
        @rtype: L{str}
        """

        if status is None:
            status = _BIND_STATUS_ACCEPTED

        @inlineCallbacks
        def doInsert(subt):
            newName = shareName if shareName is not None else self.newShareName()
            yield self._bindInsertQuery.on(
                subt,
                homeID=shareeHome._resourceID,
                resourceID=self._resourceID,
                name=newName,
                mode=mode,
                bindStatus=status,
                bindUID=None,
                message=summary
            )
            returnValue(newName)
        try:
            bindName = yield self._txn.subtransaction(doInsert)
        except AllRetriesFailed:
            # FIXME: catch more specific exception
            child = yield shareeHome.allChildWithID(self._resourceID)
            yield self.updateShare(
                child, mode=mode, status=status,
                summary=summary
            )
            bindName = child._name
        else:
            if status == _BIND_STATUS_ACCEPTED:
                shareeView = yield shareeHome.anyObjectWithShareUID(bindName)
                yield shareeView._initSyncToken()
                yield shareeView._initBindRevision()

        # Mark this as shared
        yield self.setShared(True)

        # Must send notification to ensure cache invalidation occurs
        yield self.notifyPropertyChanged()
        yield shareeHome.notifyChanged()

        returnValue(bindName)

    @inlineCallbacks
    def createShare(self, shareeUID, mode, summary=None, shareName=None):
        """
        Create a new shared resource. If the mode is direct, the share is created in accepted state,
        otherwise the share is created in invited state.
        """
        shareeHome = yield self._txn.homeWithUID(self.ownerHome()._homeType, shareeUID, create=True)

        yield self.shareWith(
            shareeHome,
            mode=mode,
            status=_BIND_STATUS_INVITED if mode != _BIND_MODE_DIRECT else _BIND_STATUS_ACCEPTED,
            summary=summary,
            shareName=shareName,
        )
        shareeView = yield self.shareeView(shareeUID)
        returnValue(shareeView)

    @inlineCallbacks
    def updateShare(self, shareeView, mode=None, status=None, summary=None):
        """
        Update share mode, status, and message for a home child shared with
        this (owned) L{CommonHomeChild}.

        @param shareeView: The sharee home child that shares this.
        @type shareeView: L{CommonHomeChild}

        @param mode: The sharing mode; L{_BIND_MODE_READ} or
            L{_BIND_MODE_WRITE} or None to not update
        @type mode: L{str}

        @param status: The sharing status; L{_BIND_STATUS_INVITED} or
            L{_BIND_STATUS_ACCEPTED} or L{_BIND_STATUS_DECLINED} or
            L{_BIND_STATUS_INVALID}  or None to not update
        @type status: L{str}

        @param summary: The proposed message to go along with the share, which
            will be used as the default display name, or None to not update
        @type summary: L{str}
        """
        # TODO: raise a nice exception if shareeView is not, in fact, a shared
        # version of this same L{CommonHomeChild}

        # remove None parameters, and substitute None for empty string
        bind = self._bindSchema
        columnMap = {}
        if mode != None and mode != shareeView._bindMode:
            columnMap[bind.BIND_MODE] = mode
        if status != None and status != shareeView._bindStatus:
            columnMap[bind.BIND_STATUS] = status
        if summary != None and summary != shareeView._bindMessage:
            columnMap[bind.MESSAGE] = summary

        if columnMap:

            # Count accepted
            if bind.BIND_STATUS in columnMap:
                previouslyAcceptedCount = yield shareeView._previousAcceptCount()

            yield self._updateBindColumnsQuery(columnMap).on(
                self._txn,
                resourceID=self._resourceID, homeID=shareeView._home._resourceID
            )

            # Update affected attributes
            if bind.BIND_MODE in columnMap:
                shareeView._bindMode = columnMap[bind.BIND_MODE]

            if bind.BIND_STATUS in columnMap:
                shareeView._bindStatus = columnMap[bind.BIND_STATUS]
                yield shareeView._changedStatus(previouslyAcceptedCount)

            if bind.MESSAGE in columnMap:
                shareeView._bindMessage = columnMap[bind.MESSAGE]

            yield shareeView.invalidateQueryCache()

            # Must send notification to ensure cache invalidation occurs
            yield self.notifyPropertyChanged()
            yield shareeView.viewerHome().notifyChanged()

    def _previousAcceptCount(self):
        return succeed(1)

    @inlineCallbacks
    def _changedStatus(self, previouslyAcceptedCount):
        key = self._home._childrenKey(self.isInTrash())
        if self._bindStatus == _BIND_STATUS_ACCEPTED:
            yield self._initSyncToken()
            yield self._initBindRevision()
            self._home._children[key][self.name()] = self
            self._home._children[key][self.id()] = self
        elif self._bindStatus in (_BIND_STATUS_INVITED, _BIND_STATUS_DECLINED):
            yield self._deletedSyncToken(sharedRemoval=True)
            self._home._children[key].pop(self.name(), None)
            self._home._children[key].pop(self.id(), None)

    @inlineCallbacks
    def removeShare(self, shareeView):
        """
        Remove the shared version of this (owned) L{CommonHomeChild} from the
        referenced L{CommonHome}.

        @see: L{CommonHomeChild.shareWith}

        @param shareeView: The shared resource being removed.

        @return: a L{Deferred} which will fire with the previous shareUID
        """
        key = self._home._childrenKey(self.isInTrash())

        # remove sync tokens
        shareeHome = shareeView.viewerHome()
        yield shareeView._deletedSyncToken(sharedRemoval=True)
        shareeHome._children[key].pop(shareeView._name, None)
        shareeHome._children[key].pop(shareeView._resourceID, None)

        # Must send notification to ensure cache invalidation occurs
        yield self.notifyPropertyChanged()
        yield shareeHome.notifyChanged()

        # delete binds including invites
        yield self._deleteBindForResourceIDAndHomeID.on(
            self._txn,
            resourceID=self._resourceID,
            homeID=shareeHome._resourceID,
        )

        yield shareeView.invalidateQueryCache()

    @inlineCallbacks
    def unshare(self):
        """
        Unshares a collection, regardless of which "direction" it was shared.
        """
        if self.owned():
            # This collection may be shared to others
            invites = yield self.sharingInvites()
            for invite in invites:
                shareeView = yield self.shareeView(invite.shareeUID)
                yield self.removeShare(shareeView)
        else:
            # This collection is shared to me
            ownerView = yield self.ownerView()
            yield ownerView.removeShare(self)

    @inlineCallbacks
    def sharingInvites(self):
        """
        Retrieve the list of all L{SharingInvitation}'s for this L{CommonHomeChild}, irrespective of mode.

        @return: L{SharingInvitation} objects
        @rtype: a L{Deferred} which fires with a L{list} of L{SharingInvitation}s.
        """
        if not self.owned():
            returnValue([])

        # get all accepted binds
        invitedRows = yield self._sharedInvitationBindForResourceID.on(
            self._txn, resourceID=self._resourceID, homeID=self._home._resourceID
        )

        result = []
        for homeUID, homeRID, homeStatus, _ignore_resourceID, resourceName, bindMode, bindStatus, bindMessage in invitedRows:
            invite = SharingInvitation(
                resourceName,
                self.ownerHome().name(),
                self.ownerHome().id(),
                homeUID,
                homeRID,
                homeStatus,
                bindMode,
                bindStatus,
                bindMessage,
            )
            result.append(invite)
        returnValue(result)

    @inlineCallbacks
    def sharingBindRecords(self):
        """
        Return an L{dict} that maps home/directory uid to a sharing bind record.
        """
        if not self.owned():
            returnValue({})

        records = yield self._bindRecordClass.querysimple(
            self._txn,
            **{self._bindResourceIDAttributeName: self.id()}
        )
        homeIDs = [getattr(record, self._bindHomeIDAttributeName) for record in records]
        homes = yield self._homeRecordClass.query(
            self._txn,
            self._homeRecordClass.resourceID.In(homeIDs),
        )
        homeMap = dict((home.resourceID, home.ownerUID,) for home in homes)

        returnValue(dict([(homeMap[getattr(record, self._bindHomeIDAttributeName)], record,) for record in records if record.bindMode != _BIND_MODE_OWN]))

    def migrateBindRecords(self, bindUID):
        """
        The user that owns this collection is being migrated to another pod. We need to switch over
        the sharing details to point to the new external user.
        """
        if self.owned():
            return self.migrateSharedByRecords(bindUID)
        else:
            return self.migrateSharedToRecords()

    @inlineCallbacks
    def migrateSharedByRecords(self, bindUID):
        """
        The user that owns this collection is being migrated to another pod. We need to switch over
        the sharing details to point to the new external user. For sharees hosted on this pod, we
        update their bind record to point to a new external home/calendar for the sharer. For sharees
        hosted on other pods, we simply remove their bind entries.
        """

        # Get the external home and make sure there is a "fake" calendar associated with it
        home = yield self.externalHome()
        calendar = yield home.childWithBindUID(bindUID)
        if calendar is None:
            calendar = yield home.createCollectionForExternalShare(
                self.name(),
                bindUID,
                self.getSupportedComponents() if hasattr(self, "getSupportedComponents") else None,
            )

        remaining = False
        records = yield self._bindRecordClass.querysimple(self._txn, **{self._bindResourceIDAttributeName: self.id()})
        for record in records:
            if record.bindMode == _BIND_MODE_OWN:
                continue
            shareeHome = yield self._txn.homeWithResourceID(home._homeType, getattr(record, self._bindHomeIDAttributeName))
            if shareeHome.normal():
                remaining = True
                yield record.update(**{
                    self._bindResourceIDAttributeName: calendar.id(),
                })
            else:
                # It is OK to just delete (as opposed to doing a full "unshare") without adjusting other things
                # like sync revisions since those would not have been used for an external share anyway. Also,
                # revisions are tied to the calendar id and the original calendar will be removed after migration
                # is complete.
                yield record.delete()

        # If there are no external shares remaining, we can remove the external calendar
        if not remaining:
            yield calendar.remove()

    @inlineCallbacks
    def migrateSharedToRecords(self):
        """
        The user that owns this collection is being migrated to another pod. We need to switch over
        the sharing details to point to the new external user.
        """

        # Update the bind record for this calendar to point to the external home
        records = yield self._bindRecordClass.querysimple(
            self._txn,
            **{
                self._bindHomeIDAttributeName: self.viewerHome().id(),
                self._bindResourceIDAttributeName: self.id(),
            }
        )

        if len(records) == 1:

            # What we do depends on whether the sharer is local to this pod or not
            if self.ownerHome().normal():
                # Get the external home for the sharee
                home = yield self.externalHome()

                yield records[0].update(**{
                    self._bindHomeIDAttributeName: home.id(),
                })
            else:
                # It is OK to just delete (as opposed to doing a full "unshare") without adjusting other things
                # like sync revisions since those would not have been used for an external share anyway. Also,
                # revisions are tied to the sharee calendar home id and that will be removed after migration
                # is complete.
                yield records[0].delete()

                # Clean up external calendar if no sharees left
                calendar = yield self.ownerView()
                invites = yield calendar.sharingInvites()
                if len(invites) == 0:
                    yield calendar.remove()
        else:
            raise AssertionError("We must have a bind record for this calendar.")

    def externalHome(self):
        """
        Create and return an L{CommonHome} for the user being migrated. Note that when called, the user
        directory record may still indicate that they are hosted on this pod, so we have to forcibly create
        a home for the external user.
        """
        currentHome = self.viewerHome()
        return self._txn.homeWithUID(currentHome._homeType, currentHome.uid(), status=_HOME_STATUS_EXTERNAL, create=True)

    @inlineCallbacks
    def _initBindRevision(self):
        yield self.syncToken()  # init self._syncTokenRevision if None
        self._bindRevision = self._syncTokenRevision

        bind = self._bindSchema
        yield self._updateBindColumnsQuery(
            {bind.BIND_REVISION: Parameter("revision"), }
        ).on(
            self._txn,
            revision=self._bindRevision,
            resourceID=self._resourceID,
            homeID=self.viewerHome()._resourceID,
        )
        yield self.invalidateQueryCache()

    def sharedResourceType(self):
        """
        The sharing resource type. Needs to be overridden by each type of resource that can be shared.

        @return: an identifier for the type of the share.
        @rtype: C{str}
        """
        return ""

    def newShareName(self):
        """
        Name used when creating a new share. By default this is a UUID.
        """
        return str(uuid4())

    def owned(self):
        """
        @see: L{ICalendar.owned}
        """
        return self._bindMode == _BIND_MODE_OWN

    def isSharedByOwner(self):
        """
        For an owned collection indicate whether it is shared.

        @return: C{True} if shared, C{False} otherwise
        @rtype: C{bool}
        """
        return self.owned() and self._bindMessage == "shared"

    @inlineCallbacks
    def setShared(self, shared):
        """
        Set an owned collection to shared or unshared state. Technically this is not useful as "shared"
        really means it has invitees, but the current sharing spec supports a notion of a shared collection
        that has not yet had invitees added. For the time being we will support that option by using a new
        MESSAGE value to indicate an owned collection that is "shared".

        @param shared: whether or not the owned collection is "shared"
        @type shared: C{bool}
        """
        assert self.owned(), "Cannot change share mode on a shared collection"

        # Only if change is needed
        newMessage = "shared" if shared else None
        if self._bindMessage == newMessage:
            returnValue(None)

        self._bindMessage = newMessage

        bind = self._bindSchema
        yield Update(
            {bind.MESSAGE: self._bindMessage},
            Where=(bind.RESOURCE_ID == Parameter("resourceID")).And(
                bind.HOME_RESOURCE_ID == Parameter("homeID")),
        ).on(self._txn, resourceID=self._resourceID, homeID=self.viewerHome()._resourceID)

        yield self.invalidateQueryCache()
        yield self.notifyPropertyChanged()

    def direct(self):
        """
        Is this a "direct" share?

        @return: a boolean indicating whether it's direct.
        """
        return self._bindMode == _BIND_MODE_DIRECT

    def indirect(self):
        """
        Is this an "indirect" share?

        @return: a boolean indicating whether it's indirect.
        """
        return self._bindMode == _BIND_MODE_INDIRECT

    def shareUID(self):
        """
        @see: L{ICalendar.shareUID}
        """
        return self.name()

    def shareMode(self):
        """
        @see: L{ICalendar.shareMode}
        """
        return self._bindMode

    def _effectiveShareMode(self, bindMode, viewerUID, txn):
        """
        Get the effective share mode without a calendar object
        """
        return bindMode

    def effectiveShareMode(self):
        """
        @see: L{ICalendar.shareMode}
        """
        return self._bindMode

    def shareName(self):
        """
        This is a path like name for the resource within the home being shared. For object resource
        shares this will be a combination of the L{CommonHomeChild} name and the L{CommonObjecrResource}
        name. Otherwise it is just the L{CommonHomeChild} name. This is needed to expose a value to the
        app-layer such that it can construct a URI for the actual WebDAV resource being shared.
        """
        name = self.name()
        if self.sharedResourceType() == "group":
            name = self.parentCollection().name() + "/" + name
        return name

    def shareStatus(self):
        """
        @see: L{ICalendar.shareStatus}
        """
        return self._bindStatus

    def bindUID(self):
        """
        @see: L{ICalendar.bindUID}
        """
        return self._bindUID

    def accepted(self):
        """
        @see: L{ICalendar.shareStatus}
        """
        return self._bindStatus == _BIND_STATUS_ACCEPTED

    def shareMessage(self):
        """
        @see: L{ICalendar.shareMessage}
        """
        return self._bindMessage

    def getInviteCopyProperties(self):
        """
        Get a dictionary of property name/values (as strings) for properties that are shadowable and
        need to be copied to a sharee's collection when an external (cross-pod) share is created.
        Sub-classes should override to expose the properties they care about.
        """
        return {}

    def setInviteCopyProperties(self, props):
        """
        Copy a set of shadowable properties (as name/value strings) onto this shared resource when
        a cross-pod invite is processed. Sub-classes should override to expose the properties they
        care about.
        """
        pass

    @classmethod
    def metadataColumns(cls):
        """
        Return a list of column name for retrieval of metadata. This allows
        different child classes to have their own type specific data, but still make use of the
        common base logic.
        """

        # Common behavior is to have created and modified

        return (
            cls._homeChildMetaDataSchema.CREATED,
            cls._homeChildMetaDataSchema.MODIFIED,
        )

    @classmethod
    def metadataAttributes(cls):
        """
        Return a list of attribute names for retrieval of metadata. This allows
        different child classes to have their own type specific data, but still make use of the
        common base logic.
        """

        # Common behavior is to have created and modified

        return (
            "_created",
            "_modified",
        )

    @classmethod
    def bindColumns(cls):
        """
        Return a list of column names for retrieval during creation. This allows
        different child classes to have their own type specific data, but still make use of the
        common base logic.
        """

        return (
            cls._bindSchema.HOME_RESOURCE_ID,
            cls._bindSchema.RESOURCE_ID,
            cls._bindSchema.RESOURCE_NAME,
            cls._bindSchema.BIND_MODE,
            cls._bindSchema.BIND_STATUS,
            cls._bindSchema.BIND_REVISION,
            cls._bindSchema.BIND_UID,
            cls._bindSchema.MESSAGE
        )

    @classmethod
    def bindAttributes(cls):
        """
        Return a list of column names for retrieval during creation. This allows
        different child classes to have their own type specific data, but still make use of the
        common base logic.
        """

        return (
            "_homeResourceID",
            "_resourceID",
            "_name",
            "_bindMode",
            "_bindStatus",
            "_bindRevision",
            "_bindUID",
            "_bindMessage",
        )

    bindColumnCount = 8

    @classmethod
    def additionalBindColumns(cls):
        """
        Return a list of column names for retrieval during creation. This allows
        different child classes to have their own type specific data, but still make use of the
        common base logic.
        """

        return ()

    @classmethod
    def additionalBindAttributes(cls):
        """
        Return a list of attribute names for retrieval of during creation. This allows
        different child classes to have their own type specific data, but still make use of the
        common base logic.
        """

        return ()

    @classproperty
    def _childrenAndMetadataForHomeID(cls):
        bind = cls._bindSchema
        child = cls._homeChildSchema
        childMetaData = cls._homeChildMetaDataSchema

        columns = cls.bindColumns() + cls.additionalBindColumns() + cls.metadataColumns()
        return Select(
            columns,
            From=child.join(
                bind, child.RESOURCE_ID == bind.RESOURCE_ID,
                'left outer').join(
                    childMetaData, childMetaData.RESOURCE_ID == bind.RESOURCE_ID,
                    'left outer'),
            Where=(bind.HOME_RESOURCE_ID == Parameter("homeID")).And(
                bind.BIND_STATUS == _BIND_STATUS_ACCEPTED)
        )

    @inlineCallbacks
    def invalidateQueryCache(self):
        queryCacher = self._txn._queryCacher
        if queryCacher is not None:
            yield queryCacher.invalidateAfterCommit(self._txn, queryCacher.keyForHomeChildMetaData(self._resourceID))
            yield queryCacher.invalidateAfterCommit(self._txn, queryCacher.keyForObjectWithName(self._home._resourceID, self._name))
            yield queryCacher.invalidateAfterCommit(self._txn, queryCacher.keyForObjectWithResourceID(self._home._resourceID, self._resourceID))
            yield queryCacher.invalidateAfterCommit(self._txn, queryCacher.keyForObjectWithBindUID(self._home._resourceID, self._bindUID))
