# Copyright (c) The PyAMF Project.
# See LICENSE.txt for details.

"""
AMF0 implementation.

C{AMF0} supports the basic data types used for the NetConnection, NetStream,
LocalConnection, SharedObjects and other classes in the Adobe Flash Player.

@since: 0.1
@see: U{Official AMF0 Specification in English (external)
    <http://opensource.adobe.com/wiki/download/attachments/1114283/amf0_spec_121207.pdf>}
@see: U{Official AMF0 Specification in Japanese (external)
    <http://opensource.adobe.com/wiki/download/attachments/1114283/JP_amf0_spec_121207.pdf>}
@see: U{AMF documentation on OSFlash (external)
    <http://osflash.org/documentation/amf>}
"""

from __future__ import absolute_import

import datetime
import six
from six.moves import range

import miniamf
from . import util, codec, xml


#: Represented as 9 bytes: 1 byte for C{0x00} and 8 bytes a double
#: representing the value of the number.
TYPE_NUMBER = b'\x00'
#: Represented as 2 bytes: 1 byte for C{0x01} and a second, C{0x00}
#: for C{False}, C{0x01} for C{True}.
TYPE_BOOL = b'\x01'
#: Represented as 3 bytes + len(String): 1 byte C{0x02}, then a UTF8 string,
#: including the top two bytes representing string length as a C{int}.
TYPE_STRING = b'\x02'
#: Represented as 1 byte, C{0x03}, then pairs of UTF8 string, the key, and
#: an AMF element, ended by three bytes, C{0x00} C{0x00} C{0x09}.
TYPE_OBJECT = b'\x03'
#: MovieClip does not seem to be supported by Remoting.
#: It may be used by other AMF clients such as SharedObjects.
TYPE_MOVIECLIP = b'\x04'
#: 1 single byte, C{0x05} indicates null.
TYPE_NULL = b'\x05'
#: 1 single byte, C{0x06} indicates null.
TYPE_UNDEFINED = b'\x06'
#: When an ActionScript object refers to itself, such C{this.self = this},
#: or when objects are repeated within the same scope (for example, as the
#: two parameters of the same function called), a code of C{0x07} and an
#: C{int}, the reference number, are written.
TYPE_REFERENCE = b'\x07'
#: A MixedArray is indicated by code C{0x08}, then a Long representing the
#: highest numeric index in the array, or 0 if there are none or they are
#: all negative. After that follow the elements in key : value pairs.
TYPE_MIXEDARRAY = b'\x08'
#: @see: L{TYPE_OBJECT}
TYPE_OBJECTTERM = b'\x09'
#: An array is indicated by C{0x0A}, then a Long for array length, then the
#: array elements themselves. Arrays are always sparse; values for
#: inexistant keys are set to null (C{0x06}) to maintain sparsity.
TYPE_ARRAY = b'\x0A'
#: Date is represented as C{0x0B}, then a double, then an C{int}. The double
#: represents the number of milliseconds since 01/01/1970. The C{int}
#: represents the timezone offset in minutes between GMT. Note for the latter
#: than values greater than 720 (12 hours) are represented as M{2^16} - the
#: value. Thus GMT+1 is 60 while GMT-5 is 65236.
TYPE_DATE = b'\x0B'
#: LongString is reserved for strings larger then M{2^16} characters long. It
#: is represented as C{0x0C} then a LongUTF.
TYPE_LONGSTRING = b'\x0C'
#: Trying to send values which don't make sense, such as prototypes, functions,
#: built-in objects, etc. will be indicated by a single C{00x0D} byte.
TYPE_UNSUPPORTED = b'\x0D'
#: Remoting Server -> Client only.
#: @see: L{RecordSet}
#: @see: U{RecordSet structure on OSFlash
#: <http://osflash.org/documentation/amf/recordset>}
TYPE_RECORDSET = b'\x0E'
#: The XML element is indicated by C{0x0F} and followed by a LongUTF containing
#: the string representation of the XML object. The receiving gateway may which
#: to wrap this string inside a language-specific standard XML object, or
#: simply pass as a string.
TYPE_XML = b'\x0F'
#: A typed object is indicated by C{0x10}, then a UTF string indicating class
#: name, and then the same structure as a normal C{0x03} Object. The receiving
#: gateway may use a mapping scheme, or send back as a vanilla object or
#: associative array.
TYPE_TYPEDOBJECT = b'\x10'
#: An AMF message sent from an AVM+ client such as the Flash Player 9 may break
#: out into L{AMF3<miniamf.amf3>} mode. In this case the next byte will be the
#: AMF3 type code and the data will be in AMF3 format until the decoded object
#: reaches it's logical conclusion (for example, an object has no more keys).
TYPE_AMF3 = b'\x11'


class Context(codec.Context):
    """
    """

    def clear(self):
        codec.Context.clear(self)

        encoder = self.extra.get('amf3_encoder', None)

        if encoder:
            encoder.context.clear()

        decoder = self.extra.get('amf3_decoder', None)

        if decoder:
            decoder.context.clear()

    def getAMF3Encoder(self, amf0_encoder):
        encoder = self.extra.get('amf3_encoder', None)

        if encoder:
            return encoder

        encoder = miniamf.get_encoder(
            miniamf.AMF3,
            stream=amf0_encoder.stream,
            timezone_offset=amf0_encoder.timezone_offset
        )

        self.extra['amf3_encoder'] = encoder

        return encoder

    def getAMF3Decoder(self, amf0_decoder):
        decoder = self.extra.get('amf3_decoder', None)

        if decoder:
            return decoder

        decoder = miniamf.get_decoder(
            miniamf.AMF3,
            stream=amf0_decoder.stream,
            timezone_offset=amf0_decoder.timezone_offset
        )

        self.extra['amf3_decoder'] = decoder

        return decoder


class Decoder(codec.Decoder):
    """
    Decodes an AMF0 stream.
    """

    def buildContext(self, **kwargs):
        return Context(**kwargs)

    def getTypeFunc(self, data):
        # great for coverage, sucks for readability
        if data == TYPE_NUMBER:
            return self.readNumber
        elif data == TYPE_BOOL:
            return self.readBoolean
        elif data == TYPE_STRING:
            return self.readString
        elif data == TYPE_OBJECT:
            return self.readObject
        elif data == TYPE_NULL:
            return self.readNull
        elif data == TYPE_UNDEFINED:
            return self.readUndefined
        elif data == TYPE_REFERENCE:
            return self.readReference
        elif data == TYPE_MIXEDARRAY:
            return self.readMixedArray
        elif data == TYPE_ARRAY:
            return self.readList
        elif data == TYPE_DATE:
            return self.readDate
        elif data == TYPE_LONGSTRING:
            return self.readLongString
        elif data == TYPE_UNSUPPORTED:
            return self.readNull
        elif data == TYPE_XML:
            return self.readXML
        elif data == TYPE_TYPEDOBJECT:
            return self.readTypedObject
        elif data == TYPE_AMF3:
            return self.readAMF3

    def readNumber(self):
        """
        Reads a ActionScript C{Number} value.

        In ActionScript 1 and 2 the C{NumberASTypes} type represents all
        numbers, both floats and integers.

        @rtype: C{int} or C{float}
        """
        return _check_for_int(self.stream.read_double())

    def readBoolean(self):
        """
        Reads a ActionScript C{Boolean} value.

        @rtype: C{bool}
        @return: Boolean.
        """
        return bool(self.stream.read_uchar())

    def readString(self, bytes=False):
        """
        Reads a C{string} from the stream. If bytes is C{True} then you will
        get the raw data read from the stream, otherwise a string that has been
        B{utf-8} decoded.
        """
        l = self.stream.read_ushort()
        b = self.stream.read(l)

        if bytes:
            return b

        return self.context.getStringForBytes(b)

    def readNull(self):
        """
        Reads a ActionScript C{null} value.
        """
        return None

    def readUndefined(self):
        """
        Reads an ActionScript C{undefined} value.

        @return: L{Undefined<miniamf.Undefined>}
        """
        return miniamf.Undefined

    def readMixedArray(self):
        """
        Read mixed array.

        @rtype: L{miniamf.MixedArray}
        """
        # TODO: something with the length/strict
        self.stream.read_ulong()  # length

        obj = miniamf.MixedArray()
        self.context.addObject(obj)

        attrs = self.readObjectAttributes(obj)

        for key, value in six.iteritems(attrs):
            try:
                key = int(key)
            except ValueError:
                pass

            obj[key] = value

        return obj

    def readList(self):
        """
        Read a C{list} from the data stream.
        """
        obj = []
        self.context.addObject(obj)
        l = self.stream.read_ulong()

        for i in range(l):
            obj.append(self.readElement())

        return obj

    def readTypedObject(self):
        """
        Reads an aliased ActionScript object from the stream and attempts to
        'cast' it into a python class.

        @see: L{miniamf.register_class}
        """
        class_alias = self.readString()

        try:
            alias = self.context.getClassAlias(class_alias)
        except miniamf.UnknownClassAlias:
            if self.strict:
                raise

            alias = miniamf.TypedObjectClassAlias(class_alias)

        obj = alias.createInstance(codec=self)
        self.context.addObject(obj)

        attrs = self.readObjectAttributes(obj)
        alias.applyAttributes(obj, attrs, codec=self)

        return obj

    def readAMF3(self):
        """
        Read AMF3 elements from the data stream.

        @return: The AMF3 element read from the stream
        """
        return self.context.getAMF3Decoder(self).readElement()

    def readObjectAttributes(self, obj):
        obj_attrs = {}

        key = self.readString()

        while self.stream.peek() != TYPE_OBJECTTERM:
            obj_attrs[key] = self.readElement()
            key = self.readString()

        # discard the end marker (TYPE_OBJECTTERM)
        self.stream.read(1)

        return obj_attrs

    def readObject(self):
        """
        Reads an anonymous object from the data stream.

        @rtype: L{ASObject<miniamf.ASObject>}
        """
        obj = miniamf.ASObject()
        self.context.addObject(obj)

        obj.update(self.readObjectAttributes(obj))

        return obj

    def readReference(self):
        """
        Reads a reference from the data stream.

        @raise miniamf.ReferenceError: Unknown reference.
        """
        idx = self.stream.read_ushort()
        o = self.context.getObject(idx)

        if o is None:
            raise miniamf.ReferenceError('Unknown reference %d' % (idx,))

        return o

    def readDate(self):
        """
        Reads a UTC date from the data stream. Client and servers are
        responsible for applying their own timezones.

        Date: C{0x0B T7 T6} .. C{T0 Z1 Z2 T7} to C{T0} form a 64 bit
        Big Endian number that specifies the number of nanoseconds
        that have passed since 1/1/1970 0:00 to the specified time.
        This format is UTC 1970. C{Z1} and C{Z0} for a 16 bit Big
        Endian number indicating the indicated time's timezone in
        minutes.
        """
        ms = self.stream.read_double() / 1000.0
        self.stream.read_short()  # tz

        # Timezones are ignored
        d = util.get_datetime(ms)

        if self.timezone_offset:
            d = d + self.timezone_offset

        self.context.addObject(d)

        return d

    def readLongString(self):
        """
        Read UTF8 string.
        """
        l = self.stream.read_ulong()

        bytes = self.stream.read(l)

        return self.context.getStringForBytes(bytes)

    def readXML(self):
        """
        Read XML.
        """
        data = self.readLongString()
        root = xml.fromstring(
            data,
            forbid_dtd=self.context.forbid_dtd,
            forbid_entities=self.context.forbid_entities,
        )

        self.context.addObject(root)

        return root


class Encoder(codec.Encoder):
    """
    Encodes an AMF0 stream.

    @ivar use_amf3: A flag to determine whether this encoder should default to
        using AMF3. Defaults to C{False}
    @type use_amf3: C{bool}
    """

    def __init__(self, *args, **kwargs):
        codec.Encoder.__init__(self, *args, **kwargs)

        self.use_amf3 = kwargs.pop('use_amf3', False)

    def buildContext(self, **kwargs):
        return Context(**kwargs)

    def getTypeFunc(self, data):
        if self.use_amf3:
            return self.writeAMF3

        t = type(data)

        if t is miniamf.MixedArray:
            return self.writeMixedArray

        return codec.Encoder.getTypeFunc(self, data)

    def writeType(self, t):
        """
        Writes the type to the stream.

        @type   t: byte string
        @param  t: ActionScript type.
        """
        self.stream.write(t)

    def writeUndefined(self, data):
        """
        Writes the L{undefined<TYPE_UNDEFINED>} data type to the stream.

        @param data: Ignored, here for the sake of interface.
        """
        self.writeType(TYPE_UNDEFINED)

    def writeNull(self, n):
        """
        Write null type to data stream.
        """
        self.writeType(TYPE_NULL)

    def writeList(self, a):
        """
        Write array to the stream.

        @param a: The array data to be encoded to the AMF0 data stream.
        """
        if self.writeReference(a) != -1:
            return

        self.context.addObject(a)

        self.writeType(TYPE_ARRAY)
        self.stream.write_ulong(len(a))

        for data in a:
            self.writeElement(data)

    def writeNumber(self, n):
        """
        Write number to the data stream .

        @param  n: The number data to be encoded to the AMF0 data stream.
        """
        self.writeType(TYPE_NUMBER)
        self.stream.write_double(float(n))

    def writeBoolean(self, b):
        """
        Write boolean to the data stream.

        @param b: The boolean data to be encoded to the AMF0 data stream.
        """
        self.writeType(TYPE_BOOL)

        if b:
            self.stream.write_uchar(1)
        else:
            self.stream.write_uchar(0)

    def serialiseString(self, s):
        """
        Similar to L{writeString} but does not encode a type byte.
        """
        if isinstance(s, six.text_type):
            s = self.context.getBytesForString(s)

        l = len(s)

        if l > 0xffff:
            self.stream.write_ulong(l)
        else:
            self.stream.write_ushort(l)

        self.stream.write(s)

    def writeBytes(self, s):
        """
        Write a string of bytes to the data stream.
        """
        l = len(s)

        if l > 0xffff:
            self.writeType(TYPE_LONGSTRING)
        else:
            self.writeType(TYPE_STRING)

        if l > 0xffff:
            self.stream.write_ulong(l)
        else:
            self.stream.write_ushort(l)

        self.stream.write(s)

    def writeString(self, u):
        """
        Write a Unicode string to the data stream.
        """
        s = self.context.getBytesForString(u)

        self.writeBytes(s)

    def writeReference(self, o):
        """
        Write reference to the data stream.

        @param o: The reference data to be encoded to the AMF0 datastream.
        """
        idx = self.context.getObjectReference(o)

        if idx == -1 or idx > 65535:
            return -1

        self.writeType(TYPE_REFERENCE)
        self.stream.write_ushort(idx)

        return idx

    def _writeDict(self, o):
        """
        Write C{dict} to the data stream.

        @param o: The C{dict} data to be encoded to the AMF0 data stream.
        """

        # For consistency with AMF3, all string indices are output first,
        # then all integer indices.  We don't do any of the other special
        # handling for integer indices required in AMF3, though.
        int_items = []
        str_items = []

        for k, v in six.iteritems(o):
            if isinstance(k, six.integer_types):
                int_items.append((six.text_type(k).encode("utf-8"), v))
            elif isinstance(k, six.text_type):
                str_items.append((k.encode("utf-8"), v))
            elif isinstance(k, six.binary_type):
                str_items.append((k, v))
            else:
                raise miniamf.EncodeError(
                    "Unable to encode key %r in dict %r" % (k, o))

        int_items.sort()
        str_items.sort()

        for k, v in int_items:
            self.serialiseString(k)
            self.writeElement(v)

        for k, v in str_items:
            self.serialiseString(k)
            self.writeElement(v)

    def writeMixedArray(self, o):
        """
        Write mixed array to the data stream.

        @type o: L{miniamf.MixedArray}
        """
        if self.writeReference(o) != -1:
            return

        self.context.addObject(o)
        self.writeType(TYPE_MIXEDARRAY)

        # TODO: optimise this
        # work out the highest integer index
        try:
            # list comprehensions to save the day
            max_index = max([
                y[0] for y in o.items()
                if isinstance(y[0], six.integer_types)
            ])

            if max_index < 0:
                max_index = 0
        except ValueError:
            max_index = 0

        self.stream.write_ulong(max_index)

        self._writeDict(o)
        self._writeEndObject()

    def _writeEndObject(self):
        self.stream.write(b'\x00\x00' + TYPE_OBJECTTERM)

    def writeObject(self, o):
        """
        Write a Python object to the stream.

        @param o: The object data to be encoded to the AMF0 data stream.
        """
        if self.writeReference(o) != -1:
            return

        self.context.addObject(o)
        alias = self.context.getClassAlias(o.__class__)

        alias.compile()

        if alias.amf3:
            self.writeAMF3(o)

            return

        if alias.anonymous:
            self.writeType(TYPE_OBJECT)
        else:
            self.writeType(TYPE_TYPEDOBJECT)
            self.serialiseString(alias.alias)

        attrs = alias.getEncodableAttributes(o, codec=self)

        if alias.static_attrs and attrs:
            for key in sorted(alias.static_attrs):
                value = attrs.pop(key)

                self.serialiseString(key)
                self.writeElement(value)

        if attrs:
            self._writeDict(attrs)

        self._writeEndObject()

    def writeDate(self, d):
        """
        Writes a date to the data stream.

        @type d: Instance of C{datetime.datetime}
        @param d: The date to be encoded to the AMF0 data stream.
        """
        if isinstance(d, datetime.time):
            raise miniamf.EncodeError(
                'A datetime.time instance was found but AMF0 has no way to '
                'encode time objects. Please use datetime.datetime instead '
                '(got:%r)' % (d,)
            )

        # According to the Red5 implementation of AMF0, dates references are
        # created, but not used.
        if self.timezone_offset is not None:
            d -= self.timezone_offset

        secs = util.get_timestamp(d)
        tz = 0

        self.writeType(TYPE_DATE)
        self.stream.write_double(secs * 1000.0)
        self.stream.write_short(tz)

    def writeXML(self, e):
        """
        Writes an XML instance.
        """
        self.writeType(TYPE_XML)

        data = xml.tostring(e)

        if isinstance(data, six.text_type):
            data = data.encode('utf-8')

        self.stream.write_ulong(len(data))
        self.stream.write(data)

    def writeAMF3(self, data):
        """
        Writes an element in L{AMF3<miniamf.amf3>} format.
        """
        self.writeType(TYPE_AMF3)

        self.context.getAMF3Encoder(self).writeElement(data)


class RecordSet(object):
    """
    I represent the C{RecordSet} class used in Adobe Flash Remoting to hold
    (amongst other things) SQL records.

    @ivar columns: The columns to send.
    @type columns: List of strings.
    @ivar items: The C{RecordSet} data.
    @type items: List of lists, the order of the data corresponds to the order
        of the columns.
    @ivar service: Service linked to the C{RecordSet}.
    @type service:
    @ivar id: The id of the C{RecordSet}.
    @type id: string

    @see: U{RecordSet on OSFlash (external)
        <http://osflash.org/documentation/amf/recordset>}
    """

    class __amf__:
        alias = 'RecordSet'
        static = ('serverInfo',)
        dynamic = False

    def __init__(self, columns=[], items=[], service=None, id=None):
        self.columns = columns
        self.items = items
        self.service = service
        self.id = id

    def _get_server_info(self):
        ret = miniamf.ASObject(
            totalCount=len(self.items),
            cursor=1,
            version=1,
            initialData=self.items,
            columnNames=self.columns
        )

        if self.service is not None:
            ret.update({'serviceName': six.text_type(self.service['name'])})

        if self.id is not None:
            ret.update({'id': six.text_type(self.id)})

        return ret

    def _set_server_info(self, val):
        self.columns = val['columnNames']
        self.items = val['initialData']

        try:
            # TODO nick: find relevant service and link in here.
            self.service = dict(name=val['serviceName'])
        except KeyError:
            self.service = None

        try:
            self.id = val['id']
        except KeyError:
            self.id = None

    serverInfo = property(_get_server_info, _set_server_info)

    def __repr__(self):
        ret = '<%s.%s' % (self.__module__, self.__class__.__name__)

        if self.id is not None:
            ret += ' id=%s' % self.id

        if self.service is not None:
            ret += ' service=%s' % self.service

        ret += ' at 0x%x>' % id(self)

        return ret


miniamf.register_class(RecordSet)


def _check_for_int(x):
    """
    This is a compatibility function that takes a C{float} and converts it to
    an C{int} if the values are equal.
    """
    try:
        y = int(x)
    except (OverflowError, ValueError):
        pass
    else:
        # There is no way in AMF0 to distinguish between integers and floats
        if x == x and y == x:
            return y

    return x
