# -*- mode: python -*-
#------------------------------------------------------------------------
#
#------------------------------------------------------------------------
import argparse
from bs4 import BeautifulSoup
import os
import re
import subprocess
import sys

def enum(*sequential, **named):
    enums = dict(zip(sequential, range(len(sequential))), **named)
    return type('Enum', (), enums)

LibraryAge = enum('OLD', 'NEW')

DEBUG_ALWAYS = -10
DEBUG_OFF = 0
DEBUG_LOW = 10
DEBUG_MEDIUM = 20
DEBUG_HIGH = 30
DEBUG_EXTREME = 40

ENCODING = 'utf-8'

EXIT_CODE_OK = 0x00
EXIT_CODE_BINARY_REMOVED = 0x01
EXIT_CODE_BINARY_SYMBOLS = 0x02
EXIT_CODE_BINARY_UNKNOWN = 0x08
EXIT_CODE_SOURCE_REMOVED = 0x10
EXIT_CODE_SOURCE_SYMBOLS = 0x20
EXIT_CODE_SOURCE_UNKNOWN = 0x80

exit_code = EXIT_CODE_OK
DEBUG_LEVEL = 0
FROMFILE_PREFIX_CHAR = '@'
PROG_CMAKE = None
PROG_CPPFILTER = None
PROG_ABI_DUMPER = None
PROG_ABI_COMPLIANCE_CHECKER = None

CMAKE_INFO = {}

exit_code = 0

def analyse( report ):
    global exit_code

    binary_symbol_pattern = re.compile("^#Symbol_Binary_Problems_.*$")
    binary_pattern = re.compile("^#.*_Binary_.*$")
    source_symbol_pattern = re.compile("^#Symbol_Source_Problems_.*$")
    source_pattern = re.compile("^#.*_Source_.*$")
    if (PROG_CPPFILTER):
        html_report = open( report, 'r' )
        try:
            report_string = subprocess.check_output([PROG_CPPFILTER], stdin=html_report)
            report_string = report_string.decode(ENCODING)
        except CalledProcessError as exception:
            exit_code = exit_code | EXIT_CODE_SOURCE_UNKNOWN | EXIT_CODE_BINARY_UNKNOWN
            return
    else:
        with open( report, 'r' ) as html_report:
            report_string = html_report.read( )
    soup = BeautifulSoup( report_string, "html.parser" )
    for summary_results in soup.find_all("table", attrs={"class":"summary"}):
        for table_row in summary_results.find_all("tr"):
            msg_debug( DEBUG_MEDIUM, "table_row: %s" % (table_row))
            table_header = table_row.find("th")
            if( table_header and
                table_header.get_text() == "Version #1"):
                table_data = table_row.find("td").get_text( )
                old_library_version = int(table_data.split("_")[0])
                msg_debug( DEBUG_MEDIUM, "old_library_version: %d" % (old_library_version))
                continue
            elif( table_header and
                table_header.get_text() == "Version #2"):
                table_data = table_row.find("td").get_text( )
                new_library_version = int(table_data.split("_")[0])
                msg_debug( DEBUG_MEDIUM, "new_library_version: %d" % (new_library_version))
                continue
            last_td = table_row.find_all("td")[-1:]
            if( not last_td ):
                # Nothing to do as there is no table data element
                continue
            last_td = last_td[0]
            msg_debug(DEBUG_HIGH, "last_td: %s" % (last_td))
            incident_count = last_td.get_text( )
            if ( not incident_count.isdigit( ) ):
                # Nothing to do as the table data element is not a number
                continue
            incident_count = int(incident_count)
            if ( incident_count <= 0 ):
                # No incidents to report
                continue
            classification = last_td.get("class")
            if ( not classification ):
                # No class associated with the table data
                continue
            if (isinstance(classification, list) ):
                classification = classification[0]
            incident_anchor = last_td.find("a")
            incident_type = incident_anchor.get("href")
            #------------------------------------------------------------
            # Evaluate the severity of the incident
            #------------------------------------------------------------
            if ( classification == "failed" ):
                msg_debug(DEBUG_LOW, "parsing failure: td: %s" % (last_td))
                if ( new_library_version > old_library_version ):
                    continue
                if (incident_type == "#Source_Removed" ):
                    exit_code = exit_code | EXIT_CODE_SOURCE_REMOVED
                    continue
                if (source_symbol_pattern.match(incident_type ) ):
                    exit_code = exit_code | EXIT_CODE_SOURCE_SYMBOLS
                    continue
                if (incident_type == "#Binary_Removed" ):
                    exit_code = exit_code | EXIT_CODE_BINARY_REMOVED
                    continue
                if (binary_symbol_pattern.match(incident_type ) ):
                    exit_code = exit_code | EXIT_CODE_BINARY_SYMBOLS
                    continue
                #--------------------------------------------------------
                # Unknown failures
                #--------------------------------------------------------
                if (source_pattern.match(incident_type ) ):
                    exit_code = exit_code | EXIT_CODE_SOURCE_UNKNOWN
                    continue
                if (binary_pattern.match(incident_type ) ):
                    exit_code = exit_code | EXIT_CODE_BINARY_UNKNOWN
                    continue
            elif ( classification == "new" ):
                #--------------------------------------------------------
                # Adding symbols does not result in backwards
                #   compatability issues
                #--------------------------------------------------------
                continue

def default_from_environment(variable_name, default_value):
    if ( os.environ.get(variable_name)):
        return(os.environ[variable_name])
    else:
        return(str(default_value))

def dump_library(headers,
                 header_dir,
                 library,
                 library_dir,
                 install_prefix_dir = None,
                 library_age = LibraryAge.OLD):
    global CMAKE_INFO
    output_filename = None
    version = None

    msg_debug(DEBUG_HIGH, "library_dir: %s" % (str(library_dir)))
    find_library_script = [
        'set(paths_ %s64 %s/64 %s)' % (library_dir,
                                  library_dir,
                                  library_dir
        ),
        'find_library(library_full_path',
        '  NAMES %s' % (library),
        '  PATHS ${paths_}',
        '  HINTS ${paths_}',
        '  NO_DEFAULT_PATH',
        '  NO_CMAKE_PATH',
        '  NO_CMAKE_SYSTEM_PATH',
        ')',
        'message("LIBRARY_FULL_PATH=${library_full_path}")'
    ]
    msg_debug(DEBUG_EXTREME, str(find_library_script))
    cmd = [PROG_ABI_DUMPER]
    eval_cmake_script(find_library_script)
    shared_library_full_path = CMAKE_INFO['LIBRARY_FULL_PATH']
    msg_debug(DEBUG_HIGH, "LIBRARY_FULL_PATH: %s" % str(shared_library_full_path))
    if (os.path.exists(shared_library_full_path)):
        cmd.extend([shared_library_full_path])
        version = get_shared_library_version(shared_library_full_path)
        msg_debug(DEBUG_HIGH, "version: %s" % (version))
        shared_library_dir_name = os.path.dirname(shared_library_full_path)
        shared_library_base_name = os.path.basename(shared_library_full_path)
        shared_library_base_name = os.path.basename(shared_library_full_path)
        shared_library_base_name = re.sub('[.][^.]*$', '', shared_library_base_name)
        if (library_age == LibraryAge.OLD):
            shared_library_base_name += ('_old')
        elif (library_age == LibraryAge.NEW):
            shared_library_base_name += ('_new')
        shared_library_base_name += ('_ABI_%s' % version)
        output_filename = "%s/%s.dump" % (CMAKE_INFO['CMAKE_CURRENT_BINARY_DIR'],
                                          shared_library_base_name)
        cmd.extend(['-o', output_filename])
        if (install_prefix_dir):
            shared_library_debug_info_dir = ('%s/lib/debug%s' %
                                             (install_prefix_dir,
                                              shared_library_dir_name))
            if (os.path.exists(shared_library_debug_info_dir)):
                cmd.extend(['--search-debuginfo=%s' %
                            (shared_library_debug_info_dir)])
        cmd.extend(['--lver', version])
        cmd.extend(['--sort', '--dir', '--all'])
        if (headers):
            headers = headers.split(';')
            header_filename = "%s/%s.hdr" % (CMAKE_INFO['CMAKE_CURRENT_BINARY_DIR'],
                                              shared_library_base_name)
            with open(header_filename, 'w') as header_stream:
                for header in headers:
                    header_stream.write("%s\n" % os.path.join(header_dir, header))
            cmd.extend(['--public-headers', header_filename])

        msg_debug(DEBUG_EXTREME, "Dump command: %s" % str(cmd))
        try:
            abi_dumper_output = subprocess.check_output(cmd,
                                                        stderr=subprocess.STDOUT)
            abi_dumper_output = abi_dumper_output.decode(ENCODING)
        except subprocess.CalledProcessError as failure:
            msg_debug(DEBUG_MEDIUM, ("Failed: %s: %d: %s") % (failure.cmd, failure.returncode, failure.output))
        # os.remove(header_filename)
    return(output_filename, version)


def eval_cmake_script(script):
    global PROG_CMAKE
    global CMAKE_INFO

    STRIP_LEADING_HYPHENS = re.compile(r'^-- ')
    cmd = []
    script_filename = ',cmake_info.cmake'

    script = [
        'if (UNIX)',
        '  if (APPLE)',
        '    # OSX',
        '    set(CMAKE_FIND_LIBRARY_PREFIXES lib)',
        '    set(CMAKE_FIND_LIBRARY_SUFFIXES .dylib .so .a)',
        '    if(NOT CMAKE_INSTALL_PREFIX)',
        '      if(EXISTS /opt/local)',
        '        # MacPorts',
        '        set(CMAKE_INSTALL_PREFIX /opt/local)',
        '      else( )',
        '        set(CMAKE_INSTALL_PREFIX /usr)',
        '      endif()',
        '    endif()',
        '  else( )',
        '    # Unix/Linux',
        '    set(CMAKE_FIND_LIBRARY_PREFIXES lib)',
        '    set(CMAKE_FIND_LIBRARY_SUFFIXES .so .a)',
        '    if(NOT CMAKE_INSTALL_PREFIX)',
        '      set(CMAKE_INSTALL_PREFIX /usr)',
        '    endif()',
        '  endif( )',
        'else( )',
        'endif( )'
    ] + script
    if (os.path.exists(script_filename)):
        os.remove(script_filename)
    with open(script_filename, "w") as script_file:
        script_file.write('\n'.join(script))
    cmd.append( PROG_CMAKE )
    cmd.extend( [ '-DCMAKE_INSTALL_PREFIX=%s' % (CMAKE_INFO['CMAKE_INSTALL_PREFIX']) ] )
    cmd.extend( ['-P', script_filename ] )
    msg_debug(DEBUG_EXTREME, "command line arguments: %s" % (str(cmd)))
    cmake_output = subprocess.check_output(cmd, stderr = subprocess.STDOUT)
    cmake_output = cmake_output.decode(ENCODING)
    msg_debug(DEBUG_HIGH, "cmake_output: %s" % (cmake_output) )
    for line in cmake_output.splitlines():
        line = STRIP_LEADING_HYPHENS.sub('', str(line))
        if '=' in line:
            msg_debug(DEBUG_HIGH, "Parsing variable assignment: %s" % (line))
            (key, value) = line.split('=', 1)
            msg_debug(DEBUG_HIGH, "Assigning: %s to %s" % (key, str(value)))
            CMAKE_INFO[str(key)] = str(value)
    msg_debug(DEBUG_HIGH, "CMAKE_INFO: %s" % str(CMAKE_INFO) )

def generate_compatibility_report( library,
                                   dump_info_old,
                                   dump_info_new,
                                   version_number_old = 0,
                                   version_number_new = 1):
    msg_debug(DEBUG_HIGH, "abi_compliance-checker program: %s" % PROG_ABI_COMPLIANCE_CHECKER)
    cmd = [ PROG_ABI_COMPLIANCE_CHECKER,
            '-debug',
            '-l', library,
            '-old', dump_info_old,
            '-new', dump_info_new]
    msg_debug(DEBUG_EXTREME, ('cmd: %s' % str(cmd)))
    try:
        abi_compliance_checker_output = subprocess.check_output(cmd,
                                                                stderr=subprocess.STDOUT)
        abi_compliance_checker_output.decode(ENCODING)
    except subprocess.CalledProcessError as failure:
        msg_debug(DEBUG_LOW, "returncode: %d cmd: %s output: %s" % (failure.returncode, failure.cmd, failure.output))
    compatibility_report = os.path.join( CMAKE_INFO['CMAKE_CURRENT_BINARY_DIR'],
                                         'compat_reports',
                                         library,
                                         '%s_to_%s' % (version_number_old, version_number_new),
                                         'compat_report.html')
    msg_debug(DEBUG_MEDIUM, "compatibility_report: %s" % (compatibility_report))
    if (os.path.exists(compatibility_report)):
        return(compatibility_report)
    return None

def get_shared_library_version(shared_object_filename):
    versioned_shared_object_filename = \
        os.path.basename( os.path.realpath(shared_object_filename) )
    version = re.sub('^.*[.]so[.]', '', versioned_shared_object_filename)
    version = re.sub('^.*[.]([0-9][.0-9]*)[.]dylib$', '\1', version)
    #version = re.sub('[.].*$', '', version)
    version = re.sub('[.]', '_', version)
    return(version)


def init( command_line_arguments ):
    global DEBUG_LEVEL
    global PROG_CMAKE
    global PROG_CPPFILTER
    global PROG_ABI_COMPLIANCE_CHECKER
    global PROG_ABI_DUMPER

    DEBUG_LEVEL = command_line_arguments.debug
    PROG_ABI_COMPLIANCE_CHECKER = which(['abi-compliance-checker'])
    PROG_ABI_DUMPER = which('abi-dumper')
    PROG_CMAKE = which( [ 'cmake3', 'cmake' ] )
    PROG_CPPFILTER = which( [ 'c++filt' ] )
    msg_debug(DEBUG_LOW, PROG_CMAKE)
    init_cmake( command_line_arguments )
    if ( command_line_arguments.headers[0] == FROMFILE_PREFIX_CHAR ):
        with open(command_line_arguments.headers[1:]) as header_stream:
            content = [line.strip( ) for line in header_stream.readlines( )]
        command_line_arguments.headers = content
    if (',' in command_line_arguments.headers):
        command_line_arguments.headers = command_line_arguments.headers.split(',')
    msg_debug_variable(DEBUG_MEDIUM, "command_line_arguments.headers", str(command_line_arguments.headers))
    if ( not os.path.isabs(command_line_arguments.header_dir_old)):
        command_line_arguments.header_dir_old = os.path.join(CMAKE_INFO['CMAKE_INSTALL_FULL_INCLUDEDIR'],
                                                             command_line_arguments.header_dir_old)

def init_cmake( command_line_arguments ):
    global CMAKE_INFO

    CMAKE_INFO['CMAKE_INSTALL_PREFIX'] = command_line_arguments.prefix
    script_contents = [
        r'#',
        r'include(GNUInstallDirs)'
        ]
    for var in ['CMAKE_CURRENT_BINARY_DIR']:
        script_contents.append(r'message("%s=${%s}")' % (var, var))
        msg_debug_variable(DEBUG_EXTREME, "script_contents[-1]: ", script_contents[-1])
    for var in ['INCLUDEDIR', 'LIBDIR']:
        var_name=("CMAKE_INSTALL_%s" % (var))
        var_full_name=("CMAKE_INSTALL_FULL_%s" % (var))
        script_contents.append(r'message("%s=${%s}")' % (var_name, var_name))
        msg_debug_variable(DEBUG_EXTREME, "script_contents[-1]: ", script_contents[-1])
        script_contents.append(r'message("%s=${%s}")' % (var_full_name, var_full_name))
        msg_debug_variable(DEBUG_EXTREME, "script_contents[-1]: ", script_contents[-1])
    eval_cmake_script(script_contents)

def msg(Message):
    print( "%s" % Message)

def msg_debug(Level, Message):
    global DEBUG_LEVEL
    if (Level > DEBUG_LEVEL):
        return( )
    msg("DEBUG: %s" % (Message))

def msg_debug_variable(Level, Variable, Value):
    msg_debug(Level, " := ".join((Variable, str(Value))))

def str2bool(v):
    if isinstance(v, bool):
        return( v );
    if v.lower() in ('yes', 'true', 't', 'y', '1'):
        return True
    elif v.lower( ) in ( 'no', 'false', 'f', '0'):
        return False
    else:
        raise argparse.ArgumentTypeError('Boolean value expected.')

def verify(config):
    if (config.message):
        msg_begin = ( "STARTED: %s %s" % (config.message, config.library_name))
        msg_end = ( "FINISHED: %s %s" % (config.message, config.library_name))
    (dump_info_old, version_number_old) = dump_library(
        library_age = LibraryAge.OLD,
        install_prefix_dir = config.prefix,
        library = config.library_name,
        library_dir = config.library_dir_old,
        headers = config.headers,
        header_dir = config.header_dir_old
    )
    (dump_info_new, version_number_new) = dump_library(
        library_age = LibraryAge.NEW,
        library = config.library_name,
        library_dir = config.library_dir_new,
        headers = config.headers,
        header_dir = config.header_dir_new
    )
    compatibility_report = generate_compatibility_report(
        config.library_name,
        dump_info_old,
        dump_info_new,
        version_number_old,
        version_number_new
    )
    msg_debug(DEBUG_MEDIUM, "compatibility_report: %s" % (compatibility_report))
    analyse( compatibility_report )

def which(programs):
    import os
    def is_exe(fpath):
        return os.path.isfile(fpath) and os.access(fpath, os.X_OK)

    if (not isinstance(programs, list)):
        programs = [programs]
    for program in programs:
        fpath, fname = os.path.split(program)
        if fpath:
            if is_exe(program):
                return program
        else:
            for path in os.environ["PATH"].split(os.pathsep):
                exe_file = os.path.join(path, program)
                if is_exe(exe_file):
                    return exe_file
    return None

def main():
    global FROMFILE_PREFIX_CHAR

    library_dir_old_default = '/usr/lib'
    header_dir_old_default = '/usr/include'
    program_name=os.path.basename(sys.argv[0])
    parser = argparse.ArgumentParser(
        usage='%(prog)s [options]',
        fromfile_prefix_chars=FROMFILE_PREFIX_CHAR
    )
    parser.add_argument(
        '--debug',
        help='Enable debug level',
        type=int,
        nargs='?',
        const=10,
        default=int(default_from_environment('DEBUG', 0))
    )
    parser.add_argument(
        '--headers',
        help='Collection of relative header files separated by commas or file name, preceded with the \'@\' symbol, containing one header file per line'
    )
    parser.add_argument(
        '--header-dir-new',
        help='Top level directory containing the new header files'
    )
    parser.add_argument(
        '--header-dir-old',
        help='Top level directory containing the old header files',
        default=header_dir_old_default
    )
    parser.add_argument(
        '--library-dir-new',
        help='Top level directory containing the new library',
        default=None
    )
    parser.add_argument(
        '--library-dir-old',
        help='Top level directory containing the old library',
        default=library_dir_old_default
    )
    parser.add_argument(
        '--library-name',
        help='Base name of the library'
    )
    parser.add_argument(
        '--message',
        help='Message to be displayed when doing comparison'
    )
    parser.add_argument(
        '--prefix',
        help='Installation prefix for old libraries and headers',
        default='/usr'
    )
    config = parser.parse_args( )
    init(config)
    msg_debug_variable(DEBUG_MEDIUM, "config.headers", str(config.headers))
    msg_debug_variable(DEBUG_MEDIUM, "config.library_dir_new", config.library_dir_new)
    if ( config.library_dir_new == None):
        config.library_dir_new = CMAKE_INFO['CMAKE_CURRENT_BINARY_DIR']
    msg_debug(DEBUG_ALWAYS, "config: %s" % str(config))
    verify(config)
    #for report in args.reports:
    #    analyse( report )

if __name__ == "__main__":
    main( )
    msg_debug(DEBUG_LOW, 'Exiting with exit_code: %s ' % (exit_code))
    exit(exit_code)
