""" Classes and functions for creating and parsing FCP messages.

    Copyright (C) 2008 Darrell Karbott

    This library is free software; you can redistribute it and/or
    modify it under the terms of the GNU General Public
    License as published by the Free Software Foundation; either
    version 2.0 of the License, or (at your option) any later version.

    This library is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
    General Public License for more details.

    You should have received a copy of the GNU General Public
    License along with this library; if not, write to the Free Software
    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA

    Author: djk@isFiaD04zgAgnrEC5XJt1i4IE7AkNPqhBG5bONi6Yks

    An FCP message is represented as a
    (msg_name, msg_values_dict) tuple.

    Some message e.g. AllData may have a third entry
    which contains the raw data string for the FCP
    message's trailing data.
"""

#-----------------------------------------------------------#
# FCP mesage creation helper functions
#-----------------------------------------------------------#

def merge_params(params, allowed, defaults = None):
    """ Return a new dictionary instance containing only the values
        which have keys in the allowed field list.

        Values are taken from defaults only if they are not
        set in params.
    """

    ret = {}
    for param in allowed:
        if param in params:
            ret[param] = params[param]
        elif defaults and param in defaults:
            ret[param] = defaults[param]
    return ret

def format_params(params, allowed, required):
    """ INTERNAL: Format params into an FCP message body string. """

    ret = ''
    for field in params:
        if not field in allowed:
            raise ValueError("Illegal field [%s]." % field)

    for field in allowed:
        if field in params:
            if field == 'Files':
                # Special case Files dictionary.
                assert params['Files']
                for subfield in params['Files']:
                    ret += "%s=%s\n" % (subfield, params['Files'][subfield])
                continue
            value = str(params[field])
            if not value:
                raise ValueError("Illegal value for field [%s]." % field)
            if value.lower() == 'true' or value.lower() == 'false':
                value = value.lower()
            ret += "%s=%s\n" % (field, value)
        elif field in required:
            #print "FIELD:", field, required
            raise ValueError("A required field [%s] was not set." % field)
    return ret

# REDFLAG: remove trailing_data?
def make_request(definition, params, defaults = None, trailing_data = None):
    """ Make a request message string from a definition tuple
        and params parameters dictionary.

        Values for allowed parameters not specified in params are
        taken from defaults if they are present and params IS
        UPDATED to include these values.

        A definition tuple has the following entries:
        (msg_name, allowed_fields, required_fields, contraint_func)

        msg_name is the FCP message name.
        allowed_fields is a sequence of field names which are allowed
           in params.
        required_fields is a sequence of field names which are required
           in params. If this is None all the allowed fields are
           assumed to be required.
        constraint_func is a function which takes definitions, params
           arguments and can raise if contraints on the params values
           are not met. This can be None.
    """

    #if 'Identifier' in params:
    #    print "MAKE_REQUEST: ", definition[0], params['Identifier']
    #else:
    #    print "MAKE_REQUEST: ", definition[0], "NO_IDENTIFIER"

    #print "DEFINITION:"
    #print definition
    #print "PARAMS:"
    #print params
    name, allowed, required, constraint_func = definition
    assert name

    real_params = merge_params(params, allowed, defaults)

    # Don't force repetition if required is the same.
    if required is None:
        required = allowed

    ret = name + '\n' + format_params(real_params, allowed, required) \
          + 'EndMessage\n'

    # Run extra checks on parameter values
    # Order is important.  Format_params can raise on missing fields.
    if constraint_func:
        constraint_func(definition, real_params)

    if trailing_data:
        ret += trailing_data

    params.clear()
    params.update(real_params)

    return ret

#-----------------------------------------------------------#
# FCP request definitions for make_request()
#-----------------------------------------------------------#

def get_constraint(dummy, params):
    """ INTERNAL: Check get params. """
    if 'ReturnType' in params and params['ReturnType'] != 'disk':
        if 'Filename' in params or 'TempFilename' in params:
            raise ValueError("'Filename' and 'TempFileName' only allowed" \
                             + " when 'ReturnType' is disk.")

def put_file_constraint(dummy, params):
    """ INTERNAL: Check put_file params. """
    # Hmmmm... this only checks for required arguments, it
    # doesn't report values that have no effect.
    upload_from = 'direct'
    if 'UploadFrom' in params:
        upload_from = params['UploadFrom']
    if upload_from == 'direct':
        if not 'DataLength' in params:
            raise ValueError("'DataLength' MUST be set, 'UploadFrom =="
                                 + " 'direct'.")
    elif upload_from == 'disk':
        if not 'Filename' in params:
            raise ValueError("'Filename' MUST be set, 'UploadFrom =="
                             + " 'disk'.")
        elif upload_from == 'redirect':
            if not 'TargetURI' in params:
                raise ValueError("'TargetURI' MUST be set, 'UploadFrom =="
                                 + " 'redirect'.")
    else:
        raise ValueError("Unknown value, 'UploadFrom' == %s" % upload_from)


HELLO_DEF = ('ClientHello', ('Name', 'ExpectedVersion'), None, None)

# Identifier not included in doc?
GETNODE_DEF = ('GetNode', ('Identifier', 'GiveOpennetRef', 'WithPrivate',
                           'WithVolatile'),
               None, None)

#IMPORTANT: One entry tuple MUST have trailing comma or it will evaluate
#           to a string instead of a tuple.
GENERATE_SSK_DEF = ('GenerateSSK', ('Identifier',), None, None)
GET_REQUEST_URI_DEF = ('ClientPut',
                       ('URI', 'Identifier', 'MaxRetries', 'PriorityClass',
                        'UploadFrom', 'DataLength', 'GetCHKOnly'),
                       None, None)
GET_DEF = ('ClientGet',
           ('IgnoreDS', 'DSOnly', 'URI', 'Identifier', 'Verbosity',
            'MaxSize', 'MaxTempSize', 'MaxRetries', 'PriorityClass',
            'Persistence', 'ClientToken', 'Global', 'ReturnType',
            'BinaryBlob', 'AllowedMimeTypes', 'FileName', 'TmpFileName'),
           ('URI', 'Identifier'),
           get_constraint)
PUT_FILE_DEF = ('ClientPut',
                ('URI', 'Metadata.ContentType', 'Identifier', 'Verbosity',
                 'MaxRetries', 'PriorityClass', 'GetCHKOnly', 'Global',
                 'DontCompress','ClientToken', 'Persistence',
                 'TargetFilename', 'EarlyEncode', 'UploadFrom', 'DataLength',
                 'Filename', 'TargetURI', 'FileHash', 'BinaryBlob'),
                ('URI', 'Identifier'),
                put_file_constraint)
PUT_REDIRECT_DEF = ('ClientPut',
                    ('URI', 'Metadata.ContentType', 'Identifier', 'Verbosity',
                     'MaxRetries', 'PriorityClass', 'GetCHKOnly', 'Global',
                     'ClientToken', 'Persistence', 'UploadFrom',
                     'TargetURI'),
                    ('URI', 'Identifier', 'TargetURI'),
                    None)
PUT_COMPLEX_DIR_DEF = ('ClientPutComplexDir',
                       ('URI', 'Identifier', 'Verbosity',
                        'MaxRetries', 'PriorityClass', 'GetCHKOnly', 'Global',
                        'DontCompress', 'ClientToken', 'Persistence',
                        'TargetFileName', 'EarlyEncode', 'DefaultName',
                        'Files'), #<- one off code in format_params() for this
                       ('URI', 'Identifier'),
                       None)

REMOVE_REQUEST_DEF = ('RemoveRequest', ('Identifier', 'Global'), None, None)

# REDFLAG: Shouldn't assert on bad data! raise instead.
# Hmmmm... I hacked this together by unwinding a "pull" parser
# to make a "push" parser.  Feels like there's too much code here.
class FCPParser:
    """Parse a raw byte stream into FCP messages and trailing data blobs.

       Push bytes into the parser by calling FCPParser.parse_bytes().
       Set FCPParser.msg_callback to get the resulting FCP messages.
       Set FCPParser.context_callback to control how trailing data is written.
       See RequestContext in the fcpconnection module for an example of how
       contexts are supposed to work.

       NOTE: This only handles byte level presentation. It DOES NOT validate
             that the incoming messages are correct w.r.t. the FCP 2.0 spec.
    """
    def __init__(self):
        self.msg = None
        self.prev_chunk = ""
        self.data_context = None

        # lambda's prevent pylint E1102 warning
        # Called for each parsed message.
        self.msg_callback = lambda msg:None

        # MUST set this callback.
        # Return the RequestContext for the request_id
        self.context_callback = None #lambda request_id:RequestContext()

    def handle_line(self, line):
        """ INTERNAL: Process a single line of an FCP message. """
        if not line:
            return False

        if not self.msg:
            # Start of a new message
            self.msg = [line, {}]
            return False

        pos = line.find('=')
        if pos != -1:
            # name=value pair
            fields = (line[:pos], line[pos + 1:])
            # CANNOT just split
            # fields = line.split('=')
            # e.g.
            # ExtraDescription=Invalid precompressed size: 81588 maxlength=10
            assert len(fields) ==  2
            self.msg[1][fields[0].strip()] = fields[1].strip()
        else:
            # end of message line
            if line == 'Data':
                # Handle trailing data
                assert self.msg
                # REDFLAG: runtime protocol error (should never happen)
                assert 'Identifier' in self.msg[1]
                assert not self.data_context
                self.data_context = self.context_callback(self.msg[1]
                                                          ['Identifier'])
                self.data_context.data_sink.initialize(int(self.msg[1]
                                                           ['DataLength']),
                                                       self.data_context.
                                                       file_name)
                return True

            assert line == 'End' or line == 'EndMessage'
            msg = self.msg
            self.msg = None
            assert not self.data_context or self.data_context.writable() == 0
            self.msg_callback(msg)

        return False

    def handle_data(self, data):
        """ INTERNAL: Handle trailing data following an FCP message. """
        #print "RECVD: ", len(data), "bytes of data."
        assert self.data_context
        self.data_context.data_sink.write_bytes(data)
        if self.data_context.writable() == 0:
            assert self.msg
            msg = self.msg
            self.msg = None
            self.data_context = None
            self.msg_callback(msg)

    def parse_bytes(self, bytes):
        """ This method drives an FCP Message parser and eventually causes
            calls into msg_callback().
        """
        #print "FCPParser.parse_bytes -- called"
        if self.data_context and self.data_context.writable():
            # Expecting raw data.
            assert not self.prev_chunk
            data = bytes[:self.data_context.writable()]
            self.handle_data(data) # MUST handle msg notification!
            bytes = bytes[len(data):]
            if bytes:
                # Hmmm... recursion depth
                self.parse_bytes(bytes)
        else:
            # Expecting a \n terminated line.
            bytes = self.prev_chunk + bytes
            self.prev_chunk = ""
            last_eol = -1
            pos = bytes.find('\n')
            while pos != -1:
                if last_eol <= 0:
                    last_eol = 0

                line = bytes[last_eol:pos].strip()
                last_eol = pos
                if self.handle_line(line):
                    # Reading trailing data
                    # Hmmm... recursion depth
                    self.parse_bytes(bytes[last_eol + 1:])
                    return
                pos = bytes.find('\n', last_eol + 1)

            assert not self.data_context or not self.data_context.writable()
            self.prev_chunk = bytes[last_eol + 1:]