# Copyright (c) 2006,2007,2008 Mitch Garnaat http://garnaat.org/
# Copyright (c) 2010 Chris Moyer http://coredumped.org/
#
# 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, dis-
# tribute, sublicense, and/or sell copies of the Software, and to permit
# persons to whom the Software is furnished to do so, subject to the fol-
# lowing 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 MERCHANTABIL-
# ITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
# SHALL THE AUTHOR 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.
import boto
import re
from boto.utils import find_class
import uuid
from boto.sdb.db.key import Key
from boto.sdb.db.blob import Blob
from boto.sdb.db.property import ListProperty, MapProperty
from datetime import datetime, date, time
from boto.exception import SDBPersistenceError, S3ResponseError

ISO8601 = '%Y-%m-%dT%H:%M:%SZ'


class TimeDecodeError(Exception):
    pass


class SDBConverter(object):
    """
    Responsible for converting base Python types to format compatible
    with underlying database.  For SimpleDB, that means everything
    needs to be converted to a string when stored in SimpleDB and from
    a string when retrieved.

    To convert a value, pass it to the encode or decode method.  The
    encode method will take a Python native value and convert to DB
    format.  The decode method will take a DB format value and convert
    it to Python native format.  To find the appropriate method to
    call, the generic encode/decode methods will look for the
    type-specific method by searching for a method
    called"encode_<type name>" or "decode_<type name>".
    """
    def __init__(self, manager):
        # Do a delayed import to prevent possible circular import errors.
        from boto.sdb.db.model import Model
        self.model_class = Model
        self.manager = manager
        self.type_map = {bool: (self.encode_bool, self.decode_bool),
                         int: (self.encode_int, self.decode_int),
                         long: (self.encode_long, self.decode_long),
                         float: (self.encode_float, self.decode_float),
                         self.model_class: (
                            self.encode_reference, self.decode_reference
                         ),
                         Key: (self.encode_reference, self.decode_reference),
                         datetime: (self.encode_datetime, self.decode_datetime),
                         date: (self.encode_date, self.decode_date),
                         time: (self.encode_time, self.decode_time),
                         Blob: (self.encode_blob, self.decode_blob),
                         str: (self.encode_string, self.decode_string),
                      }

    def encode(self, item_type, value):
        try:
            if self.model_class in item_type.mro():
                item_type = self.model_class
        except:
            pass
        if item_type in self.type_map:
            encode = self.type_map[item_type][0]
            return encode(value)
        return value

    def decode(self, item_type, value):
        if item_type in self.type_map:
            decode = self.type_map[item_type][1]
            return decode(value)
        return value

    def encode_list(self, prop, value):
        if value in (None, []):
            return []
        if not isinstance(value, list):
            # This is a little trick to avoid encoding when it's just a single value,
            # since that most likely means it's from a query
            item_type = getattr(prop, "item_type")
            return self.encode(item_type, value)
        # Just enumerate(value) won't work here because
        # we need to add in some zero padding
        # We support lists up to 1,000 attributes, since
        # SDB technically only supports 1024 attributes anyway
        values = {}
        for k, v in enumerate(value):
            values["%03d" % k] = v
        return self.encode_map(prop, values)

    def encode_map(self, prop, value):
        import urllib
        if value is None:
            return None
        if not isinstance(value, dict):
            raise ValueError('Expected a dict value, got %s' % type(value))
        new_value = []
        for key in value:
            item_type = getattr(prop, "item_type")
            if self.model_class in item_type.mro():
                item_type = self.model_class
            encoded_value = self.encode(item_type, value[key])
            if encoded_value is not None:
                new_value.append('%s:%s' % (urllib.quote(key), encoded_value))
        return new_value

    def encode_prop(self, prop, value):
        if isinstance(prop, ListProperty):
            return self.encode_list(prop, value)
        elif isinstance(prop, MapProperty):
            return self.encode_map(prop, value)
        else:
            return self.encode(prop.data_type, value)

    def decode_list(self, prop, value):
        if not isinstance(value, list):
            value = [value]
        if hasattr(prop, 'item_type'):
            item_type = getattr(prop, "item_type")
            dec_val = {}
            for val in value:
                if val is not None:
                    k, v = self.decode_map_element(item_type, val)
                    try:
                        k = int(k)
                    except:
                        k = v
                    dec_val[k] = v
            value = dec_val.values()
        return value

    def decode_map(self, prop, value):
        if not isinstance(value, list):
            value = [value]
        ret_value = {}
        item_type = getattr(prop, "item_type")
        for val in value:
            k, v = self.decode_map_element(item_type, val)
            ret_value[k] = v
        return ret_value

    def decode_map_element(self, item_type, value):
        """Decode a single element for a map"""
        import urllib
        key = value
        if ":" in value:
            key, value = value.split(':', 1)
            key = urllib.unquote(key)
        if self.model_class in item_type.mro():
            value = item_type(id=value)
        else:
            value = self.decode(item_type, value)
        return (key, value)

    def decode_prop(self, prop, value):
        if isinstance(prop, ListProperty):
            return self.decode_list(prop, value)
        elif isinstance(prop, MapProperty):
            return self.decode_map(prop, value)
        else:
            return self.decode(prop.data_type, value)

    def encode_int(self, value):
        value = int(value)
        value += 2147483648
        return '%010d' % value

    def decode_int(self, value):
        try:
            value = int(value)
        except:
            boto.log.error("Error, %s is not an integer" % value)
            value = 0
        value = int(value)
        value -= 2147483648
        return int(value)

    def encode_long(self, value):
        value = long(value)
        value += 9223372036854775808
        return '%020d' % value

    def decode_long(self, value):
        value = long(value)
        value -= 9223372036854775808
        return value

    def encode_bool(self, value):
        if value == True or str(value).lower() in ("true", "yes"):
            return 'true'
        else:
            return 'false'

    def decode_bool(self, value):
        if value.lower() == 'true':
            return True
        else:
            return False

    def encode_float(self, value):
        """
        See http://tools.ietf.org/html/draft-wood-ldapext-float-00.
        """
        s = '%e' % value
        l = s.split('e')
        mantissa = l[0].ljust(18, '0')
        exponent = l[1]
        if value == 0.0:
            case = '3'
            exponent = '000'
        elif mantissa[0] != '-' and exponent[0] == '+':
            case = '5'
            exponent = exponent[1:].rjust(3, '0')
        elif mantissa[0] != '-' and exponent[0] == '-':
            case = '4'
            exponent = 999 + int(exponent)
            exponent = '%03d' % exponent
        elif mantissa[0] == '-' and exponent[0] == '-':
            case = '2'
            mantissa = '%f' % (10 + float(mantissa))
            mantissa = mantissa.ljust(18, '0')
            exponent = exponent[1:].rjust(3, '0')
        else:
            case = '1'
            mantissa = '%f' % (10 + float(mantissa))
            mantissa = mantissa.ljust(18, '0')
            exponent = 999 - int(exponent)
            exponent = '%03d' % exponent
        return '%s %s %s' % (case, exponent, mantissa)

    def decode_float(self, value):
        case = value[0]
        exponent = value[2:5]
        mantissa = value[6:]
        if case == '3':
            return 0.0
        elif case == '5':
            pass
        elif case == '4':
            exponent = '%03d' % (int(exponent) - 999)
        elif case == '2':
            mantissa = '%f' % (float(mantissa) - 10)
            exponent = '-' + exponent
        else:
            mantissa = '%f' % (float(mantissa) - 10)
            exponent = '%03d' % abs((int(exponent) - 999))
        return float(mantissa + 'e' + exponent)

    def encode_datetime(self, value):
        if isinstance(value, basestring):
            return value
        if isinstance(value, datetime):
            return value.strftime(ISO8601)
        else:
            return value.isoformat()

    def decode_datetime(self, value):
        """Handles both Dates and DateTime objects"""
        if value is None:
            return value
        try:
            if "T" in value:
                if "." in value:
                    # Handle true "isoformat()" dates, which may have a microsecond on at the end of them
                    return datetime.strptime(value.split(".")[0], "%Y-%m-%dT%H:%M:%S")
                else:
                    return datetime.strptime(value, ISO8601)
            else:
                value = value.split("-")
                return date(int(value[0]), int(value[1]), int(value[2]))
        except Exception, e:
            return None

    def encode_date(self, value):
        if isinstance(value, basestring):
            return value
        return value.isoformat()

    def decode_date(self, value):
        try:
            value = value.split("-")
            return date(int(value[0]), int(value[1]), int(value[2]))
        except:
            return None

    encode_time = encode_date

    def decode_time(self, value):
        """ converts strings in the form of HH:MM:SS.mmmmmm
            (created by datetime.time.isoformat()) to
            datetime.time objects.

            Timzone-aware strings ("HH:MM:SS.mmmmmm+HH:MM") won't
            be handled right now and will raise TimeDecodeError.
        """
        if '-' in value or '+' in value:
            # TODO: Handle tzinfo
            raise TimeDecodeError("Can't handle timezone aware objects: %r" % value)
        tmp = value.split('.')
        arg = map(int, tmp[0].split(':'))
        if len(tmp) == 2:
            arg.append(int(tmp[1]))
        return time(*arg)

    def encode_reference(self, value):
        if value in (None, 'None', '', ' '):
            return None
        if isinstance(value, basestring):
            return value
        else:
            return value.id

    def decode_reference(self, value):
        if not value or value == "None":
            return None
        return value

    def encode_blob(self, value):
        if not value:
            return None
        if isinstance(value, basestring):
            return value

        if not value.id:
            bucket = self.manager.get_blob_bucket()
            key = bucket.new_key(str(uuid.uuid4()))
            value.id = "s3://%s/%s" % (key.bucket.name, key.name)
        else:
            match = re.match("^s3:\/\/([^\/]*)\/(.*)$", value.id)
            if match:
                s3 = self.manager.get_s3_connection()
                bucket = s3.get_bucket(match.group(1), validate=False)
                key = bucket.get_key(match.group(2))
            else:
                raise SDBPersistenceError("Invalid Blob ID: %s" % value.id)

        if value.value is not None:
            key.set_contents_from_string(value.value)
        return value.id

    def decode_blob(self, value):
        if not value:
            return None
        match = re.match("^s3:\/\/([^\/]*)\/(.*)$", value)
        if match:
            s3 = self.manager.get_s3_connection()
            bucket = s3.get_bucket(match.group(1), validate=False)
            try:
                key = bucket.get_key(match.group(2))
            except S3ResponseError, e:
                if e.reason != "Forbidden":
                    raise
                return None
        else:
            return None
        if key:
            return Blob(file=key, id="s3://%s/%s" % (key.bucket.name, key.name))
        else:
            return None

    def encode_string(self, value):
        """Convert ASCII, Latin-1 or UTF-8 to pure Unicode"""
        if not isinstance(value, str):
            return value
        try:
            return unicode(value, 'utf-8')
        except:
            # really, this should throw an exception.
            # in the interest of not breaking current
            # systems, however:
            arr = []
            for ch in value:
                arr.append(unichr(ord(ch)))
            return u"".join(arr)

    def decode_string(self, value):
        """Decoding a string is really nothing, just
        return the value as-is"""
        return value


class SDBManager(object):

    def __init__(self, cls, db_name, db_user, db_passwd,
                 db_host, db_port, db_table, ddl_dir, enable_ssl,
                 consistent=None):
        self.cls = cls
        self.db_name = db_name
        self.db_user = db_user
        self.db_passwd = db_passwd
        self.db_host = db_host
        self.db_port = db_port
        self.db_table = db_table
        self.ddl_dir = ddl_dir
        self.enable_ssl = enable_ssl
        self.s3 = None
        self.bucket = None
        self.converter = SDBConverter(self)
        self._sdb = None
        self._domain = None
        if consistent is None and hasattr(cls, "__consistent__"):
            consistent = cls.__consistent__
        self.consistent = consistent

    @property
    def sdb(self):
        if self._sdb is None:
            self._connect()
        return self._sdb

    @property
    def domain(self):
        if self._domain is None:
            self._connect()
        return self._domain

    def _connect(self):
        args = dict(aws_access_key_id=self.db_user,
                    aws_secret_access_key=self.db_passwd,
                    is_secure=self.enable_ssl)
        try:
            region = [x for x in boto.sdb.regions() if x.endpoint == self.db_host][0]
            args['region'] = region
        except IndexError:
            pass
        self._sdb = boto.connect_sdb(**args)
        # This assumes that the domain has already been created
        # It's much more efficient to do it this way rather than
        # having this make a roundtrip each time to validate.
        # The downside is that if the domain doesn't exist, it breaks
        self._domain = self._sdb.lookup(self.db_name, validate=False)
        if not self._domain:
            self._domain = self._sdb.create_domain(self.db_name)

    def _object_lister(self, cls, query_lister):
        for item in query_lister:
            obj = self.get_object(cls, item.name, item)
            if obj:
                yield obj

    def encode_value(self, prop, value):
        if value is None:
            return None
        if not prop:
            return str(value)
        return self.converter.encode_prop(prop, value)

    def decode_value(self, prop, value):
        return self.converter.decode_prop(prop, value)

    def get_s3_connection(self):
        if not self.s3:
            self.s3 = boto.connect_s3(self.db_user, self.db_passwd)
        return self.s3

    def get_blob_bucket(self, bucket_name=None):
        s3 = self.get_s3_connection()
        bucket_name = "%s-%s" % (s3.aws_access_key_id, self.domain.name)
        bucket_name = bucket_name.lower()
        try:
            self.bucket = s3.get_bucket(bucket_name)
        except:
            self.bucket = s3.create_bucket(bucket_name)
        return self.bucket

    def load_object(self, obj):
        if not obj._loaded:
            a = self.domain.get_attributes(obj.id, consistent_read=self.consistent)
            if '__type__' in a:
                for prop in obj.properties(hidden=False):
                    if prop.name in a:
                        value = self.decode_value(prop, a[prop.name])
                        value = prop.make_value_from_datastore(value)
                        try:
                            setattr(obj, prop.name, value)
                        except Exception, e:
                            boto.log.exception(e)
            obj._loaded = True

    def get_object(self, cls, id, a=None):
        obj = None
        if not a:
            a = self.domain.get_attributes(id, consistent_read=self.consistent)
        if '__type__' in a:
            if not cls or a['__type__'] != cls.__name__:
                cls = find_class(a['__module__'], a['__type__'])
            if cls:
                params = {}
                for prop in cls.properties(hidden=False):
                    if prop.name in a:
                        value = self.decode_value(prop, a[prop.name])
                        value = prop.make_value_from_datastore(value)
                        params[prop.name] = value
                obj = cls(id, **params)
                obj._loaded = True
            else:
                s = '(%s) class %s.%s not found' % (id, a['__module__'], a['__type__'])
                boto.log.info('sdbmanager: %s' % s)
        return obj

    def get_object_from_id(self, id):
        return self.get_object(None, id)

    def query(self, query):
        query_str = "select * from `%s` %s" % (self.domain.name, self._build_filter_part(query.model_class, query.filters, query.sort_by, query.select))
        if query.limit:
            query_str += " limit %s" % query.limit
        rs = self.domain.select(query_str, max_items=query.limit, next_token = query.next_token)
        query.rs = rs
        return self._object_lister(query.model_class, rs)

    def count(self, cls, filters, quick=True, sort_by=None, select=None):
        """
        Get the number of results that would
        be returned in this query
        """
        query = "select count(*) from `%s` %s" % (self.domain.name, self._build_filter_part(cls, filters, sort_by, select))
        count = 0
        for row in self.domain.select(query):
            count += int(row['Count'])
            if quick:
                return count
        return count

    def _build_filter(self, property, name, op, val):
        if name == "__id__":
            name = 'itemName()'
        if name != "itemName()":
            name = '`%s`' % name
        if val is None:
            if op in ('is', '='):
                return "%(name)s is null" % {"name": name}
            elif op in ('is not', '!='):
                return "%s is not null" % name
            else:
                val = ""
        if property.__class__ == ListProperty:
            if op in ("is", "="):
                op = "like"
            elif op in ("!=", "not"):
                op = "not like"
            if not(op in ["like", "not like"] and val.startswith("%")):
                val = "%%:%s" % val
        return "%s %s '%s'" % (name, op, val.replace("'", "''"))

    def _build_filter_part(self, cls, filters, order_by=None, select=None):
        """
        Build the filter part
        """
        import types
        query_parts = []

        order_by_filtered = False

        if order_by:
            if order_by[0] == "-":
                order_by_method = "DESC"
                order_by = order_by[1:]
            else:
                order_by_method = "ASC"

        if select:
            if order_by and order_by in select:
                order_by_filtered = True
            query_parts.append("(%s)" % select)

        if isinstance(filters, basestring):
            query = "WHERE %s AND `__type__` = '%s'" % (filters, cls.__name__)
            if order_by in ["__id__", "itemName()"]:
                query += " ORDER BY itemName() %s" % order_by_method
            elif order_by is not None:
                query += " ORDER BY `%s` %s" % (order_by, order_by_method)
            return query

        for filter in filters:
            filter_parts = []
            filter_props = filter[0]
            if not isinstance(filter_props, list):
                filter_props = [filter_props]
            for filter_prop in filter_props:
                (name, op) = filter_prop.strip().split(" ", 1)
                value = filter[1]
                property = cls.find_property(name)
                if name == order_by:
                    order_by_filtered = True
                if types.TypeType(value) == types.ListType:
                    filter_parts_sub = []
                    for val in value:
                        val = self.encode_value(property, val)
                        if isinstance(val, list):
                            for v in val:
                                filter_parts_sub.append(self._build_filter(property, name, op, v))
                        else:
                            filter_parts_sub.append(self._build_filter(property, name, op, val))
                    filter_parts.append("(%s)" % (" OR ".join(filter_parts_sub)))
                else:
                    val = self.encode_value(property, value)
                    if isinstance(val, list):
                        for v in val:
                            filter_parts.append(self._build_filter(property, name, op, v))
                    else:
                        filter_parts.append(self._build_filter(property, name, op, val))
            query_parts.append("(%s)" % (" or ".join(filter_parts)))


        type_query = "(`__type__` = '%s'" % cls.__name__
        for subclass in self._get_all_decendents(cls).keys():
            type_query += " or `__type__` = '%s'" % subclass
        type_query += ")"
        query_parts.append(type_query)

        order_by_query = ""

        if order_by:
            if not order_by_filtered:
                query_parts.append("`%s` LIKE '%%'" % order_by)
            if order_by in ["__id__", "itemName()"]:
                order_by_query = " ORDER BY itemName() %s" % order_by_method
            else:
                order_by_query = " ORDER BY `%s` %s" % (order_by, order_by_method)

        if len(query_parts) > 0:
            return "WHERE %s %s" % (" AND ".join(query_parts), order_by_query)
        else:
            return ""


    def _get_all_decendents(self, cls):
        """Get all decendents for a given class"""
        decendents = {}
        for sc in cls.__sub_classes__:
            decendents[sc.__name__] = sc
            decendents.update(self._get_all_decendents(sc))
        return decendents

    def query_gql(self, query_string, *args, **kwds):
        raise NotImplementedError("GQL queries not supported in SimpleDB")

    def save_object(self, obj, expected_value=None):
        if not obj.id:
            obj.id = str(uuid.uuid4())

        attrs = {'__type__': obj.__class__.__name__,
                 '__module__': obj.__class__.__module__,
                 '__lineage__': obj.get_lineage()}
        del_attrs = []
        for property in obj.properties(hidden=False):
            value = property.get_value_for_datastore(obj)
            if value is not None:
                value = self.encode_value(property, value)
            if value == []:
                value = None
            if value is None:
                del_attrs.append(property.name)
                continue
            attrs[property.name] = value
            if property.unique:
                try:
                    args = {property.name: value}
                    obj2 = obj.find(**args).next()
                    if obj2.id != obj.id:
                        raise SDBPersistenceError("Error: %s must be unique!" % property.name)
                except(StopIteration):
                    pass
        # Convert the Expected value to SDB format
        if expected_value:
            prop = obj.find_property(expected_value[0])
            v = expected_value[1]
            if v is not None and not isinstance(v, bool):
                v = self.encode_value(prop, v)
            expected_value[1] = v
        self.domain.put_attributes(obj.id, attrs, replace=True, expected_value=expected_value)
        if len(del_attrs) > 0:
            self.domain.delete_attributes(obj.id, del_attrs)
        return obj

    def delete_object(self, obj):
        self.domain.delete_attributes(obj.id)

    def set_property(self, prop, obj, name, value):
        setattr(obj, name, value)
        value = prop.get_value_for_datastore(obj)
        value = self.encode_value(prop, value)
        if prop.unique:
            try:
                args = {prop.name: value}
                obj2 = obj.find(**args).next()
                if obj2.id != obj.id:
                    raise SDBPersistenceError("Error: %s must be unique!" % prop.name)
            except(StopIteration):
                pass
        self.domain.put_attributes(obj.id, {name: value}, replace=True)

    def get_property(self, prop, obj, name):
        a = self.domain.get_attributes(obj.id, consistent_read=self.consistent)

        # try to get the attribute value from SDB
        if name in a:
            value = self.decode_value(prop, a[name])
            value = prop.make_value_from_datastore(value)
            setattr(obj, prop.name, value)
            return value
        raise AttributeError('%s not found' % name)

    def set_key_value(self, obj, name, value):
        self.domain.put_attributes(obj.id, {name: value}, replace=True)

    def delete_key_value(self, obj, name):
        self.domain.delete_attributes(obj.id, name)

    def get_key_value(self, obj, name):
        a = self.domain.get_attributes(obj.id, name, consistent_read=self.consistent)
        if name in a:
            return a[name]
        else:
            return None

    def get_raw_item(self, obj):
        return self.domain.get_item(obj.id)
