# -*- coding: utf-8 -*- #
# Copyright 2017 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.

"""The Cloud SDK persistent cache abstract base classes.

A persistent cache is a long-lived object that contains cache data and metadata.
Cache data is organized into zero or more named tables. Table data is an
unordered list of row tuples of fixed length. Column value types within a row
are fixed and may be one of string (basestring or unicode), floating point, or
integer.

    +-----------------------+
    | persistent cache      |
    | +-------------------+ |
    | | table             | |
    | | (key,...,col,...) | |
    | |        ...        | |
    | +-------------------+ |
    |          ...          |
    +-----------------------+

A persistent cache is implemented as a Cache object that contains Table objects.
Each table has a timeout and last modified time attribute. Read access on a
table that has expired is an error. The rows in a table have a fixed number of
columns specified by the columns attribute. The keys attribute is the count of
columns in a table row, left to right, that forms the primary key. The primary
key is used to differentiate rows. Adding a row that already exists is not an
error. The row is simply replaced by the new data.

A Table object can be restricted and hidden from cache users. These tables
must be instantiated when the Cache object is instantiated, before the first
user access to the cache. This allows a cache implementation layer to have
tables that are hidden from the layers above it.

The table select and delete methods match against a row template. A template may
have fewer columns than the number of columns in the table. Omitted template
columns or columns with value None match all values for that column. '*' and '?'
matching operators are supported for string columns. It is not an error to
select or delete a row that does not exist.

HINTS for IMPLEMENTERS

By default the Cache and Table constructors create the objects if they don't
exist. The create=False kwarg disables this and raises an exception if the
object does not exist. In addition, the Select ignore_expiration=True kwarg
disables expiry check. These can be used by meta commands/functions to view
and debug cache data without modifying the underlying persistent data.
"""

from __future__ import absolute_import
from __future__ import division
from __future__ import unicode_literals

import abc
import time

from googlecloudsdk.core.cache import exceptions

import six
import six.moves.urllib.parse


def Now():
  """Returns the current time in seconds since the epoch."""
  return time.time()


@six.add_metaclass(abc.ABCMeta)
class Table(object):
  """A persistent cache table object.

  This object should only be instantiated by a Cache object.

  The AddRows and DeleteRows methods operate on lists of rows rather than a
  single row. This accomodates sqlite3 (and possibly other implementation
  layers) that batch rows ops. Restricting to a single row would rule out
  batching.

  Attributes:
    cache: The parent cache object.
    changed: Table data or metadata changed if True.
    name: The table name.
    modified: Table modify Now() (time.time()) value. 0 for expired tables.
    restricted: True if Table is restricted.
    timeout: A float number of seconds. Tables older than (modified+timeout)
      are invalid. 0 means no timeout.
  """

  def __init__(self, cache, name, columns=1, keys=1, timeout=0, modified=0,
               restricted=False):
    self._cache = cache
    self.name = name
    self.restricted = restricted
    self.modified = modified
    self.changed = False
    self.timeout = timeout or 0
    self.columns = columns
    self.keys = keys
    # Determine is the table has expired once at initialization time. We expect
    # callers to keep cache or table objects open for a few seconds at most.
    # Given that it doesn't make sense to do a few operations in that window
    # only to have the last one expire.
    if timeout and modified and (modified + timeout) < Now():
      self.Invalidate()

  @property
  def is_expired(self):
    """True if the table data has expired.

    Expired tables have a self.modified value of 0. Expiry is currently
    computed once when the table object is instantiated. This property shields
    callers from that implementation detail.

    Returns:
      True if the table data has expired.
    """
    return not self.modified

  @classmethod
  def EncodeName(cls, name):
    r"""Returns name encoded for file system path compatibility.

    A table name may be a file name. alnum and '_.-' are not encoded.

    Args:
      name: The cache name string to encode.

    Raises:
      CacheTableNameInvalid: For invalid table names.

    Returns:
      Name encoded for portability.
    """
    if not name:
      raise exceptions.CacheTableNameInvalid(
          'Cache table name [{}] is invalid.'.format(name))
    return six.moves.urllib.parse.quote(name, '!@+,')

  def _CheckRows(self, rows):
    """Raise an exception if the size of any row in rows is invalid.

    Each row size must be equal to the number of columns in the table.

    Args:
      rows: The list of rows to check.

    Raises:
      CacheTableRowSizeInvalid: If any row has an invalid size.
    """
    for row in rows:
      if len(row) != self.columns:
        raise exceptions.CacheTableRowSizeInvalid(
            'Cache table [{}] row size [{}] is invalid. Must be {}.'.format(
                self.name, len(row), self.columns))

  def _CheckRowTemplates(self, rows):
    """Raise an exception if the size of any row template in rows is invalid.

    Each row template must have at least 1 column and no more than the number
    of columns in the table.

    Args:
      rows: The list of rows to check.

    Raises:
      CacheTableRowSizeInvalid: If any row template size is invalid.
    """
    for row in rows:
      if not 1 <= len(row) <= self.columns:
        if self.columns == 1:
          limits = '1'
        else:
          limits = '>= 1 and <= {}'.format(self.columns)
        raise exceptions.CacheTableRowSizeInvalid(
            'Cache table [{}] row size [{}] is invalid. Must be {}.'.format(
                self.name, len(row), limits))

  def Invalidate(self):
    """Invalidates the table by marking it expired."""
    self.changed = True
    self.modified = 0

  def Validate(self, timeout=None):
    """Validates the table and resets the TTL."""
    if timeout is not None:
      self.timeout = timeout or 0
    self.modified = Now()
    self.changed = True

  @abc.abstractmethod
  def Delete(self):
    """Deletes the table."""
    pass

  @abc.abstractmethod
  def AddRows(self, rows):
    """Adds each row in rows to the table. Existing rows are overwritten.

    The number of columns in each row must be equal to the number of columns
    in the table.

    Args:
      rows: A list of rows to add. Existing rows are overwritten.
    """
    pass

  @abc.abstractmethod
  def DeleteRows(self, row_templates=None):
    """Deletes each row in the table matching any of the row_templates.

    Args:
      row_templates: A list of row templates. See Select() below for a detailed
        description of templates. None deletes all rows and is allowed for
        expired tables.
    """
    pass

  @abc.abstractmethod
  def Select(self, row_template=None, ignore_expiration=False):
    """Returns the list of rows that match row_template.

    Args:
      row_template: A row template. The number of columns in the template must
        not exceed the number of columns in the table. An omitted column or
        column with value None matches all values for the column. A None value
        for row_template matches all rows. Each string column may contain these
        wildcard characters:
          * - match zero or more characters
          ? - match any character
      ignore_expiration: Disable table expiration checks if True.

    Raises:
      CacheTableExpired: If the table has expired.

    Returns:
      The list of rows that match row_template.
    """
    pass


@six.add_metaclass(abc.ABCMeta)
class Cache(object):
  r"""A persistent cache object.

  This class is also a context manager. Changes are automaticaly committed if
  the context exits with no exceptions. For example:

    with CacheImplementation('my-cache-name') as c:
      ...

  Attributes:
    name: The persistent cache name. Created/removed by this object. Internally
      encoded by Cache.EncodeName().
    timeout: The default table timeout in seconds. 0 for no timeout.
    version: A caller defined version string that must match the version string
      stored when the persistent object was created.
  """

  def __init__(self, name, create=True, timeout=None, version=None):
    self.name = Cache.EncodeName(name)
    del create  # Unused in __init__. Subclass constructors may use this.
    self.timeout = timeout
    self.version = version

  def __enter__(self):
    return self

  def __exit__(self, typ, value, traceback):
    self.Close(commit=typ is None)

  @classmethod
  def EncodeName(cls, name):
    r"""Returns name encoded for filesystem portability.

    A cache name may be a file path. The part after the rightmost of
    ('/', '\\') is encoded with Table.EncodeName().

    Args:
      name: The cache name string to encode.

    Raises:
      CacheNameInvalid: For invalid cache names.

    Returns:
      Name encoded for filesystem portability.
    """
    basename_index = max(name.rfind('/'), name.rfind('\\')) + 1
    if not name[basename_index:]:
      raise exceptions.CacheNameInvalid(
          'Cache name [{}] is invalid.'.format(name))
    return name[:basename_index] + Table.EncodeName(name[basename_index:])

  @abc.abstractmethod
  def Delete(self):
    """Permanently deletes the cache."""
    pass

  def Invalidate(self):
    """Invalidates the cache by invalidating all of its tables."""
    for name in self.Select():
      self.Table(name).Invalidate()

  @abc.abstractmethod
  def Commit(self):
    """Commits all changes up to this point."""
    pass

  @abc.abstractmethod
  def Close(self, commit=True):
    """Closes the cache, optionally committing any changes.

    Args:
      commit: Commits any changes before closing if True.
    """
    pass

  @abc.abstractmethod
  def Table(self, name, create=True, columns=1, keys=1, timeout=None):
    """Returns the Table object for existing table name.

    Args:
      name: The table name.
      create: If True creates the table if it does not exist.
      columns: The number of columns in the table. Must be >= 1.
      keys: The number of columns, starting from 0, that form the primary
        row key. Must be 1 <= keys <= columns. The primary key is used to
        differentiate rows in the AddRows and DeleteRows methods.
      timeout: The table timeout in seconds, 0 for no timeout.

    Raises:
      CacheTableNameInvalid: name is not a valid table name.
      CacheTableNotFound: If the table does not exist.

    Returns:
      A Table object for name.
    """
    pass

  @abc.abstractmethod
  def Select(self, name=None):
    """Returns the list of table names matching name.

    Args:
      name: The table name pattern to match, None for all tables. The pattern
        may contain these wildcard characters:
          * - match zero or more characters
          ? - match any character
    """
    pass
