#!/usr/bin/env python

from __future__ import unicode_literals

import io
import sys
import optparse
import os
import operator

from collections import Counter
from pygments import highlight
from pygments.lexers import guess_lexer_for_filename
from pygments.formatters import HtmlFormatter
from xml.sax import parse as xml_parse
from xml.sax import SAXParseException as XmlParseException
from xml.sax.handler import ContentHandler as XmlContentHandler
from xml.sax.saxutils import escape
"""
Turns a cppcheck xml file into a browsable html report along
with syntax highlighted source code.
"""

STYLE_FILE = """
body {
    font: 13px Arial, Verdana, Sans-Serif;
    margin: 0;
    width: auto;
}

h1 {
    margin: 10px;
}

#footer > p {
    margin: 4px;
}

.error {
    background-color: #ffb7b7;
}

.error2 {
    background-color: #faa;
    border: 1px dotted black;
    display: inline-block;
    margin-left: 4px;
}

.inconclusive {
    background-color: #B6B6B4;
}

.inconclusive2 {
    background-color: #B6B6B4;
    border: 1px dotted black;
    display: inline-block;
    margin-left: 4px;
}

div.verbose {
    display: inline-block;
    vertical-align: top;
    cursor: help;
}

div.verbose div.content {
    display: none;
    position: absolute;
    padding: 10px;
    margin: 4px;
    max-width: 40%;
    white-space: pre-wrap;
    border: 1px solid black;
    background-color: #FFFFCC;
    cursor: auto;
}

.highlight .hll {
    padding: 1px;
}

#header {
    border-bottom: thin solid #aaa;
}

#menu {
    float: left;
    margin-top: 5px;
    text-align: left;
    width: 150px;
    height: 75%;
    position: fixed;
    overflow: auto;
    z-index: 1;
}

#menu_index {
    float: left;
    margin-top: 5px;
    padding-left: 5px;
    text-align: left;
    width: 200px;
    height: 75%;
    position: fixed;
    overflow: auto;
    z-index: 1;
}

#menu > a {
    display: block;
    margin-left: 10px;
    font: 12px;
    z-index: 1;
}

#filename  {
    margin-left: 10px;
    font: 12px;
    z-index: 1;
}

.highlighttable {
    background-color:white;
    z-index: 10;
    position: relative;
    margin: -10 px;
}

#content {
    background-color: white;
    -webkit-box-sizing: content-box;
    -moz-box-sizing: content-box;
    box-sizing: content-box;
    float: left;
    margin: 5px;
    margin-left: 10px;
    padding: 0 10px 10px 10px;
    width: 80%;
    padding-left: 150px;
}

#content_index {
    background-color: white;
    -webkit-box-sizing: content-box;
    -moz-box-sizing: content-box;
    box-sizing: content-box;
    float: left;
    margin: 5px;
    margin-left: 10px;
    padding: 0 10px 10px 10px;
    width: 80%;
    padding-left: 200px;
}

.linenos {
    border-right: thin solid #aaa;
    color: lightgray;
    padding-right: 6px;
}

#footer {
    border-top: thin solid #aaa;
    clear: both;
    font-size: 90%;
    margin-top: 5px;
}

#footer ul {
    list-style-type: none;
    padding-left: 0;
}
"""

HTML_HEAD = """
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Cppcheck - HTML report - %s</title>
    <link rel="stylesheet" href="style.css">
    <style>
%s
    </style>
    <script language="javascript">
      function getStyle(el,styleProp) {
        if (el.currentStyle)
          var y = el.currentStyle[styleProp];
        else if (window.getComputedStyle)
          var y = document.defaultView.getComputedStyle(el,null).getPropertyValue(styleProp);
        return y;
      }
      function toggle() {
        var el = this.expandable_content;
        var mark = this.expandable_marker;
        if (el.style.display == "block") {
          el.style.display = "none";
          mark.innerHTML = "[+]";
        } else {
          el.style.display = "block";
          mark.innerHTML = "[-]";
        }
      }
      function init_expandables() {
        var elts = document.getElementsByClassName("expandable");
        for (var i = 0; i < elts.length; i++) {
          var el = elts[i];
          var clickable = el.getElementsByTagName("span")[0];
          var marker = clickable.getElementsByClassName("marker")[0];
          var content = el.getElementsByClassName("content")[0];
          var width = clickable.clientWidth - parseInt(getStyle(content, "padding-left")) - parseInt(getStyle(content, "padding-right"));
          content.style.width = width + "px";
          clickable.expandable_content = content;
          clickable.expandable_marker = marker;
          clickable.onclick = toggle;
        }
      }
      function set_class_display(c, st) {
        var elements = document.querySelectorAll('.' + c),
            len = elements.length;
        for (i = 0; i < len; i++) {
            elements[i].style.display = st;
        }
      }
      function toggle_class_visibility(id) {
        var box = document.getElementById(id);
        set_class_display(id, box.checked ? '' : 'none');
      }
    </script>
  </head>
  <body onload="init_expandables()">
      <div id="header">
        <h1>Cppcheck report - %s: %s </h1>
      </div>
      <div id="menu" dir="rtl">
       <p id="filename"><a href="index.html">Defects:</a> %s</p>
"""

HTML_HEAD_END = """
      </div>
      <div id="content">
"""

HTML_FOOTER = """
      </div>
      <div id="footer">
        <p>
         Cppcheck %s - a tool for static C/C++ code analysis</br>
         </br>
         Internet: <a href="http://cppcheck.net">http://cppcheck.net</a></br>
         IRC: <a href="irc://irc.freenode.net/cppcheck">irc://irc.freenode.net/cppcheck</a></br>
        <p>
      </div>
  </body>
</html>
"""

HTML_ERROR = "<span class='error2'>&lt;--- %s</span>\n"
HTML_INCONCLUSIVE = "<span class='inconclusive2'>&lt;--- %s</span>\n"

HTML_EXPANDABLE_ERROR = "<div class='verbose expandable'><span class='error2'>&lt;--- %s <span class='marker'>[+]</span></span><div class='content'>%s</div></div>\n"""
HTML_EXPANDABLE_INCONCLUSIVE = "<div class='verbose expandable'><span class='inconclusive2'>&lt;--- %s <span class='marker'>[+]</span></span><div class='content'>%s</div></div>\n"""

# escape() and unescape() takes care of &, < and >.
html_escape_table = {
    '"': "&quot;",
    "'": "&apos;"
}
html_unescape_table = {v: k for k, v in html_escape_table.items()}


def html_escape(text):
    return escape(text, html_escape_table)


class AnnotateCodeFormatter(HtmlFormatter):
    errors = []

    def wrap(self, source, outfile):
        line_no = 1
        for i, t in HtmlFormatter.wrap(self, source, outfile):
            # If this is a source code line we want to add a span tag at the
            # end.
            if i == 1:
                for error in self.errors:
                    if error['line'] == line_no:
                        try:
                            if error['inconclusive'] == 'true':
                                # only print verbose msg if it really differs
                                # from actual message
                                if error.get('verbose') and (error['verbose'] != error['msg']):
                                    index = t.rfind('\n')
                                    t = t[:index] + HTML_EXPANDABLE_INCONCLUSIVE % (error['msg'], html_escape(error['verbose'].replace("\\012", '\n'))) + t[index + 1:]
                                else:
                                    t = t.replace('\n', HTML_INCONCLUSIVE % error['msg'])
                        except KeyError:
                            if error.get('verbose') and (error['verbose'] != error['msg']):
                                index = t.rfind('\n')
                                t = t[:index] + HTML_EXPANDABLE_ERROR % (error['msg'], html_escape(error['verbose'].replace("\\012", '\n'))) + t[index + 1:]
                            else:
                                t = t.replace('\n', HTML_ERROR % error['msg'])

                line_no = line_no + 1
            yield i, t


class CppCheckHandler(XmlContentHandler):

    """Parses the cppcheck xml file and produces a list of all its errors."""

    def __init__(self):
        XmlContentHandler.__init__(self)
        self.errors = []
        self.version = '1'
        self.versionCppcheck = ''

    def startElement(self, name, attributes):
        if name == 'results':
            self.version = attributes.get('version', self.version)

        if self.version == '1':
            self.handleVersion1(name, attributes)
        else:
            self.handleVersion2(name, attributes)

    def handleVersion1(self, name, attributes):
        if name != 'error':
            return

        self.errors.append({
            'file': attributes.get('file', ''),
            'line': int(attributes.get('line', 0)),
            'id': attributes['id'],
            'severity': attributes['severity'],
            'msg': attributes['msg']
        })

    def handleVersion2(self, name, attributes):
        if name == 'cppcheck':
            self.versionCppcheck = attributes['version']
        if name == 'error':
            # is there a better solution than this?
            if ('inconclusive' in attributes and 'cwe' in attributes):
                self.errors.append({
                    'file': '',
                    'line': 0,
                    'id': attributes['id'],
                    'severity': attributes['severity'],
                    'msg': attributes['msg'],
                    'verbose': attributes.get('verbose'),
                    'inconclusive': attributes['inconclusive'],
                    'cwe': attributes['cwe']
                })
            elif 'inconclusive' in attributes:
                self.errors.append({
                    'file': '',
                    'line': 0,
                    'id': attributes['id'],
                    'severity': attributes['severity'],
                    'msg': attributes['msg'],
                    'verbose': attributes.get('verbose'),
                    'inconclusive': attributes['inconclusive']
                })
            elif 'cwe' in attributes:
                self.errors.append({
                    'file': '',
                    'line': 0,
                    'id': attributes['id'],
                    'severity': attributes['severity'],
                    'msg': attributes['msg'],
                    'verbose': attributes.get('verbose'),
                    'cwe': attributes['cwe']
                })
            else:
                self.errors.append({
                    'file': '',
                    'line': 0,
                    'id': attributes['id'],
                    'severity': attributes['severity'],
                    'msg': attributes['msg'],
                    'verbose': attributes.get('verbose')
                })
        elif name == 'location':
            assert self.errors
            self.errors[-1]['file'] = attributes['file']
            self.errors[-1]['line'] = int(attributes['line'])

if __name__ == '__main__':
    # Configure all the options this little utility is using.
    parser = optparse.OptionParser()
    parser.add_option('--title', dest='title',
                      help='The title of the project.',
                      default='[project name]')
    parser.add_option('--file', dest='file',
                      help='The cppcheck xml output file to read defects '
                           'from. Default is reading from stdin.')
    parser.add_option('--report-dir', dest='report_dir',
                      help='The directory where the HTML report content is '
                           'written.')
    parser.add_option('--source-dir', dest='source_dir',
                      help='Base directory where source code files can be '
                           'found.')
    parser.add_option('--source-encoding', dest='source_encoding',
                      help='Encoding of source code.', default='utf-8')

    # Parse options and make sure that we have an output directory set.
    options, args = parser.parse_args()

    try:
        sys.argv[1]
    except IndexError:  # no arguments give, print --help
        parser.print_help()
        quit()

    if not options.report_dir:
        parser.error('No report directory set.')

    # Get the directory where source code files are located.
    source_dir = os.getcwd()
    if options.source_dir:
        source_dir = options.source_dir

    # Get the stream that we read cppcheck errors from.
    input_file = sys.stdin
    if options.file:
        if not os.path.exists(options.file):
            parser.error('cppcheck xml file: %s not found.' % options.file)
        input_file = io.open(options.file, 'r')
    else:
        parser.error('No cppcheck xml file specified. (--file=)')

    # Parse the xml file and produce a simple list of errors.
    print('Parsing xml report.')
    try:
        contentHandler = CppCheckHandler()
        xml_parse(input_file, contentHandler)
    except XmlParseException as msg:
        print('Failed to parse cppcheck xml file: %s' % msg)
        sys.exit(1)

    # We have a list of errors. But now we want to group them on
    # each source code file. Lets create a files dictionary that
    # will contain a list of all the errors in that file. For each
    # file we will also generate a HTML filename to use.
    files = {}
    file_no = 0
    for error in contentHandler.errors:
        filename = error['file']
        if filename not in files.keys():
            files[filename] = {
                'errors': [], 'htmlfile': str(file_no) + '.html'}
            file_no = file_no + 1
        files[filename]['errors'].append(error)

    # Make sure that the report directory is created if it doesn't exist.
    print('Creating %s directory' % options.report_dir)
    if not os.path.exists(options.report_dir):
        os.mkdir(options.report_dir)

    # Generate a HTML file with syntax highlighted source code for each
    # file that contains one or more errors.
    print('Processing errors')

    decode_errors = []
    for filename, data in sorted(files.items()):
        htmlfile = data['htmlfile']
        errors = data['errors']

        lines = []
        for error in errors:
            lines.append(error['line'])

        if filename == '':
            continue

        source_filename = os.path.join(source_dir, filename)
        try:
            with io.open(source_filename, 'r', encoding=options.source_encoding) as input_file:
                content = input_file.read()
        except IOError:
            if (error['id'] == 'unmatchedSuppression'):
                continue  # file not found, bail out
            else:
                sys.stderr.write("ERROR: Source file '%s' not found.\n" %
                                 source_filename)
            continue
        except UnicodeDecodeError:
            sys.stderr.write("WARNING: Unicode decode error in '%s'.\n" %
                             source_filename)
            decode_errors.append(source_filename[2:])  # "[2:]" gets rid of "./" at beginning
            continue

        htmlFormatter = AnnotateCodeFormatter(linenos=True,
                                              style='colorful',
                                              hl_lines=lines,
                                              lineanchors='line',
                                              encoding=options.source_encoding)
        htmlFormatter.errors = errors

        with io.open(os.path.join(options.report_dir, htmlfile), 'w', encoding='utf-8') as output_file:
            output_file.write(HTML_HEAD %
                              (options.title,
                               htmlFormatter.get_style_defs('.highlight'),
                               options.title,
                               filename,
                               filename.split('/')[-1]))

            for error in sorted(errors, key=lambda k: k['line']):
                output_file.write("<a href='%s#line-%d'> %s %s</a>" % (data['htmlfile'], error['line'], error['id'],   error['line']))

            output_file.write(HTML_HEAD_END)
            try:
                lexer = guess_lexer_for_filename(source_filename, '')
            except:
                sys.stderr.write("ERROR: Couldn't determine lexer for the file' " + source_filename + " '. Won't be able to syntax highlight this file.")
                output_file.write("\n <tr><td colspan='4'> Could not generated content because pygments failed to retrieve the determine code type.</td></tr>")
                output_file.write("\n <tr><td colspan='4'> Sorry about this.</td></tr>")
                continue

            if options.source_encoding:
                lexer.encoding = options.source_encoding

            output_file.write(
                highlight(content, lexer, htmlFormatter).decode(
                    options.source_encoding))

            output_file.write(HTML_FOOTER % contentHandler.versionCppcheck)

        print('  ' + filename)

    # Generate a master index.html file that will contain a list of
    # all the errors created.
    print('Creating index.html')

    with io.open(os.path.join(options.report_dir, 'index.html'),
                 'w') as output_file:

        stats_count = 0
        stats = []
        for filename, data in sorted(files.items()):
            for error in data['errors']:
                stats.append(error['id'])  # get the stats
                stats_count += 1

        counter = Counter(stats)

        stat_html = []
        # the following lines sort the stat primary by value (occurrences),
        # but if two IDs occur equally often, then we sort them alphabetically by warning ID
        try:
            cnt_max = counter.most_common()[0][1]
        except IndexError:
            cnt_max = 0

        try:
            cnt_min = counter.most_common()[-1][1]
        except IndexError:
            cnt_min = 0

        stat_fmt = "            <tr><td><input type='checkbox' onclick='toggle_class_visibility(this.id)' id='{}' name='{}' checked></td><td>{}</td><td>{}</td></tr>"
        for occurrences in reversed(range(cnt_min, cnt_max + 1)):
            for _id in [k for k, v in sorted(counter.items()) if v == occurrences]:
                stat_html.append(stat_fmt.format(_id, _id, dict(counter.most_common())[_id], _id))

        output_file.write(HTML_HEAD.replace('id="menu" dir="rtl"', 'id="menu_index"', 1).replace("Defects:", "Defect summary;", 1) % (options.title, '', options.title, '', ''))
        output_file.write('       <table>')
        output_file.write('           <tr><th>Show</th><th>#</th><th>Defect ID</th></tr>')
        output_file.write(''.join(stat_html))
        output_file.write('           <tr><td></td><td>' + str(stats_count) + '</td><td>total</td></tr>')
        output_file.write('       </table>')
        output_file.write('       <a href="stats.html">Statistics</a></p>')
        output_file.write(HTML_HEAD_END.replace("content", "content_index", 1))
        output_file.write('       <table>\n')

        output_file.write(
            '       <tr><th>Line</th><th>Id</th><th>CWE</th><th>Severity</th><th>Message</th></tr>')
        for filename, data in sorted(files.items()):
            if filename in decode_errors:  # don't print a link but a note
                output_file.write("\n       <tr><td colspan='4'>%s</td></tr>" % (filename))
                output_file.write("\n       <tr><td colspan='4'> Could not generated due to UnicodeDecodeError</td></tr>")
            else:
                if filename.endswith('*'):  # assume unmatched suppression
                    output_file.write(
                        "\n       <tr><td colspan='4'>%s</td></tr>" %
                        (filename))
                else:
                    output_file.write(
                        "\n       <tr><td colspan='4'><a href='%s'>%s</a></td></tr>" %
                        (data['htmlfile'], filename))

                for error in sorted(data['errors'], key=lambda k: k['line']):
                    error_class = ''
                    try:
                        if error['inconclusive'] == 'true':
                            error_class = 'class="inconclusive"'
                            error['severity'] += ", inconcl."
                    except KeyError:
                        pass

                    try:
                        if error['cwe']:
                            cwe_url = "<a href='https://cwe.mitre.org/data/definitions/" + error['cwe'] + ".html'>" + error['cwe'] + "</a>"
                    except KeyError:
                        cwe_url = ""

                    if error['severity'] == 'error':
                        error_class = 'class="error"'
                    if error['id'] == 'missingInclude':
                        output_file.write(
                            '\n         <tr class="%s"><td></td><td>%s</td><td></td><td>%s</td><td>%s</td></tr>' %
                            (error['id'], error['id'], error['severity'], error['msg']))
                    elif (error['id'] == 'unmatchedSuppression') and filename.endswith('*'):
                        output_file.write(
                            '\n         <tr class="%s"><td></td><td>%s</td><td></td><td>%s</td><td %s>%s</td></tr>' %
                            (error['id'], error['id'], error['severity'], error_class,
                             error['msg']))
                    else:
                        output_file.write(
                            '\n       <tr class="%s"><td><a href="%s#line-%d">%d</a></td><td>%s</td><td>%s</td><td>%s</td><td %s>%s</td></tr>' %
                            (error['id'], data['htmlfile'], error['line'], error['line'],
                             error['id'], cwe_url, error['severity'], error_class,
                             error['msg']))

        output_file.write('\n       </table>')
        output_file.write(HTML_FOOTER % contentHandler.versionCppcheck)

    if (decode_errors):
        sys.stderr.write("\nGenerating html failed for the following files: " + ' '.join(decode_errors))
        sys.stderr.write("\nConsider changing source-encoding (for example: \"htmlreport ... --source-encoding=\"iso8859-1\"\"\n")

    print('Creating style.css file')
    with io.open(os.path.join(options.report_dir, 'style.css'),
                 'w') as css_file:
        css_file.write(STYLE_FILE)

    print("Creating stats.html (statistics)\n")
    stats_countlist = {}

    for filename, data in sorted(files.items()):
        if (filename == ''):
            continue
        stats_tmplist = []
        for error in sorted(data['errors'], key=lambda k: k['line']):
            stats_tmplist.append(error['severity'])

        stats_countlist[filename] = dict(Counter(stats_tmplist))

    # get top ten for each severity
    SEVERITIES = "error", "warning", "portability", "performance", "style", "unusedFunction", "information", "missingInclude", "internal"

    with io.open(os.path.join(options.report_dir, 'stats.html'), 'w') as stats_file:

        stats_file.write(HTML_HEAD.replace('id="menu" dir="rtl"', 'id="menu_index"', 1).replace("Defects:", "Back to summary", 1) % (options.title, '', options.title, 'Statistics', ''))
        stats_file.write(HTML_HEAD_END.replace("content", "content_index", 1))

        for sev in SEVERITIES:
            _sum = 0
            stats_templist = {}

            # if the we have an style warning but we are checking for
            # portability, we have to skip it to prevent KeyError
            try:
                for filename in stats_countlist:
                    try:  # also bail out if we have a file with no sev-results
                        _sum += stats_countlist[filename][sev]
                        stats_templist[filename] = (int)(stats_countlist[filename][sev])  # file : amount,
                    except KeyError:
                        continue
                # don't print "0 style" etc, if no style warnings were found
                if (_sum == 0):
                    break
            except KeyError:
                continue
            stats_file.write("<p>Top 10 files for " + sev + " severity, total findings: " + str(_sum) + "</br>\n")

            # sort, so that the file with the most severities per type is first
            stats_list_sorted = sorted(stats_templist.items(), key=operator.itemgetter(1, 0), reverse=True)
            it = 0
            LENGTH = 0

            for i in stats_list_sorted:  # printing loop
                # for aesthetics: if it's the first iteration of the loop, get
                # the max length of the number string
                if (it == 0):
                    LENGTH = len(str(i[1]))  # <- length of longest number, now get the difference and try to  make other numbers align to it

                stats_file.write("&#160;" * 3 + str(i[1]) + "&#160;" * (1 + LENGTH - len(str(i[1]))) + "<a href=\"" + files[i[0]]['htmlfile'] + "\">  " + i[0] + "</a></br>\n")
                it += 1
                if (it == 10):  # print only the top 10
                    break
            stats_file.write("</p>\n")

    print("\nOpen '" + options.report_dir + "/index.html' to see the results.")
