#!/usr/bin/env python
#
# Copyright 2007 Google Inc.
#
# 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.
#
"""A memcache viewer and editor UI.

Memcache associates a key with a value and an integer flag. The Go API maps
keys to strings and lets the user control the flag. Java, PHP and Python
map keys to an arbitrary type and uses the flag to indicate the type
information. Java, PHP and Python map types in inconsistent ways, see:
- google/appengine/api/memcache/__init__.py
- google/appengine/api/memcache/MemcacheSerialization.java
- google/appengine/runtime/MemcacheUtils.php
"""


import datetime
import logging
import urllib

from google.appengine.api import apiproxy_stub_map
from google.appengine.api import memcache
from google.appengine.api.memcache import memcache_service_pb
from google.appengine.tools.devappserver2.admin import admin_request_handler


class StringValueConverter(object):
  memcache_type = memcache.TYPE_STR
  placeholder = 'hello world!'
  can_edit = True
  friendly_type_name = 'String'

  @staticmethod
  def to_display(cache_value):
    """Convert a memcache string into a displayable representation.

    Make a memcache string into a text string that can be displayed or edited.
    While called a string, it is technically just an array of bytes. Because
    we do not know what encoding the bytes are (and possibly they are not an
    encoded text string - for example they could be an MD5 hash) we display
    in string-escaped form.

    Args:
      cache_value: an array of bytes

    Returns:
      A unicode string that represents the sequence of bytes and can be
      roundtripped back to the sequence of bytes.
    """

    # As we don't know what encoding the bytes are, we string escape so any
    # byte sequence is legal ASCII. Once we have a legal ASCII byte sequence
    # we can safely convert to a unicode/text string.
    return cache_value.encode('string-escape').decode('ascii')

  @staticmethod
  def to_cache(display_value):
    """Convert a displayable representation to a memcache string.

    Take a displayable/editable text string and convert into a memcache string.
    As a memcache string is technically an array of bytes, we only allow
    characters from the ASCII range and require all other bytes to be indicated
    via string escape. (because if we see the Unicode character Yen sign
    (U+00A5) we don't know if they want the byte 0xA5 or the UTF-8 two byte
    sequence 0xC2 0xA5).

    Args:
      display_value: a text (i.e. unicode string) using only ASCII characters;
        non-ASCII characters must be represented string escapes.

    Returns:
      An array of bytes.

    Raises:
      UnicodeEncodeError: a non-ASCII character is part of the input.
    """

    # Since we don't know how they want their Unicode encoded, this will raise
    # an exception (which will be displayed nicely) if they include non-ASCII.
    return display_value.encode('ascii').decode('string-escape')


class UnicodeValueConverter(object):
  memcache_type = memcache.TYPE_UNICODE
  # Hello world in Japanese.
  placeholder = u'\u3053\u3093\u306b\u3061\u306f\u4e16\u754c'
  can_edit = True
  friendly_type_name = 'Unicode String'

  @staticmethod
  def to_display(cache_value):
    return cache_value.decode('utf-8')

  @staticmethod
  def to_cache(display_value):
    return display_value.encode('utf-8')


class BooleanValueConverter(object):
  memcache_type = memcache.TYPE_BOOL
  placeholder = 'true'
  can_edit = True
  friendly_type_name = 'Boolean'

  @staticmethod
  def to_display(cache_value):
    if cache_value == '0':
      return 'false'
    elif cache_value == '1':
      return 'true'
    else:
      raise ValueError('unexpected boolean %r' % cache_value)

  @staticmethod
  def to_cache(display_value):
    if display_value.lower() in ('false', 'no', 'off', '0'):
      return '0'
    elif display_value.lower() in ('true', 'yes', 'on', '1'):
      return '1'

    raise ValueError(
        'invalid literal for boolean: %s (must be "true" or "false")' %
        display_value)


class IntValueConverter(object):
  memcache_type = memcache.TYPE_INT
  placeholder = '42'
  can_edit = True
  friendly_type_name = 'Integer'

  @staticmethod
  def to_display(cache_value):
    return str(cache_value)

  @staticmethod
  def to_cache(display_value):
    return str(int(display_value))


class OtherValueConverter(object):
  memcache_type = None
  placeholder = None
  can_edit = False
  friendly_type_name = 'Unknown Type'

  @staticmethod
  def to_display(cache_value):
    return repr(cache_value)[1:-1]

  @staticmethod
  def to_cache(display_value):
    raise NotImplementedError('cannot to a memcache value of unknown type')


class MemcacheViewerRequestHandler(admin_request_handler.AdminRequestHandler):
  CONVERTERS = [StringValueConverter, UnicodeValueConverter,
                BooleanValueConverter, IntValueConverter,
                OtherValueConverter]
  MEMCACHE_TYPE_TO_CONVERTER = {c.memcache_type: c for c in CONVERTERS
                                if c.memcache_type is not None}
  FRIENDLY_TYPE_NAME_TO_CONVERTER = {c.friendly_type_name: c
                                     for c in CONVERTERS}

  def _get_memcache_value_and_flags(self, key):
    """Return a 2-tuple containing a memcache value and its flags."""
    request = memcache_service_pb.MemcacheGetRequest()
    response = memcache_service_pb.MemcacheGetResponse()

    request.add_key(key)
    apiproxy_stub_map.MakeSyncCall('memcache', 'Get', request, response)
    assert response.item_size() < 2
    if response.item_size() == 0:
      return None, None
    else:
      return response.item(0).value(), response.item(0).flags()

  def _set_memcache_value(self, key, value, flags):
    """Store a value in memcache."""
    request = memcache_service_pb.MemcacheSetRequest()
    response = memcache_service_pb.MemcacheSetResponse()

    item = request.add_item()
    item.set_key(key)
    item.set_value(value)
    item.set_flags(flags)

    apiproxy_stub_map.MakeSyncCall('memcache', 'Set', request, response)
    return (response.set_status(0) ==
            memcache_service_pb.MemcacheSetResponse.STORED)

  def get(self):
    """Show template and prepare stats and/or key+value to display/edit."""
    values = {'request': self.request,
              'message': self.request.get('message')}

    edit = self.request.get('edit')
    key = self.request.get('key')
    if edit:
      # Show the form to edit/create the value.
      key = edit
      values['show_stats'] = False
      values['show_value'] = False
      values['show_valueform'] = True
      values['types'] = [type_value.friendly_type_name
                         for type_value in self.CONVERTERS
                         if type_value.can_edit]
    elif key:
      # A key was given, show it's value on the stats page.
      values['show_stats'] = True
      values['show_value'] = True
      values['show_valueform'] = False
    else:
      # Plain stats display + key lookup form.
      values['show_stats'] = True
      values['show_valueform'] = False
      values['show_value'] = False

    if key:
      values['key'] = key
      memcache_value, memcache_flags = self._get_memcache_value_and_flags(key)
      if memcache_value is not None:
        converter = self.MEMCACHE_TYPE_TO_CONVERTER.get(memcache_flags,
                                                        OtherValueConverter)
        try:
          values['value'] = converter.to_display(memcache_value)
        except ValueError:
          # This exception is possible in the case where the value was set by
          # Go, which allows for arbitrary user-assigned flag values.
          logging.exception('Could not convert %s value %s',
                            converter.friendly_type_name, memcache_value)
          converter = OtherValueConverter
          values['value'] = converter.to_display(memcache_value)

        values['type'] = converter.friendly_type_name
        values['writable'] = converter.can_edit
        values['key_exists'] = True
        values['value_placeholder'] = converter.placeholder
      else:
        values['writable'] = True
        values['key_exists'] = False

    if values['show_stats']:
      memcache_stats = memcache.get_stats()
      if not memcache_stats:
        # No stats means no memcache usage.
        memcache_stats = {'hits': 0, 'misses': 0, 'byte_hits': 0, 'items': 0,
                          'bytes': 0, 'oldest_item_age': 0}
      values['stats'] = memcache_stats
      try:
        hitratio = memcache_stats['hits'] * 100 / (memcache_stats['hits']
                                                   + memcache_stats['misses'])
      except ZeroDivisionError:
        hitratio = 0
      values['hitratio'] = hitratio
      # TODO: oldest_item_age should be formatted in a more useful
      # way.
      delta_t = datetime.timedelta(seconds=memcache_stats['oldest_item_age'])
      values['oldest_item_age'] = datetime.datetime.now() - delta_t

    self.response.write(self.render('memcache_viewer.html', values))

  def _urlencode(self, query):
    """Encode a dictionary into a URL query string.

    In contrast to urllib this encodes unicode characters as UTF8.

    Args:
      query: Dictionary of key/value pairs.

    Returns:
      String.
    """
    return '&'.join('%s=%s' % (urllib.quote_plus(k.encode('utf8')),
                               urllib.quote_plus(v.encode('utf8')))
                    for k, v in query.iteritems())

  def post(self):
    """Handle modifying actions and/or redirect to GET page."""
    next_param = {}

    if self.request.get('action:flush'):
      if memcache.flush_all():
        next_param['message'] = 'Cache flushed, all keys dropped.'
      else:
        next_param['message'] = 'Flushing the cache failed.  Please try again.'

    elif self.request.get('action:display'):
      next_param['key'] = self.request.get('key')

    elif self.request.get('action:edit'):
      next_param['edit'] = self.request.get('key')

    elif self.request.get('action:delete'):
      key = self.request.get('key')
      result = memcache.delete(key)
      if result == memcache.DELETE_NETWORK_FAILURE:
        next_param['message'] = ('ERROR: Network failure, key "%s" not deleted.'
                                 % key)
      elif result == memcache.DELETE_ITEM_MISSING:
        next_param['message'] = 'Key "%s" not in cache.' % key
      elif result == memcache.DELETE_SUCCESSFUL:
        next_param['message'] = 'Key "%s" deleted.' % key
      else:
        next_param['message'] = ('Unknown return value.  Key "%s" might still '
                                 'exist.' % key)

    elif self.request.get('action:save'):
      key = self.request.get('key')
      value = self.request.get('value')
      type_ = self.request.get('type')
      next_param['key'] = key

      converter = self.FRIENDLY_TYPE_NAME_TO_CONVERTER[type_]
      try:
        memcache_value = converter.to_cache(value)
      except ValueError as e:
        next_param['message'] = 'ERROR: Failed to save key "%s": %s.' % (key, e)
      else:
        if self._set_memcache_value(key,
                                    memcache_value,
                                    converter.memcache_type):
          next_param['message'] = 'Key "%s" saved.' % key
        else:
          next_param['message'] = 'ERROR: Failed to save key "%s".' % key
    elif self.request.get('action:cancel'):
      next_param['key'] = self.request.get('key')
    else:
      next_param['message'] = 'Unknown action.'

    next = self.request.path_url
    if next_param:
      next = '%s?%s' % (next, self._urlencode(next_param))
    self.redirect(next)
