#!/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/>.

import argparse
import git
import os
import shutil
import subprocess
import sys
import tempfile

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 precise 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 "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
    else:
        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 ""
    elif value:
        return " --" + flag
    else:
        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.0.3-1.el7")
        sys.exit(0)

# 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 0 < os.path.getsize(res_added):
    # 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)
