##
# Copyright (c) 2009-2017 Apple Inc. All rights reserved.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
##

"""
DAV Property store using memcache on top of another property store
implementation.
"""

__all__ = ["MemcachePropertyCollection"]

try:
    from hashlib import md5
except ImportError:
    from md5 import new as md5

from twext.python.log import Logger
from twext.python.filepath import CachingFilePath as FilePath
from txweb2 import responsecode
from txweb2.http import HTTPError, StatusResponse

from txdav.xml.base import encodeXMLName

from twistedcaldav.config import config
from twistedcaldav.memcacheclient import ClientFactory
from twistedcaldav.memcacheclient import MemcacheError, TokenMismatchError


NoValue = ""


class MemcachePropertyCollection (object):
    """
    Manages a single property store for all resources in a collection.
    """
    log = Logger()

    def __init__(self, collection, cacheTimeout=0):
        self.collection = collection
        self.cacheTimeout = cacheTimeout

    @classmethod
    def memcacheClient(cls, refresh=False):
        if not hasattr(MemcachePropertyCollection, "_memcacheClient"):

            cls.log.info("Instantiating memcache connection for MemcachePropertyCollection")

            MemcachePropertyCollection._memcacheClient = ClientFactory.getClient(
                [
                    "%s:%s" % (config.Memcached.Pools.Default.BindAddress, config.Memcached.Pools.Default.Port)
                ],
                debug=0,
                pickleProtocol=2,
            )
            assert MemcachePropertyCollection._memcacheClient is not None

        return MemcachePropertyCollection._memcacheClient

    def propertyCache(self):
        # The property cache has this format:
        #  {
        #    "/path/to/resource/file":
        #      (
        #        {
        #          (namespace, name, uid): property,
        #          ...,
        #        },
        #        memcache_token,
        #      ),
        #    ...,
        #  }
        if not hasattr(self, "_propertyCache"):
            self._propertyCache = self._loadCache()
        return self._propertyCache

    def childCache(self, child):
        path = child.fp.path
        key = self._keyForPath(path)
        propertyCache = self.propertyCache()

        try:
            childCache, token = propertyCache[key]
        except KeyError:
            self.log.debug("No child property cache for {c!r}", c=child)
            childCache, token = ({}, None)

            # message = "No child property cache for %s" % (child,)
            # self.log.error(message)
            # raise AssertionError(message)

        return propertyCache, key, childCache, token

    def _keyForPath(self, path):
        key = "|".join((
            self.__class__.__name__,
            path
        ))
        return md5(key).hexdigest()

    def _loadCache(self, childNames=None):
        if childNames is None:
            abortIfMissing = False
            childNames = self.collection.listChildren()
        else:
            if childNames:
                abortIfMissing = True
            else:
                return {}

        self.log.debug("Loading cache for {c}", c=self.collection)

        client = self.memcacheClient()
        assert client is not None, "OMG no cache!"
        if client is None:
            return None

        keys = tuple((
            (self._keyForPath(self.collection.fp.child(childName).path), childName)
            for childName in childNames
        ))

        result = self._split_gets_multi(
            (key for key, _ignore_name in keys),
            client.gets_multi
        )

        if abortIfMissing:
            missing = "missing "
        else:
            missing = ""
        self.log.debug(
            "Loaded keys for {missing}children of {collection}: {children()}",
            missing=missing, collection=self.collection,
            children=lambda: [name for _ignore_key, name in keys],
        )

        missing = tuple((
            name for key, name in keys
            if key not in result
        ))

        if missing:
            if abortIfMissing:
                raise MemcacheError("Unable to fully load cache for %s" % (self.collection,))

            loaded = self._buildCache(childNames=missing)
            loaded = self._loadCache(childNames=(FilePath(name).basename() for name in loaded.iterkeys()))

            result.update(loaded.iteritems())

        return result

    def _split_gets_multi(self, keys, func, chunksize=250):
        """
        Splits gets_multi into chunks to avoid a memcacheclient timeout due
        of a large number of keys.  Consolidates and returns results.
        Takes a function parameter for easier unit testing.
        """

        results = {}
        count = 0
        subset = []
        for key in keys:
            if count == 0:
                subset = []
            subset.append(key)
            count += 1
            if count == chunksize:
                results.update(func(subset))
                count = 0
        if count:
            results.update(func(subset))
        return results

    def _split_set_multi(self, values, func, time=0, chunksize=250):
        """
        Splits set_multi into chunks to avoid a memcacheclient timeout due
        of a large number of keys.
        Takes a function parameter for easier unit testing.
        """
        count = 0
        subset = {}
        for key, value in values.iteritems():
            if count == 0:
                subset.clear()
            subset[key] = value
            count += 1
            if count == chunksize:
                func(subset, time=time)
                count = 0
        if count:
            func(subset, time=time)

    def _storeCache(self, cache):
        self.log.debug("Storing cache for {c}", c=self.collection)

        values = dict((
            (self._keyForPath(path), props)
            for path, props
            in cache.iteritems()
        ))

        client = self.memcacheClient()
        if client is not None:
            self._split_set_multi(
                values, client.set_multi,
                time=self.cacheTimeout
            )

    def _buildCache(self, childNames=None):
        if childNames is None:
            childNames = self.collection.listChildren()
        elif not childNames:
            return {}

        self.log.debug("Building cache for {c}", c=self.collection)

        cache = {}

        for childName in childNames:
            child = self.collection.getChild(childName)
            if child is None:
                continue

            propertyStore = child.deadProperties()
            props = {}
            for pnamespace, pname, puid in propertyStore.list(filterByUID=False, cache=False):
                props[(pnamespace, pname, puid,)] = propertyStore.get((pnamespace, pname,), uid=puid, cache=False)

            cache[child.fp.path] = props

        self._storeCache(cache)

        return cache

    def setProperty(self, child, property, uid, delete=False):
        propertyCache, key, childCache, token = self.childCache(child)

        if delete:
            qname = property
            qnameuid = qname + (uid,)
            if qnameuid in childCache:
                del childCache[qnameuid]
        else:
            qname = property.qname()
            qnameuid = qname + (uid,)
            childCache[qnameuid] = property

        client = self.memcacheClient()

        if client is not None:
            retries = 10
            while retries:
                try:
                    if client.set(
                        key, childCache, time=self.cacheTimeout,
                        token=token
                    ):
                        # Success
                        break

                except TokenMismatchError:
                    # The value in memcache has changed since we last
                    # fetched it
                    self.log.debug("memcacheprops setProperty TokenMismatchError; retrying...")

                finally:
                    # Re-fetch the properties for this child
                    loaded = self._loadCache(childNames=(child.fp.basename(),))
                    propertyCache.update(loaded.iteritems())

                retries -= 1

                propertyCache, key, childCache, token = self.childCache(child)

                if delete:
                    if qnameuid in childCache:
                        del childCache[qnameuid]
                else:
                    childCache[qnameuid] = property

            else:
                self.log.error("memcacheprops setProperty had too many failures")
                delattr(self, "_propertyCache")
                raise MemcacheError("Unable to %s property %s%s on %s" % (
                    "delete" if delete else "set",
                    uid if uid else "",
                    encodeXMLName(*qname),
                    child
                ))

    def deleteProperty(self, child, qname, uid):
        return self.setProperty(child, qname, uid, delete=True)

    def flushCache(self, child):
        path = child.fp.path
        key = self._keyForPath(path)
        propertyCache = self.propertyCache()

        if key in propertyCache:
            del propertyCache[key]

        client = self.memcacheClient()
        if client is not None:
            result = client.delete(key)
            if not result:
                raise MemcacheError("Unable to flush cache on %s" % (child,))

    def propertyStoreForChild(self, child, childPropertyStore):
        return self.ChildPropertyStore(self, child, childPropertyStore)

    class ChildPropertyStore (object):
        log = Logger()

        def __init__(self, parentPropertyCollection, child, childPropertyStore):
            self.parentPropertyCollection = parentPropertyCollection
            self.child = child
            self.childPropertyStore = childPropertyStore

        def propertyCache(self):
            path = self.child.fp.path
            key = self.parentPropertyCollection._keyForPath(path)
            parentPropertyCache = self.parentPropertyCollection.propertyCache()
            return parentPropertyCache.get(key, ({}, None))[0]

        def flushCache(self):
            self.parentPropertyCollection.flushCache(self.child)

        def get(self, qname, uid=None, cache=True):
            if cache:
                propertyCache = self.propertyCache()
                qnameuid = qname + (uid,)
                if qnameuid in propertyCache:
                    return propertyCache[qnameuid]
                else:
                    raise HTTPError(StatusResponse(
                        responsecode.NOT_FOUND,
                        "No such property: %s%s" % (uid if uid else "", encodeXMLName(*qname))
                    ))

            self.log.debug(
                "Read for {u}{q} on {p}",
                u=("{%s}:" % (uid,)) if uid else "",
                q=qname,
                p=self.childPropertyStore.resource.fp.path
            )
            return self.childPropertyStore.get(qname, uid=uid)

        def set(self, property, uid=None):
            self.log.debug(
                "Write for {u}{q} on {p}",
                u=("{%s}:" % (uid,)) if uid else "",
                q=property.qname(),
                p=self.childPropertyStore.resource.fp.path
            )

            self.parentPropertyCollection.setProperty(self.child, property, uid)
            self.childPropertyStore.set(property, uid=uid)

        def delete(self, qname, uid=None):
            self.log.debug(
                "Delete for {u}{q} on {p}",
                u=("{%s}:" % (uid,)) if uid else "",
                q=qname,
                p=self.childPropertyStore.resource.fp.path,
            )

            self.parentPropertyCollection.deleteProperty(self.child, qname, uid)
            self.childPropertyStore.delete(qname, uid=uid)

        def contains(self, qname, uid=None, cache=True):
            if cache:
                propertyCache = self.propertyCache()
                qnameuid = qname + (uid,)
                return qnameuid in propertyCache

            self.log.debug(
                "Contains for {u}{q} on {p}",
                u=("{%s}:" % (uid,)) if uid else "",
                q=qname,
                p=self.childPropertyStore.resource.fp.path,
            )
            return self.childPropertyStore.contains(qname, uid=uid)

        def list(self, uid=None, filterByUID=True, cache=True):
            if cache:
                propertyCache = self.propertyCache()
                results = propertyCache.keys()
                if filterByUID:
                    return [
                        (namespace, name)
                        for namespace, name, propuid in results
                        if propuid == uid
                    ]
                else:
                    return results

            self.log.debug(
                "List for{p}", p=self.childPropertyStore.resource.fp.path
            )
            return self.childPropertyStore.list(uid=uid, filterByUID=filterByUID)
