#!/usr/bin/python3

# Copyright 2014 Jakub Wilk <jwilk@jwilk.net>
#
# 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 shlex
import stat
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 not hasattr(shlex, 'quote'):
    import pipes
    shlex.quote = pipes.quote

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 os.path.isdir(datadir):
    datadir = os.path.join(os.path.dirname(rootdir), 'share',
                           'check-all-the-things', 'data')


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


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.comment = None
        self.cmd = None
        self.cmd_nargs = None
        self.flags = set()
        self.prereq = None

    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_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_prereq(self, value):
        self.prereq = value

    def get_sh_cmd(self, njobs=1):
        kwargs = {
            'files': '{} +',
            'file': '{} \\;',
            'njobs': njobs,
        }
        cmd = self.cmd.format(**kwargs)
        if self.cmd_nargs > 0:
            fcmd = ['find -type f']
            if self.match is not None:
                if len(self.match) == 1:
                    [wildcard] = self.match
                    fcmd += ['-iname', shlex.quote(wildcard)]
                else:
                    for wildcard in self.match:
                        fcmd += ['-o', '-iname', shlex.quote(wildcard)]
                    fcmd[1] = '\\('
                    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:
            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_flag_set(self, value):
        return value in self.flags


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 = {}
    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)
    return checks


def skip(skipped, name, reason):
    if reason not in skipped:
        skipped[reason] = []
    skipped[reason].append(name)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--jobs', '-j', metavar='<N>', type=int, nargs='?',
                    default=1)
    ap.add_argument('--all', '-a',
                    help="also perform checks with unintended side effects",
                    action='store_true')
    options = ap.parse_args()
    if options.jobs is None:
        options.jobs = multiprocessing.cpu_count()
    skipped = {}
    checks = parse_conf()
    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)
    for name, check in sorted(checks.items()):
        if name not in matching_checks:
            skip(skipped, name, 'no matching files')
            continue
        if check.is_flag_set('dangerous') and not options.all:
            skip(skipped, name, 'dangerous check')
            continue
        try:
            check.meet_prereq()
        except UnmetPrereq as exc:
            skip(skipped, name, str(exc))
            exc = None
        else:
            cmd = check.get_sh_cmd(njobs=options.jobs)
            comment = check.comment
            if comment:
                print(*('# ' + line for line in comment.split('\n')), sep='\n')
            print('$', cmd)
            sys.stdout.flush()
            try:
                ipc.call(cmd, shell=True, stderr=sys.stdout)
            except KeyboardInterrupt:
                skip(skipped, name, 'user interrupted')
                print()
            print()
    if skipped:
        print('Skipped checks:')
        out = TextWrapper()
        out.width = get_columns()
        out.break_long_words = False
        out.break_on_hyphens = False
        for reason in sorted(skipped):
            out.initial_indent = '- {reason}: '.format(reason=reason)
            out.subsequent_indent = ' ' * len(out.initial_indent)
            print(out.fill(' '.join(skipped[reason])))

if __name__ == '__main__':
    main()

# vim:ts=4 sw=4 et
