#!/usr/bin/env python
# -*- coding: utf-8 -*-

import argparse
import os
import re
import shutil
import subprocess
import sys
import tempfile
import signal
from functools import reduce, partial

from distutils.version import LooseVersion


__version__ = '0.8.0'


PLUGIN_TYPES = ('connection', 'lookup', 'modules', 'doc_fragments', 'module_utils', 'callback', 'inventory')


class AndeBox:
    actions = {}
    parser = None

    def add_actions(self, *actions):
        self.actions.update({ac.name: ac() for ac in actions})

    def build_argparser(self):
        self.parser = argparse.ArgumentParser(prog="andebox", description="Ansible Collection Developer's Box")
        self.parser.add_argument("--version", action="version", version="%(prog)s {0}".format(__version__))
        self.parser.add_argument("--collection", "-c",
                                 help="fully qualified collection name (not necessary if a proper galaxy.yml file is available)")
        subparser = self.parser.add_subparsers(dest="action", required=True)

        for action in self.actions.values():
            action.make_parser(subparser)

    def run(self):
        self.build_argparser()
        args = self.parser.parse_args()
        action = self.actions[args.action]
        action.run(args)


class AndeboxAction:
    name = None
    help = None
    args = []  # of dict(names=[], specs={})

    def make_parser(self, subparser):
        action_parser = subparser.add_parser(self.name, help=self.help)
        for arg in self.args:
            action_parser.add_argument(*arg['names'], **arg['specs'])
        return action_parser

    def run(self, args):
        raise NotImplementedError()

    @staticmethod
    def read_coll_meta():
        import yaml

        with open("galaxy.yml") as galaxy_meta:
            meta = yaml.load(galaxy_meta, Loader=yaml.BaseLoader)
        return meta['namespace'], meta['name'], meta['version']

    def determine_collection(self, coll_arg):
        if coll_arg:
            coll_split = coll_arg.split('.')
            return '.'.join(coll_split[:-1]), coll_split[-1]
        return self.read_coll_meta()[:2]

    @staticmethod
    def copy_exclude_lines(src, dest, exclusion_filenames):
        with open(src, "r") as src_file:
            with open(dest, "w") as dest_file:
                for line in src_file.readline():
                    if not any(line.startswith(f) for f in exclusion_filenames):
                        dest_file.write(line)


class AnsibleTestError(Exception):
    pass


class IgnoreFileEntry:
    pattern = re.compile(r'^(?P<filename>\S+)\s(?P<ignore>\S+)(?:\s+#\s*(?P<comment>\S.*\S))?\s*$')
    file_filter = None
    check_filter = None
    file_parts_depth = None

    def __init__(self, filename, ignore, comment):
        self.filename = filename
        self._file_parts = self.filename.split("/")

        if ':' in ignore:
            self.ignore, self.error_code = ignore.split(":")
        else:
            self.ignore, self.error_code = ignore, None
        self.comment = comment

    @property
    def ignore_check(self):
        return "{0}:{1}".format(self.ignore, self.error_code) if self.error_code else self.ignore

    @property
    def rebuilt_comment(self):
        return " # {0}".format(self.comment) if self.comment else ""

    @property
    def file_parts(self):
        if self.file_parts_depth is None:
            return os.path.join(*self._file_parts)

        return os.path.join(*self._file_parts[:self.file_parts_depth])

    def __str__(self):
        return "<IgnoreFileEntry: {0} {1}{2}>".format(self.filename, self.ignore_check, self.rebuilt_comment)

    def __repr__(self):
        return str(self)

    @staticmethod
    def parse(line):
        match = IgnoreFileEntry.pattern.match(line)
        if not match:
            raise ValueError("Line cannot be parsed as an ignore-file entry: {0}".format(line))

        ffilter = IgnoreFileEntry.file_filter
        if ffilter is not None:
            ffilter = ffilter if isinstance(ffilter, re.Pattern) else re.compile(ffilter)
            if not ffilter.search(match.group("filename")):
                return None

        ifilter = IgnoreFileEntry.check_filter
        if ifilter is not None:
            ifilter = ifilter if isinstance(ifilter, re.Pattern) else re.compile(ifilter)
            if not ifilter.search(match.group("ignore")):
                return None

        return IgnoreFileEntry(match.group("filename"), match.group("ignore"), match.group("comment"))


class ResultLine:
    def __init__(self, file_part, ignore_check, count=1):
        self.file_part = file_part
        self.ignore_check = ignore_check
        self.count = count

    def increase(self):
        self.count = self.count + 1
        return self

    def __lt__(self, other):
        return self.count < other.count

    def __le__(self, other):
        return self.count <= other.count

    def __gt__(self, other):
        return self.count > other.count

    def __ge__(self, other):
        return self.count >= other.count

    def __eq__(self, other):
        return self.count == other.count

    def __ne__(self, other):
        return self.count != other.count

    def __str__(self):
        r = ["{0:6} ".format(self.count)]
        if self.file_part:
            r.append(" ")
            r.append(self.file_part)
        if self.ignore_check:
            r.append(" ")
            r.append(self.ignore_check)
        return "".join(r)

    def __repr__(self):
        r = ["<ResultLine: ", str(self.count), ","]
        if self.file_part:
            r.append(" ")
            r.append(self.file_part)
        if self.ignore_check:
            r.append(" ")
            r.append(self.ignore_check)
        r.append(">")
        return "".join(r)


class AnsibleTestAction(AndeboxAction):
    name = "test"
    help = "runs ansible-test in a temporary environment"
    args = [
        dict(names=("--keep", "-k"),
             specs=dict(action="store_true", help="Keep temporary directory after execution")),
        dict(names=("--exclude-ignore", "-ei"),
             specs=dict(action="store_true", help="Matching lines in ignore files will be filtered out")),
        dict(names=("ansible_test_params", ),
             specs=dict(nargs="+")),
    ]

    def make_parser(self, subparser):
        action_parser = super().make_parser(subparser)
        action_parser.epilog = "Notice the use of '--' to delimit andebox's options from ansible-test's"
        action_parser.usage = "%(prog)s usage: andebox test [-h] [--keep] -- [ansible_test_params ...]"

    def run(self, args):
        namespace, collection = self.determine_collection(args.collection)

        top_dir = tempfile.mkdtemp(prefix="andebox.")
        coll_dir = os.path.join(top_dir, "ansible_collections", namespace, collection)
        os.makedirs(coll_dir)
        info = {"collection": "{0}.{1}".format(namespace, collection), "top_dir": top_dir, "coll_dir": coll_dir}
        for k, v in info.items():
            print("{0:10} = {1}".format(k, v), file=sys.stderr)
        print(file=sys.stderr)

        try:
            self.copy_collection(coll_dir)
            self.exclude_from_ignore(args.exclude_ignore, args.ansible_test_params, coll_dir)

            os.putenv('COLLECTIONS_PATH', ':'.join([coll_dir] + os.environ.get('COLLECTIONS_PATH', '').split(':')))
            rc = subprocess.call(["ansible-test"] + args.ansible_test_params, cwd=coll_dir)

            if rc != 0:
                raise AnsibleTestError("Error running ansible-test (rc={0})".format(rc))

        finally:
            if not args.keep:
                print('Removing temporary directory: {0}'.format(coll_dir))
                shutil.rmtree(top_dir)
            else:
                print('Keeping temporary directory: {0}'.format(coll_dir))

    def exclude_from_ignore(self, exclude_ignore, ansible_test_params, coll_dir):
        if exclude_ignore:
            src_dir = os.path.join(os.getcwd(), 'tests', 'sanity')
            dest_dir = os.path.join(coll_dir, 'tests', 'sanity')
            with os.scandir(src_dir) as ts_dir:
                for ts_entry in ts_dir:
                    if ts_entry.name.startswith('ignore-') and ts_entry.name.endswith('.txt'):
                        self.copy_exclude_lines(os.path.join(src_dir, ts_entry.name),
                                                os.path.join(dest_dir, ts_entry.name),
                                                ansible_test_params)

    @staticmethod
    def copy_collection(coll_dir):
        # copy files to tmp ansible coll dir
        with os.scandir() as it:
            for entry in it:
                if entry.name.startswith('.'):
                    continue
                if entry.is_dir():
                    shutil.copytree(entry.name, os.path.join(coll_dir, entry.name))
                else:
                    shutil.copy(entry.name, os.path.join(coll_dir, entry.name))


class IgnoreLinesAction(AndeboxAction):
    ignore_path = os.path.join('.', 'tests', 'sanity')

    name = "ignores"
    help = "gathers stats on ignore*.txt file(s)"

    @property
    def args(self):
        with os.scandir(os.path.join(self.ignore_path)) as it:
            ignore_versions = sorted([
                LooseVersion(entry.name[7:-4])
                for entry in it
                if entry.name.startswith("ignore-") and entry.name.endswith(".txt")
            ])

        return [
            dict(names=["--ignore-file-spec", "-ifs"],
                 specs=dict(choices=ignore_versions + ["-"],
                            help="Use the ignore file matching this Ansible version. "
                                 "The special value '-' may be specified to read "
                                 "from stdin instead. If not specified, will use all available files")),
            dict(names=["--depth", "-d"],
                 specs=dict(type=int, help="Path depth for grouping files")),
            dict(names=["--file-filter", "-ff"],
                 specs=dict(type=re.compile, help="Regexp matching file names to be included")),
            dict(names=["--check-filter", "-cf"],
                 specs=dict(type=re.compile, help="Regexp matching checks in ignore files to be included")),
            dict(names=["--suppress-files", "-sf"],
                 specs=dict(action="store_true", help="Supress file names from the output, consolidating the results")),
            dict(names=["--suppress-checks", "-sc"],
                 specs=dict(action="store_true", help="Suppress the checks from the output, consolidating the results")),
            dict(names=["--head", "-H"],
                 specs=dict(type=int, default=10, help="Number of lines to display in the output: leading lines if "
                                                       "positive, trailing lines if negative, all lines if zero.")),
        ]

    def make_fh_list_for_version(self, version):
        if version == "-":
            return [sys.stdin]
        if version:
            return [open(os.path.join(self.ignore_path, 'ignore-{0}.txt'.format(version)))]

        with os.scandir(os.path.join(self.ignore_path)) as it:
            return [open(os.path.join(self.ignore_path, entry.name))
                    for entry in it
                    if entry.name.startswith("ignore-") and entry.name.endswith(".txt")]

    @staticmethod
    def read_ignore_file(fh):
        result = []
        with fh:
            for line in fh.readlines():
                entry = IgnoreFileEntry.parse(line)
                if entry:
                    result.append(entry)
        return result

    def retrieve_ignore_entries(self, version):
        return reduce(lambda a, b: a + b,
                      [self.read_ignore_file(fh) for fh in self.make_fh_list_for_version(version)])

    @staticmethod
    def filter_lines(lines, num):
        if num == 0:
            return lines
        return lines[num:] if num < 0 else lines[:num]

    def run(self, args):
        if args.file_filter:
            IgnoreFileEntry.file_filter = args.file_filter
        if args.check_filter:
            IgnoreFileEntry.check_filter = args.check_filter
        if args.depth:
            IgnoreFileEntry.file_parts_depth = args.depth

        try:
            ignore_entries = self.retrieve_ignore_entries(args.ignore_file_spec)
        except Exception as e:
            print("Error reading ignore file {0}: {1}".format(args.ignore_file_spec, str(e)), file=sys.stderr)
            raise e

        count_map = {}
        for entry in ignore_entries:
            fp = entry.file_parts if not args.suppress_files else ""
            ic = entry.ignore_check if not args.suppress_checks else ""
            key = fp + "|" + ic
            count_map[key] = count_map.get(key, ResultLine(fp, ic, 0)).increase()

        lines = [str(s) for s in sorted(count_map.values(), reverse=True)]
        print("\n".join(self.filter_lines(lines, args.head)))


def info_type(types, v):
    try:
        r = [t for t in types if t.startswith(v.lower())]
        return r[0][0].upper()
    except IndexError as e:
        raise argparse.ArgumentTypeError("invalid value: {0}".format(v)) from e


class RuntimeAction(AndeboxAction):
    RUNTIME_TYPES = ('redirect', 'tombstone', 'deprecation')
    name = "runtime"
    help = "returns information from runtime.yml"
    args = [
        dict(names=["--plugin-type", "-pt"],
             specs=dict(choices=PLUGIN_TYPES,
                        help="Specify the plugin type to be searched")),
        dict(names=["--regex", "--regexp", "-r"],
             specs=dict(action="store_true",
                        help="Treat plugin names as regular expressions")),
        dict(names=["--info-type", "-it"],
             specs=dict(type=partial(info_type, RUNTIME_TYPES),
                        help="Restrict type of response elements. Must be in {0}, may be shortened "
                             "down to one letter.".format(RUNTIME_TYPES))),
        dict(names=["plugin_names"],
             specs=dict(nargs='+')),
    ]
    name_tests = []
    current_version = None
    info_type = None

    def print_runtime(self, name, node):
        def is_info_type(_type):
            return self.info_type is None or self.info_type.lower() == _type.lower()

        redir, tomb, depre = [node.get(x) for x in self.RUNTIME_TYPES]
        if redir and is_info_type('R'):
            print('R {0}: redirected to {1}'.format(name, redir))
        elif tomb and is_info_type('T'):
            print('T {0}: terminated in {1}: {2}'.format(name, tomb['removal_version'], tomb['warning_text']))
        elif depre and is_info_type('D'):
            print('D {0}: deprecation in {1} (current={2}): {3}'.format(
                  name, depre['removal_version'], self.current_version, depre['warning_text']))

    def runtime_process_plugin(self, plugin_routing, plugin_types):
        for plugin_type in plugin_types:
            matching = [
                name
                for name in plugin_routing[plugin_type]
                if any(test(name) for test in self.name_tests)
            ]
            for name in matching:
                self.print_runtime('{0} {1}'.format(plugin_type, name), plugin_routing[plugin_type][name])

    def run(self, args):
        import yaml

        with open(os.path.join("meta", "runtime.yml")) as runtime_yml:
            runtime = yaml.load(runtime_yml, Loader=yaml.BaseLoader)

        plugin_types = [args.plugin_type] if args.plugin_type else PLUGIN_TYPES
        _, _, self.current_version = self.read_coll_meta()
        self.info_type = args.info_type

        def name_test(name, other):
            if name.endswith('.py'):
                name = name.split('/')[-1]
                name = name.split('.')[0]
            return name == other

        test_func = re.search if args.regex else name_test
        self.name_tests = [partial(test_func, n) for n in args.plugin_names]

        self.runtime_process_plugin(runtime['plugin_routing'], plugin_types)


def main():
    box = AndeBox()
    box.add_actions(AnsibleTestAction, IgnoreLinesAction, RuntimeAction)
    box.run()


if __name__ == '__main__':
    try:
        signal.signal(signal.SIGPIPE, signal.SIG_DFL)
        main()
    except KeyboardInterrupt:
        sys.exit(2)
    except (BrokenPipeError, IOError):
        pass
    except AnsibleTestError as e:
        print(str(e), file=sys.stderr)
        sys.exit(1)
