#!/usr/bin/env python
# -*- coding: utf-8 -*-

"""
    Python API for the ASN1Scc compiler

    Copyright (c) 2013-2019 European Space Agency

    Designed and implemented by Maxime Perrotin

    Based on the ASN.1 Space Certified Compiler from Neuropublic

    Contact: maxime.perrotin@esa.int
"""

import tempfile
#import uuid
import os
import distutils.spawn as spawn
import sys
import importlib
import logging
import hashlib
from PySide6.QtCore import QProcess, QFile, QIODevice

LOG = logging.getLogger(__name__)
terminal_formatter = logging.Formatter(fmt="[%(levelname)s] %(message)s")
handler_console = logging.StreamHandler()
handler_console.setFormatter(terminal_formatter)
LOG.addHandler(handler_console)

# global needed to store the imported module and list of modules ever loaded
AST = {}

# Same for the modules imported by the call to asn2dataModel
ASN2DM = {}

try:
    from enum import Enum
except ImportError:
    raise ImportError('Enum module not found. Run pip install --user enum34')


__all__ = ['ASN1', 'parse_asn1', 'create_choice_determinant_types']


class ASN1(Enum):
    ''' Flags used to control the compiler options '''
    NoParameterizedTypes = 1
    NoInnerTypes = 2
    NoConstraintReference = 3
    UniqueEnumeratedNames = 4
    AstOnly = 5
    NoRename = 0
    RenameOnlyConflicting = 1 # C default
    RenameAllEnumerants = 2   # All if at least one conflict
    SystematicRenameAllEnumerants = 3 # All no matter what

def waitfor_qprocess(qprocess, name):
    ''' Wait the execution of a QProcess instance
    Raise an exception if anything went wrong, otherwise return stdout '''
    if not qprocess.waitForStarted():
        raise TypeError('Could not start ' + name)
    if not qprocess.waitForFinished(300000):
        raise TypeError('Execution time out : ' + name)
    exitcode = qprocess.exitCode()
    err = qprocess.readAllStandardError()
    std = qprocess.readAllStandardOutput()
    if exitcode != 0:
        raise TypeError(f'{name} error (exit code = {exitcode}) - {str(err)}')
    return std


def parse_asn1(*files, **options):
    ''' Call the ASN.1 parser on a number of files, and return the module
        containing the AST
        This function uses QProcess to launch the ASN.1 compiler because
        the subprocess module from Python has issues on the Windows platform
    '''
    if '-g' in sys.argv:
        os.environ["PROJECT_CACHE"] = './debug'

    # use basic caching to avoid re-parsing when loading the model
    project_cache = os.getenv ("PROJECT_CACHE")
    if project_cache is not None and not os.path.isdir(project_cache):
        try:
            print(f"[INFO] Creating cache folder {project_cache}")
            os.makedirs(project_cache)
        except OSError:
            raise TypeError (f'''The configured cache folder "{project_cache} " \
                is not there and could not be created\n''')

    LOG.info(f"ASN.1 Parser: using cache folder {project_cache}")
    # make sure the same files are not parsed more than once if not modified
    filehash = hashlib.md5()
    file_list = sorted(list(*files))
    try:
        for each in file_list:
            filehash.update(open(each).read().encode('utf-8'))
            # also hash the file path: it is used in the AST, so it is
            # not enough to hash the content of the ASN.1 files, as two sets
            # of input files may have the same hash
            filehash.update(each.encode('utf-8'))
    except IOError as err:
        raise TypeError (str(err))
    new_hash = filehash.hexdigest()

    # names of the files that will be generated by asn1scc and then parsed
    out_py_name   = new_hash + ".py"
    out_html_name = new_hash + ".html"

    if new_hash in AST.keys():
        return AST[new_hash]
    elif project_cache is not None:
        outdir = project_cache
    elif project_cache is None:
        outdir = tempfile.mkdtemp()

    # to allow the import
    sys.path.append(outdir)

    ast_version = options.get('ast_version', ASN1.UniqueEnumeratedNames)
    rename_policy = options.get('rename_policy', ASN1.NoRename)
    flags = options.get('flags', [ASN1.AstOnly])
    pprint = options.get('pretty_print', False)
    extraflags = options.get('extraflags', [])
    assert isinstance(ast_version, ASN1)
    assert isinstance(rename_policy, ASN1)
    assert isinstance(flags, list)
    path_to_asn1scc = spawn.find_executable('asn1scc')

    if not path_to_asn1scc:
        raise TypeError('ASN.1 Compiler (asn1scc) not found in path')
    binary = path_to_asn1scc
    asn1scc_root = os.path.abspath(os.path.dirname(binary))

    if os.name == 'nt':
        # On windows, remove the drive letter, workaround to ASN1SCC bug
        outdir       = outdir[2:]
        asn1scc_root = asn1scc_root[2:]

    # The two possible files that can be generated with complete path:
    py_filepath   = outdir + os.sep + out_py_name
    html_filepath = outdir + os.sep + out_html_name

    # call the ASN.1 compiler only if there is no existing cached file
    if project_cache is None or not os.path.exists(py_filepath):
        stg = asn1scc_root + os.sep + 'python.stg'

        if pprint:
            # Generate an html file with pretty-printed ASN.1 types
            stg_qrc = QFile(':misc/pretty_print_asn1.stg')
            stg_qrc.open(QIODevice.ReadOnly)
            content = stg_qrc.readAll()
            stgfile = outdir + os.sep + 'pretty_print_asn1.stg'
            with open(stgfile, 'wb') as tmpfile:
                tmpfile.write(content.data())
            html = ['-customIcdUper', stgfile + '::' + html_filepath]
        else:
            html = []
        args = ['-customStgAstVersion', str(ast_version.value),
                '--field-prefix', 'AUTO',
                '-customStg', stg + '::' + py_filepath,
                '-renamePolicy', str(rename_policy.value)] + html + extraflags + file_list
        asn1scc = QProcess()
        LOG.debug(os.getcwd())
        LOG.debug (args)
        LOG.debug(binary + ' ' + ' '.join(args))
        LOG.debug(f"Python AST: {py_filepath}")
        asn1scc.start(binary, args)

        _ = waitfor_qprocess(asn1scc, "ASN.1 Compiler")

    ast = importlib.import_module(new_hash)
    AST[new_hash] = ast
    if pprint:
        # add the path to the optionally-generated pretty-printed HTML file
        ast.html = html_filepath
    return ast

def create_choice_determinant_types(ast):
    ''' Postprocess the AST to add extra types corresponding to the CHOICE
    determinants. This allows the user to declare variables of these types,
    for local storage and comparison purposes.
    input: ast is the module generate by asn1scc. ast.types are the types 
    returns the newly created types (does not modify input AST)
    '''
    new_sorts = {}
    to_be_deleted = []
    for each in (sort for sort in ast.types.values()
                 if sort.type.kind == 'ChoiceType'):
        # we must capitalize the type here to remove any ambiguity
        new_sort_name = each.__name__.title() + '-selection'
        choices = {key : type (key, (object,), {
            "IntValue": 0,
            "Line": each.Line,
            "CharPositionInLine": each.CharPositionInLine,
            "EnumID": each.type.Children[key].EnumID,
            #"IsStandardEnum" : False
            }) for key in each.type.Children.keys()
        }
        full_sort = \
                type(new_sort_name, (object,), {
                    "Line": each.Line,
                    "CharPositionInLine": each.CharPositionInLine,
                    "AddedType" : "False",
                    "ChoiceTypeName" : each.__name__,
                    "type": type(new_sort_name + "_type", (object,), {
                        "Line" : each.Line,
                        "CharPositionInLine": each.CharPositionInLine,
                        "kind": "EnumeratedType",
                        "Extensible": "False",
                        "ValuesAutoCalculated": "False",
                        "EnumValues": choices
                    })
                })

        # Check if an identical type name already exists. This is a problem
        # unless the enumerants are either the same or the same
        # with the suffix "_present" (meaning it is a type that
        # was generated by Opengeode (AdaGenerator). In that case we will
        # remove the existing type from the main AST and place the newly
        # created one instead (we must not have _present suffixes for the
        # parser to work properly, in particular with the present() operator)
        found = None
        for key in ast.types.keys():
            if key.lower() == new_sort_name.lower():
                found = key
                # here we could add a check that all enum values are identical
                # to the ones of the new type (with or without -present suffix)
                break
        if found is not None:
            # replace the content
            #ast.types[found] = full_sort
            #to_be_deleted.append(found)
            new_sorts[found] = full_sort
        else:
            new_sorts[new_sort_name] = full_sort
    for each in to_be_deleted:
        pass
        #ast.types.pop(each)
        #breakpoint()
        #ast.exportedTypes.pop(each)
    return new_sorts

def asn2dataModel(files, outdir=None, db=None):
    ''' Call asn2dataModel, including the Makefile.python and return
    the imported module "name_of_dataview_asn.py"
    From this module it is possible to create native Asn1scc instances of
    ASN.1 data types, and to access to DV.py, which contains constants
    such as the _PRESENT fields for choice selectors.
    In addition the SqlAlchemy interface is also imported
    give db a name to create the database
    if outdir is none, a temporary folder will be used
    '''
    assert len(files) > 0

    # 1) Create a temporary directory for the output
    if '-g' in sys.argv:
        # In debug mode, don't hide the files in /tmp
        os.makedirs('./debug', exist_ok=True)
        tempdir='./debug/'
    elif outdir is None:
        tempdir = tempfile.mkdtemp()
    else:
        os.makedirs(outdir, exist_ok=True)
        tempdir=outdir
    sys.path.insert(0, tempdir)
    # Use a unique name for the asn1 concatenated module
    # concat_prefix = str(uuid.uuid4()).replace('-', '_')
    # Update MP 19/01/2022 - why use a unique name while
    # we are already in a random temp folder? 
    concat_prefix = 'og_dataview'
    concat_path = os.path.join(tempdir, concat_prefix)

    concat_file = concat_path + '.asn'
    if os.path.exists(concat_file):
        concat_timestamp = os.path.getctime(concat_file)
    else:
        concat_timestamp = 0

    # 2) Concat all input files to the output directory
    #    if any of the files is more recent that the
    #    existing concatenated file
    cat_bin = spawn.find_executable('cat')
    args = files  # list
    timestamps = []

    for each in args:
        timestamps.append(os.path.getmtime(each))
    if any(ts > concat_timestamp for ts in timestamps):
        cat = QProcess()
        LOG.debug(os.getcwd())
        LOG.debug(cat_bin + ' ' + ' '.join(args))
        cat.start(cat_bin, args)
        merged = waitfor_qprocess(cat, 'Merge dataviews')
        with open(concat_path + '.asn', 'wt') as merged_file:
            merged_file.write(merged.data().decode('utf-8'))

        # 3) Run asn2dataModel for Python 
        asn2dm_bin = spawn.find_executable('asn2dataModel')
        args = ['-toPython', '-o', tempdir, concat_path + '.asn']
        asn2dm = QProcess()
        LOG.debug(os.getcwd())
        LOG.debug(asn2dm_bin + ' ' + ' '.join(args))
        asn2dm.start(asn2dm_bin, args)
        waitfor_qprocess(asn2dm, 'DMT tool "asn2dataModel"')

        # 4) call make -f Makefile.python to build the .so
        make_bin = spawn.find_executable('make')
        args = ['-f', 'Makefile.python']
        make = QProcess()
        make.setWorkingDirectory(tempdir)
        LOG.debug(os.getcwd())
        LOG.debug(make_bin + ' ' + ' '.join(args))
        make.start(make_bin, args)
        waitfor_qprocess(make, 'make -f Makefile.python')

        # 5) Run asn2dataModel for the SQL Alchemy module
        args = ['-toSqlalchemy', '-o', tempdir, concat_path + '.asn']
        asn2dm = QProcess()
        LOG.debug(os.getcwd())
        LOG.debug(asn2dm_bin + ' ' + ' '.join(args))
        asn2dm.start(asn2dm_bin, args)
        waitfor_qprocess(asn2dm, 'DMT tool "asn2dataModel"')
    else:
        LOG.info("Reusing DMT outputs from previous run")

    if concat_prefix in ASN2DM.keys():
        # Re-import module if it was already loaded
        asn1mod = ASN2DM[concat_prefix]
        dbmod = asn1mod.db_model
        importlib.reload(asn1mod)
        importlib.reload(asn1mod.DV)
        importlib.reload(dbmod)
        asn1mod.db_model = dbmod
        import Stubs
        importlib.reload(Stubs)
    else:
        asn1mod = importlib.import_module(concat_prefix + '_asn')
        # force reload of modules in case of multiple calls
        importlib.reload(asn1mod.DV)
        import Stubs
        importlib.reload(Stubs)
        # Add the SQL Alchemy module interface
        import db_model
        asn1mod.db_model = db_model
        ASN2DM[concat_prefix] = asn1mod

    # 6) Create the database is "db" is set
    if db is not None:
        # db should be e.g. "sqlite:///file.sqlite"
        from sqlalchemy import create_engine
        from db_model import Base
        engine=create_engine(db, echo=False)
        Base.metadata.create_all(engine)
    sys.path.pop(0)
    return asn1mod


if __name__ == '__main__':
    LOG.setLevel(logging.DEBUG)
    try:
        ast = parse_asn1(['dataview-uniq.asn'],
                          ast_version=ASN1.NoParameterizedTypes,
                          flags=[ASN1.AstOnly])
        print(ast.types.keys())
        sys.exit(0)
    except TypeError as err:
        print(str(err))
        sys.exit(1)
