# Copyright 2014 Google 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.

"""Helper classes to implement caching."""

__author__ = 'Pavel Simakov (psimakov@google.com)'


import collections
import datetime
import logging
import sys
import threading
import unittest

import appengine_config
from models.counters import PerfCounter


def iter_all(query, batch_size=100):
    """Yields query results iterator. Proven method for large datasets."""
    prev_cursor = None
    any_records = True
    while any_records:
        any_records = False
        query = query.with_cursor(prev_cursor)
        for entity in query.run(batch_size=batch_size):
            any_records = True
            yield entity
        prev_cursor = query.cursor()


class AbstractScopedSingleton(object):
    """A singleton object bound to and managed by a container.

    This singleton stores its instance inside the container. When container is
    wiped, the singleton instance is garbage collected and  destroyed. You can
    use a dict as a container and then wipe it yourself. You can use
    threading.local as a container and it will be wiped automatically when
    thread exits.
    """

    CONTAINER = None

    @classmethod
    def _instances(cls):
        assert cls.CONTAINER is not None
        if 'instances' not in cls.CONTAINER:
            cls.CONTAINER['instances'] = {}
        return cls.CONTAINER['instances']

    @classmethod
    def instance(cls, *args, **kwargs):
        """Creates new or returns existing instance of the object."""
        # pylint: disable=protected-access
        _instance = cls._instances().get(cls)
        if not _instance:
            try:
                _instance = cls(*args, **kwargs)
            except:
                logging.exception(
                    'Failed to instantiate %s: %s, %s', cls, args, kwargs)
                raise
            appengine_config.log_appstats_event('%s.create' % cls.__name__, {})
            _instance._init_args = (args, kwargs)
            cls._instances()[cls] = _instance
        else:
            _before = _instance._init_args
            _now = (args, kwargs)
            if _now != _before:
                raise AssertionError(
                    'Singleton initiated with %s already exists. '
                    'Failed to re-initialized it with %s.' % (_before, _now))
        return _instance

    @classmethod
    def clear_all(cls):
        """Clear all active instances."""
        if cls._instances():
            for _instance in list(cls._instances().values()):
                _instance.clear()
            del cls.CONTAINER['instances']

    def clear(self):
        """Destroys this object and its content."""
        appengine_config.log_appstats_event(
            '%s.destroy' % self.__class__.__name__, {})
        _instance = self._instances().get(self.__class__)
        if _instance:
            del self._instances()[self.__class__]

_process_scoped_singleton = {}
_request_scoped_singleton = threading.local()


class ProcessScopedSingleton(AbstractScopedSingleton):
    """A singleton object bound to the process."""

    CONTAINER = _process_scoped_singleton


class RequestScopedSingleton(AbstractScopedSingleton):
    """A singleton object bound to the request scope."""

    CONTAINER = _request_scoped_singleton.__dict__


class LRUCache(object):
    """A dict that supports capped size and LRU eviction of items."""

    def __init__(
        self, max_item_count=None,
        max_size_bytes=None, max_item_size_bytes=None):
        assert max_item_count or max_size_bytes
        if max_item_count:
            assert max_item_count > 0
        if max_size_bytes:
            assert max_size_bytes > 0
        self.total_size = 0
        self.max_item_count = max_item_count
        self.max_size_bytes = max_size_bytes
        self.max_item_size_bytes = max_item_size_bytes
        self.items = collections.OrderedDict([])

    def get_entry_size(self, key, value):
        """Computes item size. Override and compute properly for your items."""
        return sys.getsizeof(key) + sys.getsizeof(value)

    def _compute_current_size(self):
        total = 0
        for key, item in self.items.iteritems():
            total += sys.getsizeof(key) + self.get_item_size(item)
        return total

    def _allocate_space(self, key, value):
        """Remove items in FIFO order until size constraints are met."""
        entry_size = self.get_entry_size(key, value)
        if self.max_item_size_bytes and entry_size > self.max_item_size_bytes:
            return False
        while True:
            over_count = False
            over_size = False
            if self.max_item_count:
                over_count = len(self.items) >= self.max_item_count
            if self.max_size_bytes:
                over_size = self.total_size + entry_size >= self.max_size_bytes
            if not (over_count or over_size):
                if self.max_size_bytes:
                    self.total_size += entry_size
                    assert self.total_size < self.max_size_bytes
                return True
            if self.items:
                _key, _value = self.items.popitem(last=False)
                if self.max_size_bytes:
                    self.total_size -= self.get_entry_size(_key, _value)
                    assert self.total_size >= 0
            else:
                break
        return False

    def _record_access(self, key):
        """Pop and re-add the item."""
        item = self.items.pop(key)
        self.items[key] = item

    def contains(self, key):
        """Checks if item is contained without accessing it."""
        ass
ert key
        return key in self.items

    def put(self, key, value):
        assert key
        if self._allocate_space(key, value):
            self.items[key] = value
            return True
        return False

    def get(self, key):
        """Accessing item makes it less likely to be evicted."""
        a
ssert key
        if key in self.items:
            self._record_access(key)
            return True, self.items[key]
        return False, None

    def delete(self, key):
        assert key
        if key in self.items:
            del self.items[key]
            return True
        return False


class NoopCacheConnection(object):
    """Connection to no-op cache that provides no caching."""

    def put(self, *unused_args, **unused_kwargs):
        return None

    def get(self, *unused_args, **unused_kwargs):
        return False, None

    def delete(self, *unused_args, **unused_kwargs):
        return None


class AbstractCacheEntry(object):
    """Object representation while in cache."""

    # we don't track deletions; deleted item will hang around this long
    CACHE_ENTRY_TTL_SEC = 5 * 60

    @classmethod
    def internalize(cls, unused_key, *args, **kwargs):
        """Converts incoming objects into cache entry object."""
        return (args, kwargs)

    @classmethod
    def externalize(cls, unused_key, *args, **kwargs):
        """Converts cache entry into external object."""
        return (args, kwargs)

    def has_expired(self):
        age = (datetime.datetime.utcnow() - self.created_on).total_seconds()
        return age > self.CACHE_ENTRY_TTL_SEC

    def is_up_to_date(self, unused_key, unused_update):
        """Compare entry and the update object to decide if entry is fresh."""
        raise NotImplementedError()

    def updated_on(self):
        """Return last update time for entity."""
        raise NotImplementedError()


class AbstractCacheConnection(object):

    PERSISTENT_ENTITY = None
    CACHE_ENTRY = None

  