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

"""
AMF Remoting support.

A Remoting request from the client consists of a short preamble, headers, and
bodies. The preamble contains basic information about the nature of the
request. Headers can be used to request debugging information, send
authentication info, tag transactions, etc. Bodies contain actual Remoting
requests and responses. A single Remoting envelope can contain several
requests; Remoting supports batching out of the box.

Client headers and bodies need not be responded to in a one-to-one manner.
That is, a body or header may not require a response. Debug information is
requested by a header but sent back as a body object. The response index is
essential for the Adobe Flash Player to understand the response therefore.

@see: U{Remoting Envelope on OSFlash
    <http://osflash.org/documentation/amf/envelopes/remoting>}
@see: U{Remoting Headers on OSFlash
    <http://osflash.org/documentation/amf/envelopes/remoting/headers>}
@see: U{Remoting Debug Headers on OSFlash
    <http://osflash.org/documentation/amf/envelopes/remoting/debuginfo>}

@since: 0.1
"""

import pyamf
from pyamf import util


__all__ = ['Envelope', 'Request', 'Response', 'decode', 'encode']

#: Succesful call.
STATUS_OK = 0
#: Reserved for runtime errors.
STATUS_ERROR = 1
#: Debug information.
STATUS_DEBUG = 2

#: List of available status response codes.
STATUS_CODES = {
    STATUS_OK: '/onResult',
    STATUS_ERROR: '/onStatus',
    STATUS_DEBUG: '/onDebugEvents'
}

#: AMF mimetype.
CONTENT_TYPE = 'application/x-amf'

ERROR_CALL_FAILED, = range(1)
ERROR_CODES = {
    ERROR_CALL_FAILED: 'Server.Call.Failed'
}

APPEND_TO_GATEWAY_URL = 'AppendToGatewayUrl'
REPLACE_GATEWAY_URL = 'ReplaceGatewayUrl'
REQUEST_PERSISTENT_HEADER = 'RequestPersistentHeader'


class RemotingError(pyamf.BaseError):
    """
    Generic remoting error class.
    """


class RemotingCallFailed(RemotingError):
    """
    Raised if B{Server.Call.Failed} received.
    """

pyamf.add_error_class(RemotingCallFailed, ERROR_CODES[ERROR_CALL_FAILED])


class HeaderCollection(dict):
    """
    Collection of AMF message headers.
    """

    def __init__(self, raw_headers={}):
        self.required = []

        for (k, ig, v) in raw_headers:
            self[k] = v
            if ig:
                self.required.append(k)

    def is_required(self, idx):
        """
        @raise KeyError: Unknown header found.
        """
        if idx not in self:
            raise KeyError("Unknown header %s" % str(idx))

        return idx in self.required

    def set_required(self, idx, value=True):
        """
        @raise KeyError: Unknown header found.
        """
        if idx not in self:
            raise KeyError("Unknown header %s" % str(idx))

        if idx not in self.required and value:
            self.required.append(idx)

    def __len__(self):
        return len(self.keys())


class Envelope(object):
    """
    I wrap an entire request, encapsulating headers and bodies.

    There can be more than one request in a single transaction.

    @ivar amfVersion: AMF encoding version. See L{pyamf.ENCODING_TYPES}
    @type amfVersion: C{int} or C{None}
    @ivar headers: AMF headers, a list of name, value pairs. Global to each
        request.
    @type headers: L{HeaderCollection}
    @ivar bodies: A list of requests/response messages
    @type bodies: C{list} containing tuples of the key of the request and the
        L{Message}
    """

    def __init__(self, amfVersion=None):
        self.amfVersion = amfVersion
        self.headers = HeaderCollection()
        self.bodies = []

    def __repr__(self):
        r = "<Envelope amfVersion=%r>\n" % (self.amfVersion,)

        for h in self.headers:
            r += " " + repr(h) + "\n"

        for request in iter(self):
            r += " " + repr(request) + "\n"

        r += "</Envelope>"

        return r

    def __setitem__(self, name, value):
        if not isinstance(value, Message):
            raise TypeError("Message instance expected")

        idx = 0
        found = False

        for body in self.bodies:
            if name == body[0]:
                self.bodies[idx] = (name, value)
                found = True

            idx = idx + 1

        if not found:
            self.bodies.append((name, value))

        value.envelope = self

    def __getitem__(self, name):
        for body in self.bodies:
            if name == body[0]:
                return body[1]

        raise KeyError("'%r'" % (name,))

    def __nonzero__(self):
        return len(self.bodies) != 0 or len(self.headers) != 0

    def __iter__(self):
        for body in self.bodies:
            yield body[0], body[1]

        raise StopIteration

    def __len__(self):
        return len(self.bodies)

    def iteritems(self):
        for body in self.bodies:
            yield body

        raise StopIteration

    def keys(self):
        return [body[0] for body in self.bodies]

    def items(self):
        return self.bodies

    def __contains__(self, name):
        for body in self.bodies:
            if name == body[0]:
                return True

        return False

    def __eq__(self, other):
        if isinstance(other, Envelope):
            return (
                self.amfVersion == other.amfVersion and
                self.headers == other.headers and
                self.bodies == other.bodies
            )

        if hasattr(other, 'keys') and hasattr(other, 'items'):
            keys, o_keys = self.keys(), other.keys()

            if len(o_keys) != len(keys):
                return False

            for k in o_keys:
                if k not in keys:
                    return False

                keys.remove(k)

            for k, v in other.items():
                if self[k] != v:
                    return False

            return True


class Message(object):
    """
    I represent a singular request/response, containing a collection of
    headers and one body of data.

    I am used to iterate over all requests in the :class:`Envelope`.

    @ivar envelope: The parent L{envelope<Envelope>} of this AMF Message.
    @type envelope: L{Envelope}
    @ivar body: The body of the message.
    @ivar headers: The message headers. Dict like in behaviour.
    """

    def __init__(self, envelope, body):
        self.envelope = envelope
        self.body = body

    def _get_headers(self):
        return self.envelope.headers

    headers = property(_get_headers)


class Request(Message):
    """
    An AMF Request payload.

    @ivar target: The C{string} target of the request.
    """

    def __init__(self, target, body=[], envelope=None):
        Message.__init__(self, envelope, body)

        self.target = target

    def __repr__(self):
        return "<%s target=%s>%s</%s>" % (
            type(self).__name__,
            repr(self.target),
            repr(self.body),
            type(self).__name__
        )


class Response(Message):
    """
    An AMF Response.

    @ivar status: The status of the message. Default is L{STATUS_OK}.
    @type status: Member of L{STATUS_CODES}.
    """

    def __init__(self, body, status=STATUS_OK, envelope=None):
        Message.__init__(self, envelope, body)

        self.status = status

    def __repr__(self):
        return "<%s status=%s>%s</%s>" % (
            type(self).__name__, _get_status(self.status), repr(self.body),
            type(self).__name__
        )


class BaseFault(object):
    """
    I represent a fault message (C{mx.rpc.Fault}).

    @ivar level: The level of the fault.
    @ivar code: A simple code describing the fault.
    @ivar details: Any extra details of the fault.
    @ivar description: A longer description of the fault.

    @see: U{mx.rpc.Fault on Livedocs
          <http://help.adobe.com/en_US/FlashPlatform/reference/actionscript/3/mx/rpc/Fault.html>}
    """

    level = None

    class __amf__:
        static = ('level', 'code', 'type', 'details', 'description')

    def __init__(self, *args, **kwargs):
        self.code = kwargs.get('code', '')
        self.type = kwargs.get('type', '')
        self.details = kwargs.get('details', '')
        self.description = kwargs.get('description', '')

    def __repr__(self):
        x = '%s level=%s' % (self.__class__.__name__, self.level)

        if self.code not in ('', None):
            x += ' code=%s' % repr(self.code)
        if self.type not in ('', None):
            x += ' type=%s' % repr(self.type)
        if self.description not in ('', None):
            x += ' description=%s' % repr(self.description)

        if self.details not in ('', None):
            x += '\nTraceback:\n%s' % (repr(self.details),)

        return x

    def raiseException(self):
        """
        Raises an exception based on the fault object. There is no traceback
        available.
        """
        raise get_exception_from_fault(self), self.description, None


class ErrorFault(BaseFault):
    """
    I represent an error level fault.
    """

    level = 'error'


def _read_header(stream, decoder, strict=False):
    """
    Read AMF L{Message} header from the stream.

    @type stream: L{BufferedByteStream<pyamf.util.BufferedByteStream>}
    @param decoder: An AMF0 decoder.
    @param strict: Use strict decoding policy. Default is C{False}. Will raise
        a L{pyamf.DecodeError} if the data that was read from the stream does
        not match the header length.
    @type strict: C{boolean}
    @return: A C{tuple} containing the name of the header, a C{bool}
        determining if understanding this header is required and the decoded
        data.
    @note: Quite what understanding required headers actually means is unknown.
    @raise DecodeError: Data read from stream does not match header length.
    """
    name_len = stream.read_ushort()
    name = stream.read_utf8_string(name_len)

    required = bool(stream.read_uchar())

    data_len = stream.read_ulong()
    pos = stream.tell()

    data = decoder.readElement()

    if strict and pos + data_len != stream.tell():
        raise pyamf.DecodeError(
            "Data read from stream does not match header length")

    return (name, required, data)


def _write_header(name, header, required, stream, encoder, strict=False):
    """
    Write AMF message header.

    @param name: Name of the header.
    @param header: Header value.
    @param required: Whether understanding this header is required (?).
    @param stream: L{BufferedByteStream<pyamf.util.BufferedByteStream>} that
        will receive the encoded header.
    @param encoder: An encoder capable of encoding C{AMF0}.
    @param strict: Use strict encoding policy. Default is C{False}. Will write
        the correct header length after writing the header.
    @type strict: C{boolean}
    """
    stream.write_ushort(len(name))
    stream.write_utf8_string(name)

    stream.write_uchar(required)
    write_pos = stream.tell()

    stream.write_ulong(0)
    old_pos = stream.tell()
    encoder.writeElement(header)
    new_pos = stream.tell()

    if strict:
        stream.seek(write_pos)
        stream.write_ulong(new_pos - old_pos)
        stream.seek(new_pos)


def _read_body(stream, decoder, strict=False, logger=None):
    """
    Read an AMF message body from the stream.

    @type stream: L{BufferedByteStream<pyamf.util.BufferedByteStream>}
    @param decoder: An AMF0 decoder.
    @param strict: Use strict decoding policy. Default is C{False}.
    @type strict: C{boolean}
    @param logger: Used to log interesting events whilst reading a remoting
        body.
    @type logger: A C{logging.Logger} instance or C{None}
    @return: A C{tuple} containing the C{id} of the request and the L{Request}
        or L{Response}
    """
    def _read_args():
        # we have to go through this insanity because it seems that amf0
        # does not keep the array of args in the object references lookup
        type_byte = stream.peek(1)

        if type_byte == '\x11':
            if not decoder.use_amf3:
                raise pyamf.DecodeError(
                    "Unexpected AMF3 type with incorrect message type")

            return decoder.readElement()

        if type_byte != '\x0a':
            raise pyamf.DecodeError("Array type required for request body")

        stream.read(1)
        x = stream.read_ulong()

        return [decoder.readElement() for i in xrange(x)]

    target = stream.read_utf8_string(stream.read_ushort())
    response = stream.read_utf8_string(stream.read_ushort())

    status = STATUS_OK
    is_request = True

    for code, s in STATUS_CODES.iteritems():
        if not target.endswith(s):
            continue

        is_request = False
        status = code
        target = target[:0 - len(s)]

    data_len = stream.read_ulong()
    pos = stream.tell()

    if is_request:
        data = _read_args()
    else:
        data = decoder.readElement()

    if strict and pos + data_len != stream.tell():
        raise pyamf.DecodeError(
            "Data read from stream does not match body length (%d != %d)" % (
                pos + data_len, stream.tell(),
            )
        )

    if is_request:
        return response, Request(target, body=data)

    if status == STATUS_ERROR and isinstance(data, pyamf.ASObject):
        data = get_fault(data)

    return target, Response(data, status)


def _write_body(name, message, stream, encoder, strict=False):
    """
    Write AMF message body.

    @param name: The name of the request.
    @param message: The AMF L{Message}
    @type stream: L{BufferedByteStream<pyamf.util.BufferedByteStream>}
    @param encoder: An AMF0 encoder.
    @param strict: Use strict encoding policy. Default is C{False}.
    @type strict: C{boolean}
    """
    def _encode_body(message):
        if isinstance(message, Response):
            encoder.writeElement(message.body)

            return

        stream.write('\x0a')
        stream.write_ulong(len(message.body))
        for x in message.body:
            encoder.writeElement(x)

    if not isinstance(message, (Request, Response)):
        raise TypeError("Unknown message type")

    target = None

    if isinstance(message, Request):
        target = unicode(message.target)
    else:
        target = u"%s%s" % (name, _get_status(message.status))

    target = target.encode('utf8')

    stream.write_ushort(len(target))
    stream.write_utf8_string(target)

    response = 'null'

    if isinstance(message, Request):
        response = name

    stream.write_ushort(len(response))
    stream.write_utf8_string(response)

    if not strict:
        stream.write_ulong(0)
        _encode_body(message)

        return

    write_pos = stream.tell()
    stream.write_ulong(0)
    old_pos = stream.tell()

    _encode_body(message)
    new_pos = stream.tell()

    stream.seek(write_pos)
    stream.write_ulong(new_pos - old_pos)
    stream.seek(new_pos)


def _get_status(status):
    """
    Get status code.

    @see: L{STATUS_CODES}
    @raise ValueError: Unknown C{status} code.
    """
    if status not in STATUS_CODES:
        # TODO print that status code..
        raise ValueError("Unknown status code")

    return STATUS_CODES[status]


def get_fault_class(level, **kwargs):
    if level == 'error':
        return ErrorFault

    return BaseFault


def get_fault(data):
    try:
        level = data['level']
        del data['level']
    except KeyError:
        level = 'error'

    e = {}

    for x, y in data.iteritems():
        if isinstance(x, unicode):
            e[str(x)] = y
        else:
            e[x] = y

    return get_fault_class(level, **e)(**e)


def decode(stream, strict=False, logger=None, timezone_offset=None):
    """
    Decodes the incoming stream as a remoting message.

    @type stream: L{BufferedByteStream<pyamf.util.BufferedByteStream>}
    @param strict: Enforce strict decoding. Default is C{False}.
    @type strict: C{boolean}
    @param logger: Used to log interesting events whilst decoding a remoting
        message.
    @type logger: U{logging.Logger<http://
        docs.python.org/library/logging.html#loggers>}
    @param timezone_offset: The difference between the current timezone and
        UTC. Date/times should always be handled in UTC to avoid confusion but
        this is required for legacy systems.
    @type timezone_offset: U{datetime.datetime.timedelta<http://
        docs.python.org/library/datetime.html#datetime.timedelta>}

    @return: Message L{envelope<Envelope>}.
    @rtype: L{Envelope}
    """
    if not isinstance(stream, util.BufferedByteStream):
        stream = util.BufferedByteStream(stream)

    msg = Envelope()
    msg.amfVersion = stream.read_ushort()

    # see http://osflash.org/documentation/amf/envelopes/remoting#preamble
    # why we are doing this...
    if msg.amfVersion > 0x09:
        raise pyamf.DecodeError(
            "Malformed stream (amfVersion=%d)" % (
                msg.amfVersion,
            )
        )

    decoder = pyamf.get_decoder(
        pyamf.AMF0,
        stream,
        strict=strict,
        timezone_offset=timezone_offset
    )
    context = decoder.context

    decoder.use_amf3 = msg.amfVersion == pyamf.AMF3
    header_count = stream.read_ushort()

    for i in xrange(header_count):
        name, required, data = _read_header(stream, decoder, strict)
        msg.headers[name] = data

        if required:
            msg.headers.set_required(name)

    body_count = stream.read_short()

    for i in xrange(body_count):
        context.clear()

        target, payload = _read_body(stream, decoder, strict, logger)
        msg[target] = payload

    if strict and stream.remaining() > 0:
        raise RuntimeError("Unable to fully consume the buffer")

    return msg


def encode(msg, strict=False, logger=None, timezone_offset=None):
    """
    Encodes and returns the L{msg<Envelope>} as an AMF stream.

    @param strict: Enforce strict encoding. Default is C{False}. Specifically
        header/body lengths will be written correctly, instead of the default
        0. Default is C{False}. Introduced in 0.4.
    @type strict: C{boolean}
    @param logger: Used to log interesting events whilst decoding a remoting
        message.
    @type logger: U{logging.Logger<http://
        docs.python.org/library/logging.html#loggers>}
    @param timezone_offset: The difference between the current timezone and
        UTC. Date/times should always be handled in UTC to avoid confusion but
        this is required for legacy systems.
    @type timezone_offset: U{datetime.datetime.timedelta<http://
        docs.python.org/library/datetime.html#datetime.timedelta>}
    @rtype: L{BufferedByteStream<pyamf.util.BufferedByteStream>}
    """
    stream = util.BufferedByteStream()

    encoder = pyamf.get_encoder(
        pyamf.AMF0,
        stream,
        strict=strict,
        timezone_offset=timezone_offset,
    )

    if msg.amfVersion == pyamf.AMF3:
        encoder.use_amf3 = True

    stream.write_ushort(msg.amfVersion)
    stream.write_ushort(len(msg.headers))

    for name, header in msg.headers.iteritems():
        _write_header(
            name,
            header,
            int(msg.headers.is_required(name)),
            stream,
            encoder,
            strict,
        )

    stream.write_short(len(msg))

    for name, message in msg.iteritems():
        encoder.context.clear()

        _write_body(name, message, stream, encoder, strict)

    stream.seek(0)

    return stream


def get_exception_from_fault(fault):
    return pyamf.ERROR_CLASS_MAP.get(fault.code, RemotingError)


pyamf.register_class(ErrorFault)
