#!/usr/bin/env python2
import clang
import clang.cindex
import sys
from clang.cindex import CursorKind
import os
from datetime import datetime
import json
from colorama import Fore, Back, Style
import argparse
import fnmatch

def percent_string(step, total):
    percent = 100 * step / total

    if percent < 10:
        return '  {}%'.format(percent)
    elif percent < 100:
        return ' {}%'.format(percent)
    else:
        return '100%'

class PathFormatter:
    def __init__(self, sourcedir):
        self.sourcedir = sourcedir

    def format(self, path, color = Fore.WHITE, format = Style.NORMAL):
        return '{}{}{}'.format(color, os.path.relpath(path, self.sourcedir), Fore.RESET)


class Logger:
    def __init__(self, color, totalsteps = 0, tab = ''):
        self.totalsteps = totalsteps
        self.currentstep = 1

        if totalsteps > 0:
            self.step_header = lambda step: '[{}{}{}] '.format(Fore.BLUE + Style.BRIGHT, percent_string(step, totalsteps), Fore.RESET + Style.RESET_ALL)
        else:
            self.step_header = lambda step: tab + color + '  -> ' + Fore.RESET
        
        self.start_header = tab + color + '==> ' + Fore.RESET
        self.info_header = tab + ' - '

    def start(self, message, steps = -1):
        if message >= 0:
            self.totalsteps = steps

        self.currentstep = 1
        
        print ''
        print self.start_header + message

    def step(self, message):
        print self.step_header(self.currentstep) + message
        self.currentstep += 1
    
    def info(self, message):
        print self.info_header + message

class DevNullLogger:
    def start(self, message, steps = -1):
        return

    def step(self, message):
        return

    def info(self, message):
        return


class GlobalLogger:
    _okLogger = Logger(Fore.GREEN)
    _errorLogger = Logger(Fore.RED)
    _warnLogger = Logger(Fore.YELLOW)
    _infoLogger = Logger(Fore.BLUE)
    _normalLogger = Logger(Fore.WHITE)
    _devNullLogger = DevNullLogger()
    _enabled = False

    @staticmethod
    def enabled(enabled):
        GlobalLogger._enabled = enabled

    @staticmethod
    def ok():
        return GlobalLogger._okLogger if GlobalLogger._enabled else GlobalLogger._devNullLogger

    @staticmethod
    def error():
        return GlobalLogger._errorLogger if GlobalLogger._enabled else GlobalLogger._devNullLogger

    @staticmethod
    def warning():
        return GlobalLogger._warnLogger if GlobalLogger._enabled else GlobalLogger._devNullLogger

    @staticmethod
    def info():
        return GlobalLogger._infoLogger if GlobalLogger._enabled else GlobalLogger._devNullLogger

    @staticmethod
    def normal():
        return GlobalLogger._normalLogger if GlobalLogger._enabled else GlobalLogger._devNullLogger


class Field:
    def __init__(self, cursor):
        self.cursor = cursor
        self.name = cursor.spelling


class Function:
    def __init__(self, cursor):
        self.cursor = cursor
        self.name = cursor.spelling


class Class:
    def _try_match_reflection_enabler(self, node):
        """ Try to match a Reflectable<T> CRTP instantiation, 
            i.e. class Foo : public Reflectable<Foo>"""

        found_crtp_template = False

        if node.kind == CursorKind.CXX_BASE_SPECIFIER:
            for c in node.get_children():
                GlobalLogger.warning().step('base spec child: {} [{}]'.format(c.spelling, c.kind))

                if c.kind == CursorKind.TEMPLATE_REF and c.spelling == 'Reflectable':
                    found_crtp_template = True
                    self.logger.info('Found Reflectable<T> base specifier in class ' + self.full_qualified_ref)

                if found_crtp_template:
                    self.logger.info('Looking at {} (\'{}\' {})'.format(c.spelling, c.displayname, c.kind))

                    if c.kind == CursorKind.TYPE_REF and c.referenced == self.cursor:
                        return True;

        return False;

    def _collect_reflection_info(self):
        for child in self.cursor.get_children():
            if child.kind == CursorKind.CXX_METHOD:
                self.functions.append(Function(child))
            elif child.kind == CursorKind.FIELD_DECL:
                self.fields.append(Field(child))
            else:
                if not self.reflection_enabled:
                    self.reflection_enabled = self._try_match_reflection_enabler(child)

    def __init__(self, cursor, logger = Logger(Fore.WHITE), namespace = []):
        self.logger = logger
        self.cursor = cursor
        if cursor.kind == CursorKind.CLASS_DECL:
            self.declKind = 'class'
        elif cursor.kind == CursorKind.STRUCT_DECL:
            self.declKind = 'struct'
        else:
            self.declKind = '[unknown]'
        self.className = cursor.spelling
        self.possible_name_refs = [self.className, 'struct ' + self.className, 'class ' + self.className]
        self.fields = []
        self.functions = []
        self.namespace = [n for n in namespace if n] # Filter annonymous (empty) namespaces
        self.reflection_enabled = False

        self._collect_reflection_info()

    def forward_declaration(self):
        namespace_begin = '\n'.join(['namespace {} {{'.format(n) for n in self.namespace])
        namespace_end = '}' * len(self.namespace)

        if self.cursor.kind == CLASS_DECL:
            decl = '    class {};'.format(self.className)
        elif self.cursor.kind == STRUCT_DECL:
            decl = '    struct {};'.format(self.className)
        else:
            raise ValueError('Unrecognized cursor kind for forward declaration: {}'.format(self.cursor.lind))

    @property
    def full_qualified_ref(self):
        if self.namespace:
            return '::' + '::'.join(self.namespace) + '::' + self.className
        else:
            return '::' + self.className

    def __str__(self):
        return (
"""======================
class \'{0}\':
======================
 - Fields: {1}
 - Functions: {2}
"""                 ).format(self.className, 
                        ', '.join([field.name for field in self.fields]),
                        ', '.join([function.name for function in self.functions])
                )
  
class TranslationUnit:
    def __init__(self, filepath, args, jinjaTemplate, logger, print_ast = False):
        self.logger = logger
        self.filePath = filepath
        self.jinjaOutputFile = self.filePath + '.rfl'
        self.ast_file_path = filepath + '.ast'
        self.print_ast = print_ast
        self.index = clang.cindex.Index.create()
        self.jinjaTemplate = jinjaTemplate

        # Touch the file so #include <user_include.rfl> doesn't fail
        if not os.path.exists(self.jinjaOutputFile):
            with(self.jinjaOutputFile, 'w').close() 

        self.translationUnit = self.index.parse(filepath, args = args)

        self.classes = []
        self.tags = {}
        self.matching_declarations = [
            CursorKind.CLASS_DECL,
            CursorKind.STRUCT_DECL
        ]

    def _process_reflection(self, node, depth = 0, ast_file = None):
        reflectionClass = None

        childs = list(node.get_children())

        if (node.kind == CursorKind.NAMESPACE and node.spelling == 'std') or node.kind == CursorKind.UNEXPOSED_DECL:
            childs = []

        if ast_file is not None:
            ast_file.write('({}) {}{} {} [{}, line={}, column={}]\n'.format(self.namespace, '  '*depth, '==>' if childs else '  -', node.spelling, node.kind, node.location.line, node.location.column))


        if node.kind == CursorKind.NAMESPACE:
            self.namespace.append(node.spelling)

        if node.kind in self.matching_declarations:
            reflectionClass = Class(node, namespace = list(self.namespace))
            GlobalLogger.info().step('[line {}, col {}] {}'.format(node.location.line, node.location.column, reflectionClass.full_qualified_ref))

        for child in childs:
            self._process_reflection(child, depth + 1, ast_file)

        if node.kind == CursorKind.NAMESPACE:
            self.namespace.pop()

        if reflectionClass is not None and reflectionClass.reflection_enabled:
            self.classes.append(reflectionClass)
            self.tags[reflectionClass.cursor.location.line + 1] = reflectionClass


    def process(self):
        self.classes = []
        self.namespace = []

        if self.print_ast:
            with open(self.ast_file_path, 'w') as ast_file:
                self._process_reflection(self.translationUnit.cursor, ast_file = ast_file)
        else:
            self._process_reflection(self.translationUnit.cursor)

        return self.classes

    def run_jinja(self):
        if self.jinjaTemplate and self.classes:
            import hashlib

            self.logger.info('Generating file... {} -> {}'.format(self.filePath, self.jinjaOutputFile))

            with open(jinjaOutputFile, 'w') as outputFile:
                outputFile.write(self.jinjaTemplate.render(tu = self, hash = hashlib.md5(jinjaOutputFile.encode()).hexdigest()))


class SyncManager:
    def __init__(self, databasefile):
        self.databasefile = os.path.abspath(databasefile)
        self.rootpath = os.path.dirname(os.path.realpath(__file__))

        if os.path.isfile(self.databasefile):
            with open(self.databasefile, 'r') as file:
                self.database = json.load(file)
        else:
            self.database = {}
            self.database['timestamps'] = {}

    def timestamps(self):
        return self.database['timestamps']

    def fullpath(self, filename):
        return os.path.relpath(filename, self.rootpath)

    def save(self):
        with open(self.databasefile, 'w') as file:
            json.dump(self.database, file)

    def load_timestamp(self, sourcefile):
        return self.timestamps()[self.fullpath(sourcefile)]

    def get_file_timestamp(self, sourcefile):
        return os.path.getmtime(sourcefile)

    def save_timestamp(self, sourcefile):
        self.timestamps()[self.fullpath(sourcefile)] = self.get_file_timestamp(sourcefile)

    def is_synced(self, filename):
        return self.fullpath(filename) in self.database['timestamps'] and \
            self.get_file_timestamp(filename) <= self.load_timestamp(filename)

    def sync(self, filename):
        self.save_timestamp(filename)
    

class Compiler:
    def __init__(self, args, sourcedir, includedirs, files, databasefile, ignore_database = False, print_ast = False, jinjaTemplateFile = None, verbose = False):
        self.syncmanager = SyncManager(databasefile)
        self.ignore_database = ignore_database
        self.logger = Logger(Fore.GREEN, len(files))
        self.pathformat = PathFormatter(sourcedir)
        self.sourcedir = sourcedir
        self.includedirs = includedirs
        self.jinjaTemplateFile = jinjaTemplateFile

        import jinja2
        from jinja2 import Template, Environment, FileSystemLoader

        env = Environment(loader=FileSystemLoader(self.sourcedir),
                     trim_blocks=True)
        template = env.get_template(self.jinjaTemplateFile)

        self.tus = [TranslationUnit(sourcefile, args, template, self.logger, print_ast = print_ast) for sourcefile in files]

    def process(self):
        self.logger.start('Processing {} files:'.format(len(self.tus)))

        for tu in self.tus:
            if self.ignore_database or not self.syncmanager.is_synced(tu.filePath):
                self.logger.step('({}outdated{}) {}'.format(Fore.RED, Fore.RESET, self.pathformat.format(tu.filePath)))
                tu.process()
                tu.run_jinja()
                self.syncmanager.sync(tu.filePath)
            else:
                self.logger.step('({}up-to-date{}) {}'.format(Fore.GREEN, Fore.RESET, self.pathformat.format(tu.filePath)))

    def save_database(self):
        self.logger.start('Writing database...')
        self.syncmanager.save()

    def run(self):
        self.process()
        self.save_database()


class App:
    def file_matches(self, file):
        for ext in self.args.extensions:
            if fnmatch.fnmatch(file, '*' + ext):
                if self.args.exclude:
                    for ex in self.args.exclude:
                        if not fnmatch.fnmatch(file, ex):
                            return True
                else:
                    return True

        return False
        
    def search_files(self, rootpath):
        self.logger.step(rootpath)
        files = list(os.walk(rootpath))
        outputfiles = []
        
        for root, dirnames, filenames in files:
            for file in filenames:
                if self.file_matches(os.path.join(root, file)):
                    outputfiles.append(os.path.join(root, file))

        return outputfiles

    def splashscreen(self, message):
        print '{}==>{} {}'.format(Fore.MAGENTA, Fore.RESET, message)
        print '    {}{}{}'.format(Fore.MAGENTA, '='*len(message), Fore.RESET)
        print ''

    def __init__(self):
        self.splashscreen('Dynamic Reflection Library Parser')
        self.logger = Logger(Fore.MAGENTA)

        parser = argparse.ArgumentParser(description='Dynamic Reflection Library Parser')
        parser.add_argument('-f', '--files', nargs = '*', default = [], help = 'List of input files to process')
        parser.add_argument('-d', '--database', default = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'drlp_database.json'), help = 'Compilation database JSON file')
        parser.add_argument('-I', '--includedirs', default = [], help = 'List of include directories to scan for more input files')
        parser.add_argument('-s', '--sourcedir', help = 'Project source directory')
        parser.add_argument('-x', '--exclude', nargs = '*', default = [], help = 'List of GLOBs to exclude source files from include directories scan')
        parser.add_argument('-e', '--extensions', nargs = '*', default = ['.hpp', '.h', '.hxx'], help = 'List of file extensions used for include directory input files scan')
        parser.add_argument('-i', '--ignore-database', action = 'store_true', help = 'Ignore database records (Ignore input file timestamps, process file always)')
        parser.add_argument('-l', '--libclang', help = 'Full path to libclang library (libclang.so)')
        parser.add_argument('-a', '--ast-dump', help = 'Dumps the AST of each translation unit to a source_file.ast file', action = 'store_true')
        parser.add_argument('--code-template-file', help = 'Template file of reflection code generation')
        parser.add_argument('-v', '--verbose', action = 'store_true')
        self.args = parser.parse_args()

        GlobalLogger.enabled(self.args.verbose)

        if self.args.libclang:
            self.logger.step('libclang.so: ' + self.args.libclang)
            clang.cindex.Config.set_library_file(self.args.libclang)
        else:
            config = clang.cindex.Config()
            self.logger.step('libclang.so: ' + os.path.abspath(config.get_filename()))

        self.logger.step('Database file: {}'.format(self.args.database))
        self.logger.step('Extensions: ' + ', '.join(self.args.extensions))
        self.logger.step('Exclude: ' + ', '.join(self.args.exclude))
        
        self.logger = Logger(Fore.YELLOW)

        if self.args.includedirs:
            self.args.includedirs = self.args.includedirs.split()
            self.logger.start('Scanning include directories:')

            for include_dir in self.args.includedirs:
                directory_files = self.search_files(include_dir)
                self.args.files = list(set(self.args.files + directory_files))

        self.args.files.sort()

        includes = ['-I{}'.format(os.path.abspath(i)) for i in self.args.includedirs]
        includes.append('-I' + os.path.abspath(self.args.sourcedir))
        includes.append('-I' + os.path.abspath(os.path.join(self.args.sourcedir, 'include')))

        compileArgs = includes + ['-std=c++14']

        self.compiler = Compiler(compileArgs,
            self.args.sourcedir, self.args.includedirs, self.args.files,
            self.args.database, self.args.ignore_database, 
            self.args.ast_dump,
            self.args.code_template_file
        )
    
    def run(self):
        self.compiler.run()


App().run()
        
