#!/usr/bin/python3

# Copyright 2014 Jakub Wilk <jwilk@jwilk.net>
# Copyright 2015 Paul Wise <pabs@debian.org>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

import argparse
import collections
import configparser
import fnmatch
import glob
import multiprocessing
import os
import re
import pty
import shlex
import stat
import time
import subprocess as ipc
import sys
from textwrap import TextWrapper

try:
    from shutil import get_terminal_size

    def get_columns():
        return get_terminal_size().columns
except ImportError:
    from fcntl import ioctl
    from termios import TIOCGWINSZ
    from struct import unpack

    def get_columns():
        try:
            buf = ioctl(sys.stdout.fileno(), TIOCGWINSZ, ' '*4)
            return unpack('hh', buf)[1]
        except IOError:
            return 80

if sys.stdout.isatty():
    from curses import tigetstr, setupterm
    setupterm()
    erase_line = tigetstr('el')

try:
    from shutil import which
except ImportError:
    def which(cmd):
        PATH = os.environ.get('PATH', '')
        PATH = PATH.split(os.pathsep)
        for dir in PATH:
            path = os.path.join(dir, cmd)
            if os.access(path, os.X_OK):
                return path

if not hasattr(shlex, 'quote'):
    import pipes
    shlex.quote = pipes.quote

try:
    import ptyprocess
except ImportError:
    ptyprocess = None

try:
    import apt_pkg
except ImportError:
    apt_pkg = None

this = os.path.realpath(__file__)
rootdir = os.path.dirname(this)
datadir = os.path.join(rootdir, 'data')
if not datadir or not os.path.isdir(datadir):
    datadir = os.environ.get('CATT_DATA')
if not datadir or not os.path.isdir(datadir):
    datadir = os.path.join(os.path.dirname(rootdir), 'share',
                           'check-all-the-things', 'data')


def erase_to_eol_cr():
    sys.stdout.buffer.write(erase_line)
    print(end='\r')
    sys.stdout.flush()


def spawn_header_first(cmd, header):
    if sys.stdout.isatty():
        width = get_columns()
        line = '$ ' + cmd.replace('\n', '')
        size = len(line)
        if size > width:
            line = line[:width]
        print(line, end='')
        erase_to_eol_cr()
        if ptyprocess:
            proc = ptyprocess.ptyprocess.PtyProcess.spawn(['sh', '-c', cmd])
            while True:
                try:
                    line = proc.readline()
                    if line:
                        if header:
                            erase_to_eol_cr()
                            print(header)
                            sys.stdout.flush()
                            header = None
                        sys.stdout.buffer.write(line)
                        sys.stdout.flush()
                except EOFError:
                    break
        else:
            pipe = None

            def read(fd):
                nonlocal header
                nonlocal pipe
                if not pipe:
                    pipe = open(fd, closefd=False)
                data = pipe.buffer.readline()
                if data and header:
                    erase_to_eol_cr()
                    print(header.replace('\n', '\r\n'), end='\r\n')
                    sys.stdout.flush()
                    header = None
                return data
            pty.spawn(['sh', '-c', cmd], read)
            pipe.close()
    else:
        with ipc.Popen(cmd, shell=True, stdout=ipc.PIPE, stderr=ipc.STDOUT) as proc:
            line = proc.stdout.readline()
            if line and header:
                print(header)
                sys.stdout.flush()
                header = None
            sys.stdout.buffer.write(line)
            sys.stdout.flush()
            for line in proc.stdout:
                sys.stdout.buffer.write(line)
                sys.stdout.flush()
    return not bool(header)


class UnmetPrereq(Exception):
    pass


class Check(object):
    def __init__(self):
        self.apt = None
        self.match = None
        self._match_fn = id
        self.not_match = None
        self._not_match_fn = None
        self.prune = None
        self._prune_fn = None
        self.comment = None
        self.cmd = None
        self.cmd_nargs = None
        self.flags = set()
        self.groups = set()
        self.prereq = None
        self.disabled = set()

    def set_apt(self, value):
        if apt_pkg:
            self.apt = apt_pkg.parse_depends(value)

    def set_match(self, value):
        self.match = value.split()
        regexp = '|'.join(
            fnmatch.translate(s)
            for s in self.match
        )
        regexp = r'\A(?:{re})\Z'.format(re=regexp)
        regexp = re.compile(regexp, flags=re.IGNORECASE)
        self._match_fn = regexp.match

    def set_not_match(self, value):
        self.not_match = value.split()
        regexp = '|'.join(
            fnmatch.translate(s)
            for s in self.not_match
        )
        regexp = r'\A(?:{re})\Z'.format(re=regexp)
        regexp = re.compile(regexp, flags=re.IGNORECASE)
        self._not_match_fn = regexp.match

    def set_prune(self, value):
        self.prune = value.split()
        regexp = '|'.join(
            fnmatch.translate(s)
            for s in self.prune
        )
        regexp = r'\A(?:{re})\Z'.format(re=regexp)
        regexp = re.compile(regexp, flags=re.IGNORECASE)
        self._prune_fn = regexp.match

    def set_comment(self, value):
        self.comment = value.strip()

    def set_command(self, value):
        self.cmd = cmd = value
        d = collections.defaultdict(str)
        cmd.format(**d)
        nargs = 1 * ('file' in d) + 2 * ('files' in d)
        if nargs >= 3:
            raise RuntimeError('invalid command specification: ' + cmd)
        self.cmd_nargs = nargs

    def set_flags(self, value):
        self.flags = set(value.split())

    def set_groups(self, value):
        self.groups.update(value.split())

    def set_prereq(self, value):
        self.prereq = value

    def get_sh_cmd(self, njobs=1):
        kwargs = {
            'files': '{} +',
            'file': '{} \\;',
            'njobs': njobs,
        }
        if not self.cmd:
            return
        cmd = self.cmd.format(**kwargs)
        if self.cmd_nargs > 0:
            fcmd = ['find']
            if self.prune is not None:
                fcmd += ['-type', 'd']
                if len(self.prune) == 1:
                    [wildcard] = self.prune
                    fcmd += ['-iname', shlex.quote(wildcard)]
                else:
                    end = len(fcmd)
                    for wildcard in self.prune:
                        fcmd += ['-o', '-iname', shlex.quote(wildcard)]
                    fcmd[end] = '\\('
                    fcmd += ['\\)']
                fcmd += ['-prune', '-o']
            fcmd += ['-type', 'f']
            if self.match is not None:
                if len(self.match) == 1:
                    [wildcard] = self.match
                    fcmd += ['-iname', shlex.quote(wildcard)]
                else:
                    end = len(fcmd)
                    for wildcard in self.match:
                        fcmd += ['-o', '-iname', shlex.quote(wildcard)]
                    fcmd[end] = '\\('
                    fcmd += ['\\)']
            if self.not_match is not None:
                if self.match:
                    fcmd += ['-a']
                fcmd += ['!']
                if len(self.not_match) == 1:
                    [wildcard] = self.not_match
                    fcmd += ['-iname', shlex.quote(wildcard)]
                else:
                    end = len(fcmd)
                    for wildcard in self.not_match:
                        fcmd += ['-o', '-iname', shlex.quote(wildcard)]
                    fcmd[end] = '\\('
                    fcmd += ['\\)']
            fcmd += ['-exec', cmd]
            cmd = ' '.join(fcmd)
        return cmd

    def meet_prereq(self):
        if self.prereq is None:
            if not self.cmd:
                return
            cmd = shlex.split(self.cmd)[0]
            if not which(cmd):
                raise UnmetPrereq('command not found: ' + cmd)
        else:
            try:
                with open(os.devnull, 'wb') as dev_null:
                    ipc.check_call(
                        ['sh', '-e', '-c', self.prereq],
                        stdout=dev_null,
                        stderr=dev_null,
                    )
            except ipc.CalledProcessError:
                raise UnmetPrereq('command failed: ' + self.prereq)

    def is_file_matching(self, path):
        if self._not_match_fn and self._not_match_fn(path):
            return False
        return self._match_fn(path)

    def is_dir_pruned(self, path):
        return self._prune_fn(path) if self._prune_fn else False

    def is_flag_set(self, value):
        return value in self.flags


class Formatter(argparse.ArgumentDefaultsHelpFormatter, argparse.MetavarTypeHelpFormatter):
    pass


def process(self, choices):
    action = self.change
    args = set()
    for choice in choices:
        arg = None
        if choice.startswith('='):
            action = self.change
        elif choice.startswith('+'):
            action = self.enable
        elif choice.startswith('-'):
            action = self.disable
        else:
            arg = choice
        if arg is None:
            args = set()
            arg = choice[1:]
        if arg:
            arg = set([arg])
        else:
            arg = set()
        args.update(arg)
        action(args)


class CheckSelectionAction(argparse.Action):
    msg = 'cmdline disabled check'

    def __init__(self, option_strings, dest, checks={}, prepend_values=[], *args, **kwargs):
        self.checks = checks
        self.prepend_values = prepend_values
        super().__init__(option_strings=option_strings, dest=dest, *args, **kwargs)

    def __call__(self, parser, namespace, values, option_string=None):
        for value in self.prepend_values + values:
            process(self, value.split())

    def change(self, checks):
        for name, check in self.checks.items():
            if name in checks:
                self.checks[name].disabled.clear()
            else:
                self.checks[name].disabled.add(self.msg)

    def enable(self, checks):
        for name in checks:
            self.checks[name].disabled.clear()

    def disable(self, checks):
        for name in checks:
            self.checks[name].disabled.add(self.msg)


class GroupSelectionAction(argparse.Action):

    def __init__(self, option_strings, dest, msg=None, name=None, checks={}, groups=set(), prepend_values=[], *args, **kwargs):
        self.msg = msg
        self.name = name
        self.checks = checks
        self.groups = groups
        self.prepend_values = prepend_values
        super().__init__(option_strings=option_strings, dest=dest, *args, **kwargs)

    def __call__(self, parser, namespace, values, option_string=None):
        for value in self.prepend_values + values:
            process(self, value.split())

    def change(self, groups):
        self.groups.__init__(groups)
        for name, check in self.checks.items():
            if check.__getattribute__(self.name).isdisjoint(groups):
                self.checks[name].disabled.add(self.msg)
            else:
                self.checks[name].disabled.clear()

    def enable(self, groups):
        self.groups.update(groups)
        for name, check in self.checks.items():
            if not check.__getattribute__(self.name).isdisjoint(groups):
                self.checks[name].disabled.clear()

    def disable(self, groups):
        self.groups.difference_update(groups)
        for name, check in self.checks.items():
            if not check.__getattribute__(self.name).isdisjoint(groups):
                self.checks[name].disabled.add(self.msg)


def parse_section(section):
    check = Check()
    for key, value in section.items():
        key = key.replace('-', '_')
        getattr(check, 'set_' + key)(value)
    return check


def parse_conf():
    checks = {}
    flags = set()
    groups = set()
    for path in glob.glob(os.path.join(datadir, '*')):
        cp = configparser.ConfigParser(interpolation=None)
        cp.read(path, encoding='UTF-8')
        for name in cp.sections():
            if name in checks:
                raise RuntimeError('duplicate check name: ' + name)
            section = cp[name]
            checks[name] = parse_section(section)
            checks[name].groups.update({os.path.basename(path)})
            flags.update(checks[name].flags)
            groups.update(checks[name].groups)
    return (checks, flags, groups)


def skip(skipped, name, reason):
    if reason not in skipped:
        skipped[reason] = set()
    skipped[reason].add(name)
    return True


def main():
    (checks, flags, groups) = parse_conf()
    skipped = {}

    disable_flags = {
        'dangerous': 'dangerous check',
        'todo': 'help needed',
    }

    flags.difference_update(disable_flags.keys())
    for name, check in checks.items():
        for flag, reason in disable_flags.items():
            if check.is_flag_set(flag):
                check.disabled.add(reason)

    ap = argparse.ArgumentParser(
        formatter_class=Formatter,
        description='This program is aimed at checking things related to '
                    'packaging and software development.  It automates statical '
                    'analysis of code, QA checks, syntax checking, for a very large '
                    'set of files.',
        epilog="WARNING: since it checks so many things the output can be "
               "very verbose so don't use it if you don't have time to go "
               "through the output to find problems."
    )
    ap.add_argument('--jobs', '-j', metavar='N', type=int, nargs='?',
                    help="passed to tools that can parallelize their checks",
                    default=1)
    ap.add_argument('--checks', '-c', metavar='selectors', nargs=1,
                    help="alter the set of checks to be run based on check names"
                         " (example: = cppcheck + lintian duck - duck)",
                    type=str, default=argparse.SUPPRESS, dest=argparse.SUPPRESS,
                    action=CheckSelectionAction, checks=checks)
    ap.add_argument('--flags', '-f', metavar='selectors', nargs=1,
                    help="alter the set of checks to be run based on flag names"
                         " (example: = dangerous + network - todo)",
                    type=str, default=argparse.SUPPRESS, dest=argparse.SUPPRESS,
                    action=GroupSelectionAction, msg='cmdline disabled flag', name='flags', checks=checks, groups=flags)
    ap.add_argument('--groups', '-g', metavar='selectors', nargs=1,
                    help="alter the set of checks to be run based on group names"
                         " (example: = audio c - mp3 + sh)",
                    type=str, default=argparse.SUPPRESS, dest=argparse.SUPPRESS,
                    action=GroupSelectionAction, msg='cmdline disabled group', name='groups', checks=checks, groups=groups)
    ap.add_argument('--all', '-a', nargs=0,
                    help="perform checks with possibly dangerous side effects."
                         " (equivalent: --flags +dangerous)",
                    type=str, default=argparse.SUPPRESS, dest=argparse.SUPPRESS,
                    action=GroupSelectionAction, name='flags', checks=checks, groups=flags, prepend_values=['+dangerous'])
    ap.add_argument('--interrupt', '-i', type=str,
                    help="when interrupted, quit or skip the current check",
                    default='skip', choices=['quit', 'exit', 'skip'])
    ap.add_argument('--interrupt-period', '-ip', metavar='N', type=float,
                    help="how many seconds to wait after an interrupt for another one before continuing",
                    default=0.5)
    ap.add_argument('--silent-checks', type=str,
                    help="what to do with checks that did not print any output",
                    default='hide', choices=['show', 'hide'])
    ap.add_argument('--suppressed-checks-lines', metavar='N',
                    help="output lines to use for checks per suppression reason."
                         " (<= -1: all, 0: only reasons, >= 1: N lines of checks)",
                    type=int, default=1)
    ap.add_argument('--commands', type=str,
                    help="what to do with the commands for the chosen set of hooks",
                    default='run', choices=['run', 'show'])
    options = ap.parse_args()
    if options.jobs is None:
        options.jobs = multiprocessing.cpu_count()
    last_interrupt = 0
    matching_checks = set()
    for root, dirs, files in os.walk('.'):
        for file in files:
            path = os.path.join(root, file)
            st = os.lstat(path)
            if not stat.S_ISREG(st.st_mode):
                continue
            for name, check in checks.items():
                if name in matching_checks:
                    continue
                if check.is_file_matching(path):
                    matching_checks.add(name)
        i = 0
        for dir in dirs:
            for name, check in checks.items():
                if check.is_dir_pruned(path):
                    del dirs[i]
            i += 1
    for name, check in sorted(checks.items()):
        next = False
        if name not in matching_checks:
            next |= skip(skipped, name, 'no matching files')
        for reason in checks[name].disabled:
            next |= skip(skipped, name, reason)
        if next:
            continue
        try:
            check.meet_prereq()
        except UnmetPrereq as exc:
            skip(skipped, name, str(exc))
            exc = None
        else:
            show_check = options.silent_checks == 'show' or options.commands == 'show'
            if (time.time()-last_interrupt) < options.interrupt_period:
                try:
                    time.sleep(options.interrupt_period)
                except KeyboardInterrupt:
                    print()
                    sys.exit()
            cmd = check.get_sh_cmd(njobs=options.jobs)
            comment = check.comment
            header = ''
            if comment:
                header += ''.join('# ' + line + '\n' for line in comment.split('\n'))
            if cmd:
                header += '$ ' + cmd
            if show_check or (check.comment and not cmd):
                print(header)
                sys.stdout.flush()
            try:
                if cmd and options.commands == 'run':
                    if show_check:
                        ipc.call(cmd, shell=True, stderr=ipc.STDOUT)
                    else:
                        show_check |= spawn_header_first(cmd, header)
                        if not show_check:
                            skip(skipped, name, 'no output')
            except KeyboardInterrupt:
                if options.interrupt in {'exit', 'quit'} or (time.time()-last_interrupt) < options.interrupt_period:
                    if show_check:
                        print()
                    sys.exit()
                elif options.interrupt == 'skip':
                    skip(skipped, name, 'user interrupted')
                    if show_check:
                        print()
                last_interrupt = time.time()
            if cmd and show_check:
                print()
    if options.commands == 'run' and options.silent_checks == 'hide' and sys.stdout.isatty():
        erase_to_eol_cr()
    if skipped:
        header = 'Skipped and hidden checks:'
        out = TextWrapper()
        out.width = get_columns()
        out.break_long_words = False
        out.break_on_hyphens = False
        if options.suppressed_checks_lines == 0:
            print(header + ' ' + out.fill(', '.join(sorted(skipped))))
        else:
            print(header)
            if options.suppressed_checks_lines >= 1:
                out.placeholder = ' ...'
                out.max_lines = options.suppressed_checks_lines
            for reason in sorted(skipped):
                out.initial_indent = '- {reason}: '.format(reason=reason)
                out.subsequent_indent = ' ' * len(out.initial_indent)
                print(out.fill(' '.join(sorted(skipped[reason]))))

if __name__ == '__main__':
    main()

# vim:ts=4 sw=4 et
