#!/usr/bin/env python2.7

# Copyright (c) 2013 - present Facebook, Inc.
# All rights reserved.
#
# This source code is licensed under the BSD style license found in the
# LICENSE file in the root directory of this source tree. An additional grant
# of patent rights can be found in the PATENTS file in the same directory.

from __future__ import absolute_import
from __future__ import division
from __future__ import print_function
from __future__ import unicode_literals

import argparse
import codecs
import json
import os
import re
import shutil
import subprocess
import sys

from inferlib import colorize, config, issues, source, utils

HTML_REPORT_DIR = 'report.html'
TRACES_REPORT_DIR = 'traces'
SOURCE_REMOTE_GITHUB_URL_TEMPLATE = ('https://github.com/{project}/blob/'
                                     '{hash}/{relative-path}/'
                                     '{file-name}#L{line-number}')
SOURCE_REMOTE_GITHUB_RE = re.compile('.*github.com[:/](?P<project>.*)')


base_parser = argparse.ArgumentParser(
    description='Explore the error traces in Infer reports.')
base_parser.add_argument('-o', '--out', metavar='<directory>',
                         default=config.DEFAULT_INFER_OUT, dest='infer_out',
                         action=utils.AbsolutePathAction,
                         help='Set the Infer results directory')
base_parser.add_argument('--only-show',
                         action='store_true',
                         help='Show the list of reports and exit')
base_parser.add_argument('--no-source',
                         action='store_true',
                         help='Do not print code excerpts')
base_parser.add_argument('--select',
                         metavar='N',
                         nargs=1,
                         help='Select bug number N. '
                         'If omitted, prompts you for input.')
base_parser.add_argument('--max-level',
                         metavar='N',
                         nargs=1,
                         help='Level of nested procedure calls to show. '
                         'Can be "max", in which case all levels are shown. '
                         'If omitted, prompts you for input.')
base_parser.add_argument('--html',
                         action='store_true',
                         help='Generate HTML report.')


def show_error_and_exit(err, show_help):
    utils.stderr(err)
    if show_help:
        print('')
        base_parser.print_help()
    exit(1)


class Tracer(object):
    def __init__(self, args, level=sys.maxsize):
        self.args = args
        self.max_level = level
        self.indenter = source.Indenter()

    def build_node_tags(self, node):
        pass

    def build_node(self, node):
        if node[issues.JSON_INDEX_TRACE_LEVEL] > self.max_level:
            return

        report_line = node[issues.JSON_INDEX_TRACE_LINE]
        fname = node[issues.JSON_INDEX_TRACE_FILENAME]

        self.indenter.newline()
        self.indenter.add('%s:%d: %s' % (
            fname,
            report_line,
            node[issues.JSON_INDEX_TRACE_DESCRIPTION],
        ))
        self.indenter.newline()

        if not self.args.no_source:
            self.indenter.indent_push(node[issues.JSON_INDEX_TRACE_LEVEL])
            mode = colorize.TERMINAL_FORMATTER
            if self.args.html:
                mode = colorize.PLAIN_FORMATTER
            self.indenter.add(source.build_source_context(fname,
                                                          mode,
                                                          report_line))
            self.indenter.indent_pop()
            self.indenter.newline()

    def build_trace(self, trace):
        total_nodes = len(trace)
        hidden_nodes = len(
            filter(lambda n: n[issues.JSON_INDEX_TRACE_LEVEL] > self.max_level,
                   trace))
        shown_nodes = total_nodes - hidden_nodes
        hidden_str = ''
        all_str = 'all '
        if hidden_nodes > 0:
            hidden_str = ' (%d steps too deeply nested)' % hidden_nodes
            all_str = ''
        self.indenter.add('Showing %s%d steps of the trace%s\n\n'
                          % (all_str, shown_nodes, hidden_str))
        self.indenter.newline()

        for node in trace:
            self.build_node(node)

    def build_report(self, report):
        self.build_trace(report[issues.JSON_INDEX_TRACE])

    def __str__(self):
        return str(self.indenter)


class Selector(object):
    def __init__(self, args, reports):
        self.args = args

        def has_trace(report):
            return len(report[issues.JSON_INDEX_TRACE]) > 0
        self.reports = [report for report in reports if has_trace(report)]

    def show_choices(self):
        n = 0
        n_length = len(str(len(self)))
        for report in self.reports:
            # the goal is to get the following output for each report:
            # 1234. <first line of report #1234 goes here>
            #       <second line of report goes here>
            msg = issues.text_of_report(report) \
                        .replace('\n', '\n%s' % ((n_length + 2) * ' '))
            utils.stdout('%s. %s\n' % (str(n).rjust(n_length), msg))
            n += 1

    def prompt_report(self):
        report_number = 0
        if self.args.select is not None:
            report_number = self.parse_report_number(self.args.select[0], True)
        else:
            self.show_choices()

            if len(self) > 1:
                report_number_str = raw_input(
                    'Choose report to display (default=0): ')
                if report_number_str != '':
                    report_number = self.parse_report_number(report_number_str)
            elif len(self) == 1:
                print('Auto-selecting the only report.')

        return self.reports[report_number]

    def prompt_level(self):
        if self.args.max_level is not None:
            return self.parse_max_level(self.args.max_level[0], True)

        max_level_str = raw_input(
            'Choose maximum level of nested procedures calls (default=max): ')
        if max_level_str == '':
            max_level = sys.maxsize
        else:
            max_level = self.parse_max_level(max_level_str)

        print('')

        return max_level

    def parse_report_number(self, s, show_help=False):
        try:
            n = int(s)
        except ValueError:
            show_error_and_exit(
                'ERROR: integer report number expected',
                show_help)

        if n >= len(self) or n < 0:
            show_error_and_exit('ERROR: invalid report number.', show_help)

        return n

    def parse_max_level(self, s, show_help=False):
        if s == 'max':
            return sys.maxsize

        try:
            n = int(s)
        except ValueError:
            show_error_and_exit(
                'ERROR: integer max level or "max" expected',
                show_help)

        if n < 0:
            show_error_and_exit('ERROR: invalid max level.', show_help)

        return n

    def __len__(self):
        return len(self.reports)

    def __iter__(self):
        return self.reports.__iter__()

    def __next__(self):
        return self.reports.__next__()


def path_of_bug_number(traces_dir, i):
    return os.path.join(traces_dir, 'bug_%d.txt' % (i+1))


def url_of_bug_number(i):
    return '%s/bug_%d.txt' % (TRACES_REPORT_DIR, i+1)


def get_remote_source_template():
    """Return a template that given 'file-name' and 'line-number' entries
    gives a remote url to that source location. Return the empty
    template if no remote source has been detected. Currently only
    detects GitHub projects.
    """
    # see if we are in a GitHub project clone
    try:
        git_remote = subprocess.check_output(
            ['git',
             'config',
             '--get',
             'remote.origin.url']).decode().strip()
        m = SOURCE_REMOTE_GITHUB_RE.match(git_remote)
        if m is not None:
            project = m.group('project')
            # some remotes end in .git, but the http urls don't have
            # these
            if project.endswith('.git'):
                project = project[:-len('.git')]
            utils.stdout('Detected GitHub project %s' % project)
            hash = subprocess.check_output(
                ['git',
                 'rev-parse',
                 'HEAD']).decode().strip()
            root = subprocess.check_output(
                ['git',
                 'rev-parse',
                 '--show-toplevel']).decode().strip()
            # FIXME(t8921813): we should have a way to get absolute
            # paths in traces. In the meantime, trust that we run from
            # the same directory from which infer was run.
            relative_path = os.path.relpath(os.getcwd(), root)
            d = {
                'project': project,
                'hash': hash,
                'relative-path': relative_path,
                'file-name': '{file-name}',
                'line-number': '{line-number}',
            }
            return SOURCE_REMOTE_GITHUB_URL_TEMPLATE.format(**d)
    except subprocess.CalledProcessError:
        pass

    return None


def html_bug_trace(args, report, bug_id):
    bug_trace = ''
    bug_trace += '%s\n' % issues.text_of_report(report)
    tracer = Tracer(args)
    tracer.build_report(report)
    bug_trace += str(tracer)
    return bug_trace


def html_list_of_bugs(args, remote_source_template, selector):
    template = '\n'.join([
        '<html>',
        '<head>',
        '<title>Infer found {num-bugs} bugs</title>',
        '</head>',
        '<body>',
        '<h2>List of bugs found</h2>',
        '{list-of-bugs}',
        '</body>',
        '</html>',
    ])

    report_template = '\n'.join([
        '<li>',
        '{description}',
        '({source-uri}<a href="{trace-url}">trace</a>)',
        '</li>',
    ])

    def source_uri(report):
        d = {
            'file-name': report[issues.JSON_INDEX_FILENAME],
            'line-number': report[issues.JSON_INDEX_LINE],
        }
        if remote_source_template is not None:
            link = remote_source_template.format(**d)
            return '<a href="%s">source</a> | ' % link
        return ''

    i = 0
    list_of_bugs = '<ol>'
    for report in selector:
        d = {
            'description': issues.text_of_report(report),
            'trace-url': url_of_bug_number(i),
            'source-uri': source_uri(report),
        }
        list_of_bugs += report_template.format(**d)
        i += 1
    list_of_bugs += '</ol>'

    d = {
        'num-bugs': len(selector),
        'list-of-bugs': list_of_bugs,
    }
    return template.format(**d)


def generate_html_report(args, reports):
    html_dir = os.path.join(args.infer_out, HTML_REPORT_DIR)
    shutil.rmtree(html_dir, True)
    utils.mkdir_if_not_exists(html_dir)

    traces_dir = os.path.join(html_dir, TRACES_REPORT_DIR)
    utils.mkdir_if_not_exists(traces_dir)

    sel = Selector(args, reports)

    i = 0
    for bug in sel:
        bug_trace_path = path_of_bug_number(traces_dir, i)
        with codecs.open(bug_trace_path, 'w',
                         encoding=config.CODESET,
                         errors='xmlcharrefreplace') as bug_trace_file:
            bug_trace_file.write(html_bug_trace(args, bug, i))
        i += 1

    remote_source_template = get_remote_source_template()
    bug_list_path = os.path.join(html_dir, 'index.html')
    with codecs.open(bug_list_path, 'w',
                     encoding=config.CODESET,
                     errors='xmlcharrefreplace') as bug_list_file:
        bug_list_file.write(html_list_of_bugs(args,
                                              remote_source_template,
                                              sel))

    utils.stdout('Saved html report in:\n%s' % bug_list_path)


def main():
    args = base_parser.parse_args()

    report_filename = os.path.join(args.infer_out, config.JSON_REPORT_FILENAME)
    reports = utils.load_json_from_path(report_filename)

    if args.html:
        generate_html_report(args, reports)
        exit(0)

    sel = Selector(args, reports)

    if len(sel) == 0:
        print('No issues found')
        exit(0)

    if args.only_show:
        sel.show_choices()
        exit(0)

    report = sel.prompt_report()
    max_level = sel.prompt_level()

    utils.stdout(issues.text_of_report(report))

    tracer = Tracer(args, max_level)
    tracer.build_report(report)
    utils.stdout(str(tracer))


if __name__ == '__main__':
    main()
