#!/usr/bin/env python

# Copyright (C) 2014-2015 Red Hat, Inc.
#
# This file is part of csmock.
#
# csmock is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# any later version.
#
# csmock is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with csmock.  If not, see <http://www.gnu.org/licenses/>.

# standard imports
import argparse
import os
import shutil
import subprocess
import sys
import tempfile

# external imports
import git

# local imports
from csmock.common.cflags import flags_by_warning_level
from csmock.common.util   import add_paired_flag


CSBUILD_TRAVIS_MIRROR = \
        "deb https://kdudka.fedorapeople.org/csbuild trusty contrib"

RUN_SCAN_SH = "/usr/share/csbuild/scripts/run-scan.sh"

DEFAULT_ADDED_EXIT_CODE = 7

DEFAULT_BASE_FAIL_EXIT_CODE = 0

DEFAULT_CSWRAP_TIMEOUT = 30

DEFAULT_EMBED_CONTEXT = 3

DEFAULT_GCC_WARNING_LEVEL = 2

TOOL_NAME = sys.argv[0]


class StatusWriter:
    def __init__(self):
        self.color_n = ""
        self.color_r = ""
        self.color_g = ""
        self.color_y = ""
        self.color_b = ""
        self.color_opt = "--no-color"
        self.csgrep_args = "--invert-match --event \"internal warning\""

    def enable_colors(self):
        self.color_n = "\033[0m"
        self.color_r = "\033[1;31m"
        self.color_g = "\033[1;32m"
        self.color_y = "\033[1;33m"
        self.color_b = "\033[1;34m"
        self.color_opt = "--color"

    def die(self, msg, ec=1):
        sys.stderr.write("%s: %sfatal error%s: %s\n" %
                         (TOOL_NAME, self.color_r, self.color_n, msg))
        sys.exit(ec)

    def emit_warning(self, msg):
        sys.stderr.write("%s: %swarning%s: %s\n" %
                         (TOOL_NAME, self.color_y, self.color_n, msg))

    def emit_status(self, msg):
        sys.stderr.write("%s: %sstatus%s: %s\n" %
                         (TOOL_NAME, self.color_g, self.color_n, msg))

    def print_stats(self, err_file):
        os.system("csgrep --mode=stat %s %s \"%s\"" %
                  (self.csgrep_args, self.color_opt, err_file))

    def print_defects_if_any(self, err_file, title):
        if os.path.getsize(err_file) <= 0:
            return

        hline = "=" * len(title)
        print("\n%s%s\n%s%s" % (self.color_b, title, hline, self.color_n))

        # pass the --[no-]color option to csgrep
        os.system("csgrep %s %s \"%s\"" %
                  (self.csgrep_args, self.color_opt, err_file))

# FIXME: global instance
sw = StatusWriter()

# FIXME: copy/paste from csmock


def shell_quote(str_in):
    str_out = ""
    for i in range(0, len(str_in)):
        c = str_in[i]
        if c == "\\":
            str_out += "\\\\"
        elif c == "\"":
            str_out += "\\\""
        else:
            str_out += c
    return "\"" + str_out + "\""


def scan_or_die(cmd, what, fail_exit_code=1):
    sw.emit_status("running %s..." % what)
    sys.stderr.write("+ %s\n" % cmd)
    ret = os.system(cmd)

    signal = os.WTERMSIG(ret)
    if signal != 0:
        sw.die("%s signalled by signal %d" % (what, signal))

    status = os.WEXITSTATUS(ret)
    if status == 125 or (what == "prep" and status != 0):
        sw.die("%s failed: %s" % (what, cmd), ec=fail_exit_code)
    if status not in [0, 7]:
        sw.die("%s failed with exit code %d" % (what, status))

    sw.emit_status("%s succeeded" % what)
    return status


def stable_commit_ref(repo, ref):
    if hasattr(repo, "rev_parse"):
        commit = repo.rev_parse(ref)
    else:
        # repo.rev_parse() is not implemented on Ubuntu 12.04.5 LTS
        p = subprocess.Popen(["git", "rev-parse", ref], stdout=subprocess.PIPE)
        (out, _) = p.communicate()
        if p.returncode != 0:
            raise Exception("git rev-parse failed")
        commit = out.decode("utf8").strip()

    if "HEAD" in ref:
        # if HEAD is used in ref, we have have to checkout by hash (because
        # HEAD is going to change after checkout or git-bisect, which would
        # invalidate ref)
        return commit

    return ref


def do_git_checkout(repo, commit):
    sw.emit_status("checking out %s" % commit)
    repo.git.checkout(commit)


def encode_paired_flag(args, flag):
    value = getattr(args, flag.replace("-", "_"))
    if value is None:
        return ""
    if value:
        return " --" + flag
    return " --no-" + flag


def encode_csbuild_args(args):
    cmd = " -c %s" % shell_quote(args.build_cmd)

    if args.git_bisect:
        cmd += " --git-bisect"

    if args.added_exit_code != DEFAULT_ADDED_EXIT_CODE:
        cmd += " --added-exit-code %d" % args.added_exit_code

    if args.base_fail_exit_code != DEFAULT_BASE_FAIL_EXIT_CODE:
        cmd += " --base-fail-exit-code %d" % args.base_fail_exit_code

    if args.cswrap_timeout != DEFAULT_CSWRAP_TIMEOUT:
        cmd += " --cswrap-timeout %d" % args.cswrap_timeout

    if args.embed_context != DEFAULT_EMBED_CONTEXT:
        cmd += " -U%d" % args.embed_context

    if args.gcc_warning_level != DEFAULT_GCC_WARNING_LEVEL:
        cmd += " -w%d" % args.gcc_warning_level

    cmd += encode_paired_flag(args, "print-current")
    cmd += encode_paired_flag(args, "print-added")
    cmd += encode_paired_flag(args, "print-fixed")
    cmd += encode_paired_flag(args, "clean")
    cmd += encode_paired_flag(args, "color")
    return cmd


def print_yml_pair(name, value):
    print("%s: %s" % (name, value))


def print_yml_section(name):
    print("\n%s:" % name)


def print_yml_item(item):
    print("    - %s" % item)


def gen_travis_yml(args):
    print_yml_pair("language", "cpp")
    print_yml_pair("compiler", "gcc")

    # before_install
    print_yml_section("before_install")
    if "https://" in CSBUILD_TRAVIS_MIRROR:
        print_yml_item("sudo apt-get update -qq")
        print_yml_item("sudo apt-get install -qq apt-transport-https")
    print_yml_item("echo \"%s\" | sudo tee -a /etc/apt/sources.list" %
                   CSBUILD_TRAVIS_MIRROR)
    print_yml_item("sudo apt-get update -qq")

    # install
    print_yml_section("install")
    print_yml_item("sudo apt-get install -qq -y --force-yes csbuild")
    print_yml_item("sudo apt-get install %s" % args.install)

    # script
    print_yml_section("script")
    if args.prep_cmd is not None:
        print_yml_item(args.prep_cmd)
    print_yml_item("test -z \"$TRAVIS_COMMIT_RANGE\" \
|| csbuild --git-commit-range \"$TRAVIS_COMMIT_RANGE\"" +
                   encode_csbuild_args(args))

    # all OK
    return 0


# argparse._VersionAction would write to stderr, which breaks help2man
class VersionPrinter(argparse.Action):
    def __init__(self, option_strings, dest=None, default=None, help=None):
        super(VersionPrinter, self).__init__(
            option_strings=option_strings, dest=dest, default=default, nargs=0,
            help=help)

    def __call__(self, parser, namespace, values, option_string=None):
        print("csmock-2.1.0-1.el7")
        sys.exit(0)


def main():
    # initialize argument parser
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "-c", "--build-cmd", required=True,
        help="shell command used to build the sources (runs in $PWD)")

    # optional arguments
    parser.add_argument(
        "-g", "--git-commit-range",
        help="range of git revisions for a differential scan")

    parser.add_argument(
        "--git-bisect", action="store_true",
        help="if a new defect is added, use git-bisect to identify the cause \
WARNING: The given command must (re)compile all sources for this option to work!")

    parser.add_argument(
        "--added-exit-code", type=int, default=DEFAULT_ADDED_EXIT_CODE,
        help="exit code to return if there is any defect added in the new version")

    parser.add_argument(
        "--base-fail-exit-code", type=int, default=DEFAULT_BASE_FAIL_EXIT_CODE,
        help="exit code to return if the base scan fails")

    add_paired_flag(
        parser, "print-current",
        help="print all defects in the current version (default unless -g is given) \
WARNING: The given command must (re)compile all sources for this option to work!")

    add_paired_flag(
        parser, "print-added",
        help="print defects added in the new version (default if -g is given)")

    add_paired_flag(
        parser, "print-fixed",
        help="print defects fixed in the new version \
WARNING: The given command must (re)compile all sources for this option to work!")

    add_paired_flag(
        parser, "clean",
        help="clean the temporary directory with results on exit (default)")

    parser.add_argument(
        "--cswrap-timeout", type=int, default=DEFAULT_CSWRAP_TIMEOUT,
        help="maximal amount of time taken by analysis of a single module [s]")

    add_paired_flag(
        parser, "color",
        help="use colorized console output (default if connected to a tty)")

    parser.add_argument(
        "--gen-travis-yml", action="store_true",
        help="generate the .travis.yml file for Travis CI (requires --install)")

    parser.add_argument(
        "--install",
        help="space-separated list of packages to install with --gen-travis-yml")

    parser.add_argument(
        "--prep-cmd",
        help="shell command to run before the build (runs in $PWD)")

    parser.add_argument(
        "-w", "--gcc-warning-level", type=int, default=DEFAULT_GCC_WARNING_LEVEL,
        help="Adjust GCC warning level.  -w0 means no additional warnings, \
-w1 appends -Wall and -Wextra, and -w2 enables some other useful warnings \
(default).")

    parser.add_argument(
        "-U", "--embed-context", type=int, default=DEFAULT_EMBED_CONTEXT,
        help="embed a number of lines of context from the source file for the \
key event (defaults to 3).")

    # needed for help2man
    parser.add_argument(
        "--version", action=VersionPrinter,
        help="print the version of csbuild and exit")

    # parse command-line arguments
    args = parser.parse_args()

    if args.gen_travis_yml:
        if args.install is None:
            parser.error("--install is required with --gen-travis-yml")
        if args.git_commit_range is not None:
            parser.error("--git-commit-range makes no sense with --gen-travis-yml")
        ret = gen_travis_yml(args)
        sys.exit(ret)
    elif args.install is not None:
        parser.error("--install makes sense only with --gen-travis-yml")

    # initialize color escape sequences if enabled
    if args.color is None:
        args.color = sys.stdout.isatty() and sys.stderr.isatty()
    if args.color:
        sw.enable_colors()

    diff_scan = args.git_commit_range is not None
    if diff_scan:
        # parse git commit range
        tokenized = args.git_commit_range.split("...")
        if len(tokenized) != 2:
            tokenized = args.git_commit_range.split("..")
        if len(tokenized) != 2:
            parser.error("not a range of git revisions: " + args.git_commit_range)

        try:
            repo = git.Repo(".")
        except:
            parser.error("failed to open git repository: .")

        try:
            old_commit = stable_commit_ref(repo, tokenized[0])
            new_commit = stable_commit_ref(repo, tokenized[1])
        except:
            parser.error("failed to resolve the range of git revisions: " +
                         args.git_commit_range)

        if hasattr(repo.is_dirty, "__call__") and repo.is_dirty():
            sw.emit_warning("git repository is dirty: .")

    # initialize defaults where necessary
    if args.print_current is None:
        args.print_current = not diff_scan
    if args.print_added is None:
        args.print_added = diff_scan
    if args.print_fixed is None:
        args.print_fixed = False
    if args.clean is None:
        args.clean = True

    # check for possible conflict of command-line options
    if not diff_scan:
        if args.git_bisect \
                or (args.added_exit_code != DEFAULT_ADDED_EXIT_CODE) \
                or (args.base_fail_exit_code != DEFAULT_BASE_FAIL_EXIT_CODE) \
                or args.print_added or args.print_fixed:
            parser.error("options --git-bisect, --added-exit-code, --print-added, \
--base-fail-exit-code, and --print-fixed make sense only with --git-commit-range")

    if args.prep_cmd is not None:
        # run the command given by --prep-cmd
        scan_or_die(args.prep_cmd, "prep")

    # create a temporary directory for the results
    res_dir = tempfile.mkdtemp(prefix="csbuild")

    # prepare environment
    env = {}
    env["CSWRAP_TIMEOUT"] = "%d" % args.cswrap_timeout
    env["CSWRAP_TIMEOUT_FOR"] = "clang:clang++:cppcheck"

    # resolve compiler flags
    flags = flags_by_warning_level(args.gcc_warning_level)
    flags.write_to_env(env)

    # serialize environment
    cmd_prefix = ""
    for var in env:
        cmd_prefix += "%s='%s' " % (var, env[var])

    # prepare template for running the run-scan.sh script
    cmd = "%s %s %s %s %s" % (
        cmd_prefix, RUN_SCAN_SH,
        shell_quote(res_dir),
        shell_quote(args.build_cmd),
        shell_quote("csgrep --embed-context %d" % args.embed_context))

    curr = "%s/current.err" % res_dir

    if diff_scan:
        # scan base revision first
        # TODO: handle checkout failures
        do_git_checkout(repo, old_commit)
        scan_or_die(cmd, "base scan", fail_exit_code=args.base_fail_exit_code)
        sw.print_stats(curr)
        base = "%s/base.err" % res_dir
        shutil.move(curr, base)
        cmd += " %s" % shell_quote(base)
        do_git_checkout(repo, new_commit)

    # scan the current version
    ret = scan_or_die(cmd, "scan")
    sw.print_stats(curr)

    # acknowledge the overall status
    if diff_scan:
        if ret == 0:
            sw.emit_status("no new defects found!")
        else:
            sw.emit_warning("new defects found!")

    res_added = "%s/added.err" % res_dir
    if args.git_bisect and os.path.getsize(res_added) > 0:
        # new defects found and we are asked to git-bisect the cause
        res_dir_gb = "%s/git-bisect" % res_dir
        os.mkdir(res_dir_gb)
        cmd = cmd.replace(res_dir, res_dir_gb, 1)
        sw.emit_status("running git-bisect...")
        cmd = "git bisect start %s %s \
&& git bisect run $SHELL -c %s \
&& git bisect reset" \
                % (new_commit, old_commit, shell_quote(cmd))
        sys.stderr.write("+ %s\n" % cmd)
        os.system(cmd)

    # print the results selected by the command-line options
    if args.print_current:
        sw.print_defects_if_any("%s/current.err" % res_dir, "CURRENT DEFECTS")
    if args.print_fixed:
        sw.print_defects_if_any("%s/fixed.err" % res_dir, "FIXED DEFECTS")
    if args.print_added:
        sw.print_defects_if_any(res_added, "ADDED DEFECTS")

    if args.clean:
        # purge the temporary directory
        shutil.rmtree(res_dir)
    else:
        print("\nScan results: %s\n" % res_dir)

    if ret != 0:
        # return the required exit code if new defects were found
        sys.exit(args.added_exit_code)

if __name__ == '__main__':
    main()
