from datetime import date, datetime
from uuid import UUID

from .exceptions import NotFoundException
from .index import Index


class AbstractBaseDB:
  """
  A simple, un-typed, persistent storage interface that provides basic CRUD operations and indexing capabilities.

  Core concepts:
  Freeform binary 'data' is stored against a 'data_type' string. Data objects must be picklable. It is generally
  assumed that your data is formatted as a dictionary, but only the 'init_index' function actually requires that
  assumption. New data is automatically assigned a UUID as its primary key.

  Example usage:
  >>> db = ... # DB instance
  >>> db.init('user', indexes=[ 'name', 'age' ])
  >>> db.create('user', { 'name': 'Alex', 'age': 8 }) -> <user_id>
  >>> db.get('user', <user_id>) -> { 'name': 'Alex', 'age': 8 }
  >>> db.list('user', db.condition('name') == 'Alex') -> [ (<user_id>, { 'name': 'Alex', 'age': 8 }) ]
  >>> db.list('user', db.condition('name') == 'Kelly') -> []
  >>> db.update('user', <user_id>, { 'name': 'Alex', 'age': 9 })
  >>> db.get('user', <user_id>) -> { 'name': 'Alex', 'age': 9 }
  >>> db.list('user', db.condition('name') == 'Alex' and db.condition('age') > 0) -> Raises MalformedConditionException
  >>> db.list('user', db.condition('name') == db.condition('age')) -> Raises MalformedConditionException
  >>> db.delete('user', <user_id>)
  >>> db.get('user', <user_id>) -> Raises NotFoundException
  >>> db.get('invalid', <user_id>) -> Raises MissingDataTypeException
  >>> db.list('user', db.condition('invalid') == 123) -> Raises MissingIndexException
  >>> db.update('user', <user_id>, { 'name': 'Alex' })
  >>> db.create('user', { 'name': 'Kelly' }) -> <user_id_2>
  >>> db.list('user', db.condition('name')) -> [ (<user_id>, { 'name': 'Alex' }), (<user_id_2, { 'name': 'Kelly' }) ]
  """


  # We can only store index values for these types
  SUPPORTED_INDEX_TYPES = [ bool, str, int, float, UUID, date, datetime, bytes ]


  def init(self, data_type, indexes=[]):
    """
    Initializes the 'data_type' and 'indexes' for that data type. You must call this function before you can make
    any calls that reference the data type or indexes. You cannot initialize the same data type more than once.

    Parameters:
    - data_type: the string name of the data type to initialize
    - indexes: a list containing 'Index' objects. To mitigate verbosity, you can also pass in strings directly, which
      will be used as the argument to create an 'Index' object internally.

    Example:
    >>> db = ... # DB instance
    >>> db.init('user', indexes=[ 'age', Index('email_lower', key=lambda user: user['email'].lower()) ])
    """
    raise NotImplementedError()


  def describe(self):
    """
    Returns a structure showing all the available data types and indexes:
    { <data_type>: [ <index_name>, ... ], ... }
    """
    raise NotImplementedError()


  def transaction(self):
    """
    A context manager that will automatically commit (on success) or rollback (on error) any internal operations.
    Nested transactions are allowed, but no changes will be committed until the outermost block concludes.
    """
    raise NotImplementedError()


  def create(self, data_type, data):
    """
    Stores a new object specified by 'data' for the given 'data_type'. If a 'fmt' was provided when initializing the
    data type, then the 'data' will be validated against that 'fmt' and the result will be stored. See discussion on
    'init_data_type' for more discussion.

    Returns the id (UUID) of the newly created object
    """
    raise NotImplementedError()


  def get(self, data_type, data_id):
    """
    Returns the data stored against the 'data_id'
    Raises a NotFoundException if the 'data_id' cannot be found
    """
    raise NotImplementedError()


  def get_batch(self, data_type, data_ids):
    """
    As the name implies, this retrieves a batch of data in a single call. As such, avoid making the 'data_ids' iterable
    too large, as that will presumably not perform well with most implementations.

    Generates tuples of (data_id, data) for each of the input 'data_ids' that could be found. Note that the generator
    may not include an entry for every input data_id (or any).
    """

    # This default implementation simply proxies the 'get' function and should probably be overwritten
    output = []
    for data_id in data_ids:
      try:
        data = self.get(data_type, data_id)
      except NotFoundException:
        continue
      output.append((data_id, data))
    return output


  def update(self, data_type, data_id, data):
    """
    Replaces the data stored against the 'data_id' with that specified by 'data'. If a 'fmt' was provided when
    initializing the data type, then the 'data' will be validated against that 'fmt' and the result will be stored. See
    discussion on 'init_data_type' for more discussion.

    Returns nothing
    Raises a NotFoundException if the 'data_id' cannot be found
    """
    raise NotImplementedError()


  def delete(self, data_type, data_id):
    """
    Deletes the specified item from the database.

    Raises a NotFoundException if the 'data_id' cannot be found
    """
    raise NotImplementedError()


  def __getitem__(self, *fields):
    """
    Provided as an alias to the 'condition' function to make writing queries less verbose. The following two statements
    are equivalent:
    >>> db.list('user', db['age'] == 8)
    >>> db.list('user', Index('age') == 8)
    """
    return Index(*fields)


  def list(self, data_type, *condition, descending=False, limit=None):
    """
    Returns a list containing tuples of (id, data) that match all of the specified 'conditions'
    Raises a MissingDataTypeException if the 'data_type' does not exist
    Raises a MissingIndexException if an index referenced by the 'condition' does not exist
    Raises a MalformedIndexConditionException if the 'condition' is not of the right type

    Parameters:
    - data_type: list data of this type. Must have been initialized using the 'init' function.
    - condition: an optional 'Condition' object. A condition can be created using the 'condition' function coupled with
      a comparison operation (see usage examples for more clarity). An input 'condition' can only refer to index names
      that were initialized using the 'init' function.
    - descending: set to True to sort the returned list in descending order instead of ascending
    - limit: impose a limit on the size of the returned list

    Examples:
    >>> db = ... # DB instance
    >>> db.init('user', indexes=[ 'age', 'name' ])
    >>>
    >>> # List all users (sorted by ID)
    >>> db.list('user')
    >>>
    >>> # List the oldest 10 users
    >>> db.list('user', db.condition('age'), descending=True, limit=10)
    >>>
    >>> # List users who are exactly 50
    >>> db.list('user', db.condition('age') == 50)
    >>> db.list('user', 50 == db.condition('age'))
    >>>
    >>> # List users who are under 18, sorted by age
    >>> db.list('user', db.condition('age') < 18)
    >>> 
    >>> # List users between 8 and 12 (inclusive)
    >>> db.list('user', 8 <= db.condition('age') <= 12)
    >>> 
    >>> # List users whose name starts with 'alex'
    >>> db.list('user', db.condition('name') == 'alex')
    >>> 
    >>> # List users whose name is 'alex' and who are between 20 and 40 (inclusive) - note that we will need a new
    ... compound index for this (thus a new db instance)
    >>> db = ...
    >>> db.init('user', indexes=[ db.index('name/age', key=lambda x: '%(name)s/%(age)i' % x) ])
    >>> db.list('user', 'alex/20' <= db.condition('name/age') <= 'alex/40')
    """

    # This default implementation simply proxies the 'iter' function and should probably be overwritten
    return list(self.iter(data_type, *condition, descending=descending, limit=limit))


  def iter(self, data_type, *condition, descending=False, limit=None):
    """
    Identical to 'list' except that the results are returned as an iterator

    Note: this is more flexible and robust than the 'list' function, as the number of results it returns is not
    constrained by memory, but it may carry some overhead which might be noticeable for small data sets depending on
    the implementation.
    """
    raise NotImplementedError()


  # For cloning data from other databases

  def _import_items(self, data_type, items):
    raise NotImplementedError()


  # For tests

  def _remove_indexes(self):
    raise NotImplementedError()

  def _remove_data_type(self, data_type):
    raise NotImplementedError()
