#!/usr/bin/python
# Copyright 2017 VMware, Inc.  All rights reserved. -- VMware Confidential
#
import json
import logging
import sys
import platform
import io

logger = logging.getLogger(__name__)

def changeConvertSpec(formatText):
    '''Change %s to %(num)s in the formatText if the conversion specs are
    positional. Return the same formatText if the conversion specs are
    key based. Numbers are assigned starting with 0.
    User should use %% to escape a % if he needs the % to be in the output

    @param formatText: The format text that could be used with the python string
    formatting operator, or the logger functions.
    @return (str): The converted format text
    '''
    result = ''
    cur = 0
    size = len(formatText)
    keywordFound = False
    skipPercentChar = False
    replaced = False
    for i, c in enumerate(formatText):
        result += '%s' % c
        if c == '%':
            if skipPercentChar:
                skipPercentChar = False
                continue
            if i+1 < size:
                if formatText[i+1] == '%':
                    # Skip next % if see two consecutive %'s
                    skipPercentChar = True
                    continue
                elif formatText[i+1] == '(':
                    if replaced:
                        raise Exception('Cannot mix keyword and position modes'
                                    'in the format text: %s' % formatText)
                    keywordFound = True
                else:
                    if keywordFound:
                        raise Exception('Cannot mix keyword and position modes'
                                    'in the format text: %s' % formatText)
                    replaced = True
                    result += '(%d)' % cur
                    cur += 1
    return result

class MessageMetadata(object):
    '''Encapsulates a message id and its message format string
    Applications use the __init__ function to define localizable messages.
    Their code would do:
        from i18n import MessageMetadata as _T
        esx_incompatible = _T('framework.esx.incompatible',
             'ESX host %%(host)s of version %%(version)2.1f is not compatible')
    A tool runs at the build time to scan the application source code
    and collects all these message definitions and save them for translations.
    '''
    def __init__(self, msgId, formatText):
        self.msgId = msgId
        self.originalText = formatText
        self.translatableText = changeConvertSpec(formatText)

def jsonEncodeMessageArg(arg):
    '''Encode a single actual parameter of a message.
    @param arg: One of the acutal parameters to format a message.
    @return : If the arg is a primitive, return the same arg
              If the arg is an object, call the encode method on the object.
              Currently supported object type: L10nMessage which
              allows parameter translation.
    '''
    import six

    if isinstance(arg, L10nMessage):
        return arg.jsonEncode()

    if isinstance(arg, (float, bool, six.text_type, six.integer_types, six.binary_type)):
        return arg

    raise ValueError("Bad message arg to encode: %s" % arg)

def jsonDecodeMessageArg(arg):
    '''Decode a single actual parameter
    @param arg: A encoded actual parameter
    @return : The decode actual parameter
              If the arg is a primitive, return the same arg
              If the arg is an dictionary, decode into an object.
              Currently supported object type: L10nMessage which
              allows parameter translation.
    '''
    import six

    if isinstance(arg, dict):
        return L10nMessage.jsonDecode(arg)

    if isinstance(arg, (float, bool, six.text_type, six.integer_types, six.binary_type)):
        return arg

    raise ValueError("Bad message arg to decode: %s" % arg)

def jsonEncodeMessageArgs(args):
    '''Encode the actual parameters.
    @param args: The actual parameters to format a message.
                 Could be a single parameter, a list, or a dictionary.
    @return : If args is a single parameter, create a list of length
              one and encode the single parameter in the list.
              If args is a list, return a list where each parameter
              is encoded and appended in the same order as the parameter list.
              If args is a dictionary, return a dictionary which maps the
              original keys to their encoded value.
    Note: It may be better to convert all positional args into key based.
          TBD later.
    '''
    result = None

    if isinstance(args, (list, tuple)):
        result = []
        for value in args:
            result.append(jsonEncodeMessageArg(value))
        return result

    if isinstance(args, dict):
        result = {}
        for key, value in args.items():
            result[key] = jsonEncodeMessageArg(value)
        return result

    # canonicalize single arg to a list of one arg.
    result = []
    result.append(jsonEncodeMessageArg(args))
    return result

def jsonDecodeMessageArgs(args):
    '''Decode the actual parameters.
    @param args: The encoded args
                 The encoded args can only be in list or dict.
                 See jsonEncodeMessageArgs for details.
    @return : The decoded args
    '''
    result = None

    if isinstance(args, list):
        result = []
        for value in args:
            result.append(jsonDecodeMessageArg(value))
        return result

    if isinstance(args, dict):
        result = {}
        for key, value in args.items():
            result[key] = jsonDecodeMessageArg(value)
        return result

    raise ValueError("Bad message args to decode: %s" % args)

def _strToUnicode(noneUniCodeStr):
    '''Method to encode byte_type to text_type. If it cannot achieve that it will
    return the same byte_type

    @param noneUniCodeStr: Byte_type to encode to text_type
    @type str (Python2) or bytes (Python3)
    '''
    import six

    if not isinstance(noneUniCodeStr, six.binary_type):
        return noneUniCodeStr

    # Try with file system encoding, if it is not that, try with couple more
    # and after that give up
    try:
        return six.text_type(noneUniCodeStr, sys.getfilesystemencoding())
    except (UnicodeDecodeError, LookupError):
        pass

    # if it is windows its more likely to succeed with mbcs, however if
    # that fails we will try with utf-8 and then give up
    if platform.system().lower() == 'windows':
        try:
            return six.text_type(noneUniCodeStr, 'mbcs')
        except (UnicodeDecodeError, LookupError):
            pass

    # valid ascii is valid utf-8 so no point of trying ascii
    try:
        return six.text_type(noneUniCodeStr, 'utf-8')
    except (UnicodeDecodeError, LookupError):
        pass

    # cannot decode, return same string and leave the system to fail
    return noneUniCodeStr

class L10nMessage(object):
    '''Encapsulates a formattable message, which includes the message id,
    the format text and the actual parameters
    '''
    def __init__(self, msgMeta, args=None, localized=None):
        '''
        @param msgMeta: The metadata of the message
        @type msgMeta: MessageMetadata

        @param args: Arguments of the message
        @type args: Localizable message or basic type (list/dict of both also accepted)

        @param localized: Already localized message. Very helpful if the translation comes from
        a message external program, for which the current process does not have l10n catalog. It
        returns it as is and does not take into acccount the args passed.
	@type localized: str
        '''
        if not isinstance(msgMeta, MessageMetadata):
            raise ValueError("Invalid L10nMessage metadata %s", msgMeta)

        self.msgMeta = msgMeta
        self.localized = localized
        if hasattr(args, '__localizableMessage__'):
            self.args = args.__localizableMessage__()
        else:
            self.args = args

        self.repr = None

    def getId(self):
        return self.msgMeta.msgId

    def getTranslatableText(self):
        return self.msgMeta.translatableText

    def getNormalizedArgs(self):
        '''Normalize arguments
        Convert all positional arguments into key based arguments,
        starting with 0
        '''
        args = self.args
        if args is None:
            return {}

        if isinstance(args, dict):
            return args

        if not isinstance(args, (list, tuple)):
            args = [args]

        # convert list into dict
        result = {}
        for key, item in enumerate(args):
            result[str(key)] = item
        return result

    def translate(self, catalog = None):
        '''Translate the message with a catalog.
        @param catalog: The catalog in a supported language, such as french.
        @type catalog: Catalog
        @return (str): a translated message with actual parameteters filled in
        '''
        localizedText = self.getTranslatableText()
        if catalog:
            # If the catalog has the id it will use it
            # if it doesn't have it will check if there is already provided localized str of the
            # message and it will return it. If not of the above are true it will use the english
            # version of the message
            catalogText = catalog.lookup(self.getId(), None)
            if catalogText:
                localizedText = catalogText
            elif self.localized:
                logger.debug('The message comes from external program, use its own translation.')
                return self.localized

        args = self.getNormalizedArgs()
        for key, arg in args.items():
            if isinstance(arg, L10nMessage):
                args[key] = arg.translate(catalog)
            elif isinstance(arg, str):
                args[key] = _strToUnicode(arg)

        try:
            # This is to validate that there are no extra arguments that otherwise will be ignored
            # the format ensure that the str has all its key satisfied by the arguments
            if not all('%%(%s)' % key in localizedText for key in args.keys()):
                raise ValueError('Mismatch between args and formatting strings')
            return localizedText % args
        except: #pylint: disable=#W0702
            logger.warn("Got error and will use english message while formatting message: id=%s, text='%s', "
                             "args=%s", self.getId(), localizedText, args)
            try:
                return self.getTranslatableText() % args
            except:
                logger.exception("Error formatting message: id=%s, text='%s', "
                             "args=%s", self.getId(), self.getTranslatableText(), args)
                raise

    def jsonEncode(self, catalog = None):
        '''Encode the object for json based output.
        @param catalog: The catalog in a supported language, such as french.
        @type catalog: Catalog
        @return (dict): A dictionary object ready for json based serialization.
        '''
        if not self.repr:
            self.repr = {}
            self.repr['id'] = self.getId()
            self.repr['translatable'] = self.getTranslatableText()
            if self.args is not None:
                self.repr['args'] = jsonEncodeMessageArgs(self.args)
            self.repr['localized'] = self.translate(catalog)
        return self.repr

    def jsonEncodeStr(self):
        '''Encode the object as a json string
        @return (str): A json string that represents the object.
        '''
        obj = self.jsonEncode()
        # save the output as utf-8 instead of escaped unicode characters
        return json.dumps(obj, ensure_ascii=False, encoding='utf-8')

    @classmethod
    def jsonDecode(cls, dictObj):
        '''Decode a L10nMessage from a dictinoary
        @return (L10nMessage): The L10nMessage created
        '''
        meta = MessageMetadata(dictObj['id'], dictObj['translatable'])
        if 'args' in dictObj:
            args = jsonDecodeMessageArgs(dictObj['args'])
            return L10nMessage(meta, args, localized=dictObj.get('localized'))
        else:
            return L10nMessage(meta, localized=dictObj.get('localized'))

    @classmethod
    def jsonDecodeStr(cls, text):
        '''Decode a L10nMessage from a string
        @return (L10nMessage): The L10nMessage created
        '''
        jObj = json.loads(text)
        return L10nMessage.jsonDecode(jObj)

def _loadVmsgFile(filename):
    '''load a vmsg file into a dictionary
    @param filename: path to the vmsg file
    @type filename: str

    @return: a dictionary that represents the message table from the vmsg file.
    '''
    msgTable = {}

    # catch the vmsg file loading exception so that we won't break
    # existing code for now.
    # TO DO: tighten the error handling
    try:
        with io.open(filename, 'r', encoding='utf-8') as fp:
            for line in fp:
                stripped = line.rstrip()
                # skip empty lines
                if not stripped:
                    continue
                (msgId, text) = [x.strip() for x in stripped.split('=', 1)]
                msgStr = json.loads(text)
                msgTable[msgId] = msgStr
    except: #pylint: disable=W0702
        logger.exception('Encountered error loading the vmsg file: %s, '
                         'assume empty')
        return {}

    return msgTable

class Catalog(object):
    '''Contains structured data of both the English and the locale vmsg files.
    Provide a method to look up the translated text using a message id.
    '''

    def __init__(self, _enVmsgPath, loVmsgPath):
        '''
        The English vmsg file must match the locale vmsg file
        which is translated from the English vmsg file.
        Therefore, they should contain the same set of message ids.

        @param enVmsgPath: The English vmsg file path (Not use anymore)
        @type enVmsgPath: str

        @param enVmsgPath: The locale vmsg file path
        @type enVmsgPath: str
        '''
        self._enVmsgPath = _enVmsgPath
        self.loVmsgPath = loVmsgPath
        self.loMsgs = _loadVmsgFile(self.loVmsgPath)

    def lookup(self, msgId, msgText):
        '''
        Lookup the translated message text by a message id.
        If the vmsg files were outdated, and the message id would not exist
        returns the passed msgText since it is the source of the truth.

        @param msgId: The message id
        @type msgId: str

        @param msgText: The English message format text
        @type msgText: str
        '''
        return self.loMsgs.get(msgId, msgText)

# Tests

def RunTests():
    import six #pylint: disable=W0612
    _D = MessageMetadata
    esx_incompatible = _D('msg.test.esx.incompatible',
            'ESX host %(host)s of version %(version)2.1f is not compatible')

    # You should normally not do this:
    #     esx_incompatible % {'host' : 'zhangv-esx', 'version' : 4.1 }
    # though it is probably OK to use it for log output.

    # You should create a L10nMessage object
    #     message = L10nMessage(esx_incompatible,
    #                              {'host' : 'zhangv-esx', 'version' : 4.1 })
    # Pass the message to UI by calling the jsonEncode or jsonEncodeStr() method

    # At some point, UI can translate the entire message by looking up the
    # message id in a .vmsg file in a proper locale, and then format the
    # translated text using the paramters carried in the message (now part of
    # the encoded json string).

    invalid_host_creds = _D('msg.test.source_vcva.host.cannot_authenticate',
            'Failed to authenticate into the VC/ESX host %s that manages'
            ' the source VCVA %s')

    out_of_diskspace = _D('msg.test.export.diskspace',
                          'Out of export disk space(100%% used)')

    stack_overflow = _D('msg.test.stack_overflow',
                        'stack overflow')

    framework_internal = _D('msg.test.internal',
                "Encountered an internal error,\n\tDetails: '%s'")

    test_single = _D('msg.test.single.param',
                     'There are %d errors')

    msg1 = L10nMessage(esx_incompatible,
                          {'host' : u'zhangv-esx',
                           'version' : 4.1})

    msg2 = L10nMessage(invalid_host_creds,
                          (u'zhangv-esx', 'zhangv-vcva'))

    msg3 = L10nMessage(out_of_diskspace)

    msg4_param = L10nMessage(stack_overflow)

    msg4 = L10nMessage(framework_internal, msg4_param)

    msg5 = L10nMessage(test_single, 0)

    print(json.dumps([msg1.jsonEncode(), msg2.jsonEncode(),
                      msg3.jsonEncode(), msg4.jsonEncode(),
                      msg5.jsonEncode()], indent=2))

    msgd1 = L10nMessage.jsonDecode(msg1.jsonEncode())
    msgd2 = L10nMessage.jsonDecode(msg2.jsonEncode())
    msgd3 = L10nMessage.jsonDecode(msg3.jsonEncode())
    msgd4 = L10nMessage.jsonDecode(msg4.jsonEncode())
    msgd5 = L10nMessage.jsonDecode(msg5.jsonEncode())


    print(json.dumps([msgd1.jsonEncode(), msgd2.jsonEncode(),
                      msgd3.jsonEncode(), msgd4.jsonEncode(),
                      msgd5.jsonEncode()], indent=2))

def SetupLogging():
    '''Set up logging. Output to the console. Level is DEBUG
    '''
    logging.basicConfig(format='%(asctime)s %(name)-8s %(levelname)-8s '
                        '%(message)s',
                        datefmt='%m-%d %H:%M:%S',
                        level=logging.INFO)
    logger.debug('Logging is now set up')

if __name__ == '__main__':
    SetupLogging()
    RunTests()
