#!/usr/bin/env python3

import os, re, shutil
from . import settings, utils, cppcheck_report_utils, exclusion_file_list
from .exclusion_file_list import (ExclusionFileListError,
                                  cppcheck_exclusion_file_list)

class GetMakeVarsPhaseError(Exception):
    pass

class CppcheckDepsPhaseError(Exception):
    pass

class CppcheckReportPhaseError(Exception):
    pass

CPPCHECK_BUILD_DIR = "build-dir-cppcheck"
CPPCHECK_HTMLREPORT_OUTDIR = "cppcheck-htmlreport"
CPPCHECK_REPORT_OUTDIR = "cppcheck-report"
cppcheck_extra_make_args = ""
xen_cc = ""

def get_make_vars():
    global xen_cc
    invoke_make = utils.invoke_command(
            "make -C {} {} export-variable-CC"
                .format(settings.xen_dir, settings.make_forward_args),
            True, GetMakeVarsPhaseError,
            "Error occured retrieving make vars:\n{}"
        )

    cc_var_regex = re.search('^CC=(.*)$', invoke_make, flags=re.M)
    if cc_var_regex:
        xen_cc = cc_var_regex.group(1)

    if xen_cc == "":
        raise GetMakeVarsPhaseError("CC variable not found in Xen make output")


def __generate_suppression_list(out_file):
    # The following lambda function will return a file if it contains lines with
    # a comment containing "cppcheck-suppress[*]" on a single line.
    grep_action = lambda x: utils.grep(x,
                    r'^.*/\* cppcheck-suppress\[(?P<id>.*)\] \*/$')
    # Look for a list of .h files that matches the condition above
    headers = utils.recursive_find_file(settings.xen_dir, r'.*\.h$',
                                        grep_action)

    try:
        with open(out_file, "wt") as supplist_file:
            # Add this rule to skip every finding in the autogenerated
            # header for cppcheck
            supplist_file.write("*:*generated/compiler-def.h\n")

            try:
                exclusion_file = \
                    "{}/docs/misra/exclude-list.json".format(settings.repo_dir)
                exclusion_list = cppcheck_exclusion_file_list(exclusion_file)
            except ExclusionFileListError as e:
                raise CppcheckDepsPhaseError(
                    "Issue with reading file {}: {}".format(exclusion_file, e)
                )

            # Append excluded files to the suppression list, using * as error id
            # to suppress every findings on that file
            for path in exclusion_list:
                supplist_file.write("*:{}\n".format(path))

            for entry in headers:
                filename = entry["file"]
                try:
                    with open(filename, "rt") as infile:
                        header_content = infile.readlines()
                except OSError as e:
                    raise CppcheckDepsPhaseError(
                            "Issue with reading file {}: {}"
                                .format(filename, e)
                          )
                header_lines_len = len(header_content)
                # line_num in entry will be header_content[line_num-1], here we
                # are going to search the first line after line_num that have
                # anything different from comments or empty line, because the
                # in-code comment suppression is related to that line then.
                for line_num in entry["matches"]:
                    cppcheck_violation_id = ""
                    tmp_line = line_num
                    # look up to which line is referring the comment at
                    # line_num (which would be header_content[tmp_line-1])
                    comment_section = False
                    while tmp_line < header_lines_len:
                        line = header_content[tmp_line]
                        # Matches a line with just optional spaces/tabs and the
                        # start of a comment '/*'
                        comment_line_starts = re.match(r'^[ \t]*/\*.*$', line)
                        # Matches a line with text and the end of a comment '*/'
                        comment_line_stops = re.match(r'^.*\*/$', line)
                        if (not comment_section) and comment_line_starts:
                            comment_section = True
                        if (len(line.strip()) != 0) and (not comment_section):
                            cppcheck_violation_id = entry["matches"][line_num]['id']
                            break
                        if comment_section and comment_line_stops:
                            comment_section = False
                        tmp_line = tmp_line + 1

                    if cppcheck_violation_id == "":
                        raise CppcheckDepsPhaseError(
                            "Error matching cppcheck comment in {} at line {}."
                                .format(filename, line_num)
                          )
                    # Write [error id]:[filename]:[line]
                    # tmp_line refers to the array index, so translated to the
                    # file line (that begins with 1) it is tmp_line+1
                    supplist_file.write(
                            "{}:{}:{}\n".format(cppcheck_violation_id, filename,
                                                (tmp_line + 1))
                        )
    except OSError as e:
        raise CppcheckDepsPhaseError("Issue with writing file {}: {}"
                                     .format(out_file, e))


def generate_cppcheck_deps():
    global cppcheck_extra_make_args

    # Compile flags to pass to cppcheck:
    # - include config.h as this is passed directly to the compiler.
    # - define CPPCHECK as we use it to disable or enable some specific part of
    #   the code to solve some cppcheck issues.
    # - explicitely enable some cppcheck checks as we do not want to use "all"
    #   which includes unusedFunction which gives wrong positives as we check
    #   file per file.
    # - Explicitly suppress warnings on compiler-def.h because cppcheck throws
    #   an unmatchedSuppression due to the rule we put in suppression-list.txt
    #   to skip every finding in the file.
    # - Explicitly suppress findings for unusedStructMember that is not very
    #   reliable and causes lots of false positives.
    #
    # Compiler defines are in compiler-def.h which is included in config.h
    #
    cppcheck_flags="""
 --max-ctu-depth=10
 --enable=style,information,missingInclude
 --template=\'{{file}}({{line}},{{column}}):{{id}}:{{severity}}:{{message}}\'
 --relative-paths={}
 --inline-suppr
 --suppressions-list={}/suppression-list.txt
 --suppress='unmatchedSuppression:*'
 --suppress='unusedStructMember:*'
 --include={}/include/xen/config.h
 -DCPPCHECK
""".format(settings.repo_dir, settings.outdir, settings.xen_dir)

    invoke_cppcheck = utils.invoke_command(
            "{} --version".format(settings.cppcheck_binpath),
            True, CppcheckDepsPhaseError,
            "Error occured retrieving cppcheck version:\n{}\n\n{}"
        )

    version_regex = re.search(r'^Cppcheck (\d+)\.(\d+)(?:\.\d+)?$',
                              invoke_cppcheck, flags=re.M)
    # Currently, only cppcheck version >= 2.7 is supported, but version 2.8 is
    # known to be broken, please refer to docs/misra/cppcheck.txt
    if (not version_regex) or len(version_regex.groups()) < 2:
        raise CppcheckDepsPhaseError(
            "Can't find cppcheck version or version not identified: "
            "{}".format(invoke_cppcheck)
        )
    version = (int(version_regex.group(1)), int(version_regex.group(2)))
    if version < (2, 7) or version == (2, 8):
        raise CppcheckDepsPhaseError(
            "Cppcheck version < 2.7 or 2.8 are not supported"
        )

    # If misra option is selected, append misra addon and generate cppcheck
    # files for misra analysis
    if settings.cppcheck_misra:
        cppcheck_flags = cppcheck_flags + " --addon=cppcheck-misra.json"

        skip_rules_arg = ""
        if settings.cppcheck_skip_rules != "":
            skip_rules_arg = "-s {}".format(settings.cppcheck_skip_rules)

        utils.invoke_command(
            "{}/convert_misra_doc.py -i {}/docs/misra/rules.rst"
            " -o {}/cppcheck-misra.txt -j {}/cppcheck-misra.json {}"
                .format(settings.tools_dir, settings.repo_dir,
                        settings.outdir, settings.outdir, skip_rules_arg),
            False, CppcheckDepsPhaseError,
            "An error occured when running:\n{}"
        )

    # Generate compiler macros
    os.makedirs("{}/include/generated".format(settings.outdir), exist_ok=True)
    utils.invoke_command(
            "{} -dM -E -o \"{}/include/generated/compiler-def.h\" - < /dev/null"
                .format(xen_cc, settings.outdir),
            False, CppcheckDepsPhaseError,
            "An error occured when running:\n{}"
        )

    # Generate cppcheck suppression list
    __generate_suppression_list(
        "{}/suppression-list.txt".format(settings.outdir))

    # Generate cppcheck build folder
    os.makedirs("{}/{}".format(settings.outdir, CPPCHECK_BUILD_DIR),
                exist_ok=True)

    cppcheck_cc_flags = """--compiler={} --cppcheck-cmd={} {}
 --cppcheck-plat={}/cppcheck-plat --ignore-path=tools/
 --ignore-path=arch/x86/efi/check.c --build-dir={}/{}
""".format(xen_cc, settings.cppcheck_binpath, cppcheck_flags,
           settings.tools_dir, settings.outdir, CPPCHECK_BUILD_DIR)

    if settings.cppcheck_html:
        cppcheck_cc_flags = cppcheck_cc_flags + " --cppcheck-html"

    # Generate the extra make argument to pass the cppcheck-cc.sh wrapper as CC
    cppcheck_extra_make_args = "CC=\"{}/cppcheck-cc.sh {} --\"".format(
                                        settings.tools_dir,
                                        cppcheck_cc_flags
                                    ).replace("\n", "")


def generate_cppcheck_report():
    # Prepare text report
    # Look for a list of .cppcheck.txt files, those are the txt report
    # fragments
    fragments = utils.recursive_find_file(settings.outdir, r'.*\.cppcheck.txt$')
    text_report_dir = "{}/{}".format(settings.outdir,
                                        CPPCHECK_REPORT_OUTDIR)
    report_filename = "{}/xen-cppcheck.txt".format(text_report_dir)
    os.makedirs(text_report_dir, exist_ok=True)
    try:
        cppcheck_report_utils.cppcheck_merge_txt_fragments(fragments,
                                                           report_filename,
                                                           [settings.repo_dir])
    except cppcheck_report_utils.CppcheckTXTReportError as e:
        raise CppcheckReportPhaseError(e)

    # If HTML output is requested
    if settings.cppcheck_html:
        # Look for a list of .cppcheck.xml files, those are the XML report
        # fragments
        fragments = utils.recursive_find_file(settings.outdir,
                                              r'.*\.cppcheck.xml$')
        html_report_dir = "{}/{}".format(settings.outdir,
                                         CPPCHECK_HTMLREPORT_OUTDIR)
        xml_filename = "{}/xen-cppcheck.xml".format(html_report_dir)
        os.makedirs(html_report_dir, exist_ok=True)
        try:
            cppcheck_report_utils.cppcheck_merge_xml_fragments(fragments,
                                                               xml_filename,
                                                               settings.repo_dir,
                                                               settings.outdir)
        except cppcheck_report_utils.CppcheckHTMLReportError as e:
            raise CppcheckReportPhaseError(e)
        # Call cppcheck-htmlreport utility to generate the HTML output
        utils.invoke_command(
            "{} --file={} --source-dir={} --report-dir={}/html --title=Xen"
                .format(settings.cppcheck_htmlreport_binpath, xml_filename,
                        settings.repo_dir, html_report_dir),
            False, CppcheckReportPhaseError,
            "Error occured generating Cppcheck HTML report:\n{}"
        )
        # Strip src and obj path from *.html files
        html_files = utils.recursive_find_file(html_report_dir, r'.*\.html$')
        try:
            cppcheck_report_utils.cppcheck_strip_path_html(html_files,
                                                           (settings.repo_dir,
                                                            settings.outdir))
        except cppcheck_report_utils.CppcheckHTMLReportError as e:
            raise CppcheckReportPhaseError(e)


def clean_analysis_artifacts():
    clean_files = ("suppression-list.txt", "cppcheck-misra.txt",
                   "cppcheck-misra.json")
    cppcheck_build_dir = "{}/{}".format(settings.outdir, CPPCHECK_BUILD_DIR)
    if os.path.isdir(cppcheck_build_dir):
        shutil.rmtree(cppcheck_build_dir)
    artifact_files = utils.recursive_find_file(settings.outdir,
                                r'.*\.(?:c\.json|cppcheck\.txt|cppcheck\.xml)$')
    for file in clean_files:
        file = "{}/{}".format(settings.outdir, file)
        if os.path.isfile(file):
            artifact_files.append(file)
    for delfile in artifact_files:
        os.remove(delfile)


def clean_reports():
    text_report_dir = "{}/{}".format(settings.outdir,
                                     CPPCHECK_REPORT_OUTDIR)
    html_report_dir = "{}/{}".format(settings.outdir,
                                     CPPCHECK_HTMLREPORT_OUTDIR)
    if os.path.isdir(text_report_dir):
        shutil.rmtree(text_report_dir)
    if os.path.isdir(html_report_dir):
        shutil.rmtree(html_report_dir)
