"""
Iguana (c) by Marc Ammon, Moritz Fickenscher, Lukas Fridolin,
Michael Gunselmann, Katrin Raab, Christian Strate

Iguana is licensed under a
Creative Commons Attribution-ShareAlike 4.0 International License.

You should have received a copy of the license along with this
work. If not, see <http://creativecommons.org/licenses/by-sa/4.0/>.
"""
import argparse
import importlib
from importlib import util as import_util
import inspect
from io import StringIO
import json
import os
import platform
import shutil
import site
import subprocess
import sys
import tarfile
import textwrap
from urllib.request import urlopen
import venv
from subprocess import Popen, STDOUT


###########
# VARIABLES
###########
# base directories
BASE_DIR = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
FILES_DIR = os.path.join(BASE_DIR, "files")
STATIC_FILES = os.path.join(BASE_DIR, "static_files")

# settings file for the Makefile
MAKE_SETTINGS_FILE = os.path.join(BASE_DIR, ".makeSettings")

# virtualenv settings
VIRTUALENV_BASE_DIR = os.path.join(BASE_DIR, "virtualenv")
# add virtualenv bin directory to the PATH (because some executables need to be in PATH)
os.environ["PATH"] += ":" + os.path.join(VIRTUALENV_BASE_DIR, "bin")

# tools directory
TOOLS = os.path.join(BASE_DIR, "tools")

# Iguana settings
IGUANA_BASE_DIR = os.path.join(BASE_DIR, "src")
IGUANA_SCSS_DIR = os.path.join(IGUANA_BASE_DIR, "common", "scss")
IGUANA_STATIC_FILES_DIR = os.path.join(IGUANA_BASE_DIR, "common", "static")
WEBDRIVER_CONF_FILE = os.path.join(IGUANA_BASE_DIR, "common", "settings", "webdriver.py")

# django settings
DJANGO_SETTINGS_MODULE = "common.settings"

# coverage settings
COVERAGE_SETTINGS_FILE = os.path.join(IGUANA_BASE_DIR, ".coveragerc")
COVERAGE_DATA_FILE = os.path.join(IGUANA_BASE_DIR, ".coverage")

# git hooks directory
GITHOOK_DIR = os.path.join(BASE_DIR, ".git", "hooks")
# custom git hook directory
CUSTOM_GIT_HOOK_DIR = os.path.join(TOOLS, "git-hooks")

# log files directory
LOG_DIR = os.path.join(FILES_DIR, "logs")


############################
# TARGET/ARGUMENT DECORATORS
############################

def _add_variable_decorator(cls_to_decorate, name="", value=None):
    # the value can be passed as tuple, if multiple values are supported
    if isinstance(value, tuple):
        value = list(value)

        # add the value to a list
        value_list = getattr(cls_to_decorate, name, [])
        setattr(cls_to_decorate, name, value_list + value)
    else:
        setattr(cls_to_decorate, name, value)

    return cls_to_decorate


def cmd(cmd=""):
    def wrap(cls):
        return _add_variable_decorator(cls, "cmd", cmd)
    return wrap


def arg(argument="", arg_short=None, arg_long=None):
    def wrap(cls):
        cls = _add_variable_decorator(cls, "arg", argument)
        cls.arg_short = arg_short
        cls.arg_long = arg_long
        return cls
    return wrap


def default(default=""):
    def wrap(cls):
        return _add_variable_decorator(cls, "default", default)
    return wrap


def required(cls):
    return _add_variable_decorator(cls, "is_required", True)


def boolean(cls):
    return _add_variable_decorator(cls, "is_boolean", True)


def multiple(cls):
    return _add_variable_decorator(cls, "is_multiple", True)


def group(group=""):
    def wrap(cls):
        return _add_variable_decorator(cls, "group", group)
    return wrap


def help(help=""):  # noqa
    def wrap(cls):
        return _add_variable_decorator(cls, "help", help)
    return wrap


def call_before(*target):
    def wrap(cls):
        return _add_variable_decorator(cls, "call_before", target)
    return wrap


def call_after(*target):
    def wrap(cls):
        return _add_variable_decorator(cls, "call_after", target)
    return wrap


################
# PARENT-CLASSES
################

class _MetaTarget(type):
    @property
    def child_targets(self):
        return [inner_cls for inner_cls in self.__dict__.values()
                if inspect.isclass(inner_cls) and issubclass(inner_cls, _Target)]

    @property
    def argument_values(self):
        cmd = self.super_parent_target.cmd
        if cmd not in self._argument_values:
            # the argument values are captured in a dictionary of their absolute parent target
            self._argument_values[cmd] = {}

        return self._argument_values

    @property
    def is_help_needed(self):
        # if contains child targets or arguments
        if self.child_targets or self.argument_classes:
            return True
        else:
            return False

    @property
    def super_parent_target(self):
        parent = self.parent_target
        # recursively get the absolute parent
        while parent and parent.parent_target:
            parent = parent.parent_target

        # return self class if it's already the parent
        if parent:
            return parent
        else:
            return self

    @property
    def parent_target(self):
        try:
            parent_name = self.__qualname__.split('.')[-2]
            parent_module = sys.modules[self.__module__]
            return vars(parent_module)[parent_name]
        except Exception:
            # there is no parent
            return None

    @property
    def has_parent(self):
        if self.parent_target is not None and \
                isinstance(self.parent_target, _Target):
            return True
        else:
            return False

    @property
    def argument_classes(self):
        return [inner_cls for inner_cls in self.__dict__.values()
                if inspect.isclass(inner_cls) and issubclass(inner_cls, _Argument)]


class _Target(argparse.Action, metaclass=_MetaTarget):
    # default values (Do not change these in subclasses! Instead use the decorators above!)
    cmd = ""
    group = ""
    help = ""
    call_before = []
    call_after = []
    _argument_values = {}

    @classmethod
    def execute_target(cls, parser, argument_values, argv_rest):
        # override this method!
        # by default it shows the help message
        _HelpTarget.execute_target(parser, argument_values, argv_rest)

    @classmethod
    def _get_callable_targets(cls, target_list=[]):
        for target in target_list:
            if issubclass(target, _Target):
                yield target

    @classmethod
    def _call_targets(cls, parser, argument_values, argv_rest):
        # call dependency targets
        for target in cls._get_callable_targets(cls.call_before):
            target._call_targets(parser, argument_values, argv_rest)

        # call the target
        cls.execute_target(parser, argument_values, argv_rest or "")

        # call dependent targets
        for target in cls._get_callable_targets(cls.call_after):
            target._call_targets(parser, argument_values, argv_rest)

    def __call__(self, parser, _, argv_rest, *unused):
        # do not execute the target if it has child targets and one of them is used
        choice_made_and_possible = False
        for action in parser._actions:
            if action.choices is not None:
                for choice in action.choices:
                    if str(choice) == sys.argv[-1]:
                        choice_made_and_possible = True
        if choice_made_and_possible:
            return

        # call the target with its dependecies
        self._call_targets(parser, type(self).argument_values[type(self).super_parent_target.cmd], argv_rest)


class _MetaArgument(type):
    @property
    def arg_short(self):
        return self._arg_short

    @arg_short.setter
    def arg_short(self, value):
        if value is None:
            value = self.arg[0]

        self._arg_short = '-' + str.lower(value)

    @property
    def arg_long(self):
        return self._arg_long

    @arg_long.setter
    def arg_long(self, value):
        if value is None:
            value = self.arg

        self._arg_long = "--" + str.lower(value)

    @property
    def parent_target(self):
        parent_name = self.__qualname__.split('.')[0:-1]
        parent_module = sys.modules[self.__module__]
        env = vars(parent_module)
        for path in parent_name:
            if path in env:
                cls = env[path]
                env = vars(cls)
            else:
                break
        return cls

    @property
    def parent_target_value(self):
        return self.parent_target.argument_values[self.parent_target.super_parent_target.cmd][self.arg]

    @parent_target_value.setter
    def parent_target_value(self, value):
        self.parent_target.argument_values[self.parent_target.super_parent_target.cmd][self.arg] = value


class _Argument(argparse.Action, metaclass=_MetaArgument):
    arg = ""
    _arg_short = ""
    _arg_long = ""
    default = None
    is_required = False
    is_boolean = False
    is_multiple = False
    help = ""

    def __init__(self,
                 option_strings,
                 dest,
                 nargs=None,
                 const=None,
                 default=None,
                 type=None,  # noqa
                 choices=None,
                 required=False,
                 help=None,  # noqa
                 metavar=None):
        # set values for _StoreConstAction
        if self.is_boolean:
            if default is None:
                default = False
            nargs = 0
            const = True

        # call the parent constructor
        argparse.Action.__init__(self, option_strings, dest, nargs=nargs, const=const, default=default, type=type,
                                 choices=choices, required=required, help=help, metavar=metavar)

        # set default value in the parent
        self.__class__.parent_target_value = default

    # on call add argument values to parent
    def __call__(self, parser, namespace, values, option_string):
        if self.is_boolean:
            # call _StoreConstAction action
            argparse._StoreTrueAction.__call__(self, parser, namespace, values, option_string)
            values = True

        # if multiple arguments are allowed, add them to a list
        if self.is_multiple:
            current_values = self.__class__.parent_target_value or []
            values = current_values + [values]

        # add the value to the target
        self.__class__.parent_target_value = values


# custom class to enable pseudo groups with subparsers (see https://bugs.python.org/issue9341)
class _SubParsersCustom(argparse._SubParsersAction):
    class _SubParsersGroup(argparse.Action):
        def __init__(self, container, title):
            argparse.Action.__init__(self, option_strings=[], dest=title)
            self.container = container
            self._choices_actions = []

        def add_parser(self, name, **kwargs):
            # add the parser to the main Action, but move the pseudo action
            # in the group's own list
            parser = self.container.add_parser(name, **kwargs)
            choice_action = self.container._choices_actions.pop()
            self._choices_actions.append(choice_action)
            return parser

        def _get_subactions(self):
            return self._choices_actions

        def add_parser_group(self, title):
            # the formatter can handle reursive subgroups
            grp = type(self)(self, title)
            self._choices_actions.append(grp)
            return grp

    def add_parser_group(self, title):
        grp = self._SubParsersGroup(self, title)
        self._choices_actions.append(grp)
        return grp

    # add action parameter to the method
    # if this parameter is specified, it is enough to call this subparser to execute the underlining action
    def add_parser(self, name, action=None, **kwargs):
        parser = argparse._SubParsersAction.add_parser(self, name, **kwargs)
        if action is not None and \
                issubclass(action, argparse.Action):
            parser.add_argument("none", action=action, nargs='?', help=argparse.SUPPRESS)
        return parser


#########
# TARGETS
#########

# class with operations used by multiple targets
class _MetaCommonTargets(type):
    @property
    def is_development(self):
        settings = self._get_dev_stage_setting()
        return settings["development"]


class _CommonTargets(metaclass=_MetaCommonTargets):
    @classmethod
    def remove_dev_stage_setting(cls):
        # remove the settings file if it exeists
        if os.path.isfile(MAKE_SETTINGS_FILE):
            os.remove(MAKE_SETTINGS_FILE)

    @classmethod
    def remove_virtualenv_directory(cls):
        if os.path.isdir(VIRTUALENV_BASE_DIR):
            shutil.rmtree(VIRTUALENV_BASE_DIR, ignore_errors=True)

    @classmethod
    def activate_virtual_environment(cls):
        # check if already a virtual environment is present
        # or running on Travis CI
        if sys.base_prefix != sys.prefix or \
                os.environ.get('TRAVIS') == 'true':
            return

        # check if a virtual environment can be activated
        virt_python = os.path.join(VIRTUALENV_BASE_DIR, "bin", "python")
        if not os.path.isfile(virt_python):
            cls.exit("No virtual environment is present! Please run 'setup-virtualenv'.", 1)

        # use the environment now
        script_file = __file__
        if script_file.endswith('.pyc'):
            script_file = script_file[:-1]
        try:
            result = subprocess.run([virt_python, script_file] + sys.argv[1:]).returncode
        except KeyboardInterrupt:
            # return no error when the process is interrupted by ctrl-c
            raise SystemExit(0)
        # all other cases
        raise SystemExit(result)

    @classmethod
    def get_requirements_file(cls):
        # check which requirements should be installed
        requirements_file = os.path.join(BASE_DIR, "requirements")
        if cls.is_development:
            requirements_file = os.path.join(requirements_file, "development.req")
        else:
            requirements_file = os.path.join(requirements_file, "production.req")

        return requirements_file

    @classmethod
    def check_webdriver(cls):
        global WEBDRIVER
        if os.path.isfile(WEBDRIVER_CONF_FILE):
            import common.settings.webdriver as driver
            WEBDRIVER = driver.WEBDRIVER
        else:
            print("Default webdriver 'chrome' is used.")

    @classmethod
    def initialize_settings(cls):
        # load side side_module
        spec = import_util.spec_from_file_location('manage_settings',
                                                   os.path.join(IGUANA_BASE_DIR, "lib", "manage_settings.py"))
        side_module = import_util.module_from_spec(spec)
        spec.loader.exec_module(side_module)

        # initialize settings
        side_module.initialize_secret_key(cls.is_development)

    @classmethod
    def _get_dev_stage_setting(cls):
        if os.path.isfile(MAKE_SETTINGS_FILE):
            # open the settings file
            with open(MAKE_SETTINGS_FILE, 'r') as f:
                settings = json.load(f)
        else:
            # default settings
            settings = {
                "development": False
            }

        return settings

    @classmethod
    def save_make_settings(cls, development=False):
        # add the settings to a dictionary
        settings = {
            "development": development
        }

        # open the settings file
        with open(MAKE_SETTINGS_FILE, 'w') as f:
            json.dump(settings, f)

    @classmethod
    def link_git_hooks(cls):
        for hook in CUSTOM_GIT_HOOKS:
            # the path for the link destination
            hook_dest = os.path.join(GITHOOK_DIR, os.path.basename(hook))

            if os.path.islink(hook_dest) or \
                    os.path.isfile(hook_dest):
                os.remove(hook_dest)

            # the relative path to the source file
            hook_src = os.path.join(os.path.relpath(BASE_DIR, GITHOOK_DIR), hook)

            # create the system link
            os.symlink(hook_src, hook_dest)

    @classmethod
    def exec_django_cmd(cls, cmd, *args, **kwargs):
        cls.activate_virtual_environment()

        from django.core.management import call_command
        # this is needed for several Django actions
        from common import wsgi  # noqa

        # execute the command
        call_command(cmd, *args, **kwargs)

    @classmethod
    def exit(cls, error_msg=None, error_code=0):
        if error_code != 0 or error_msg is not None:
            print(error_msg, file=sys.stderr)

        sys.exit(error_code)


# override of argparse._HelpAction
@cmd("help")
@help("Show this help message and exit.")
class _HelpTarget(_Target):
    @property
    @classmethod
    def root_parser(cls):
            return cls._root_parser

    @root_parser.setter
    @classmethod
    def root_parser(cls, value):
            cls._root_parser = value

    @classmethod
    def execute_target(cls, parser, *unused):
        # print help if no choice is made at all
        if parser.prog.endswith(sys.argv[-1]) and sys.argv[-1] != "help":
            parser.print_help()
            parser.exit()
        # do nothing if it's not asked for help
        elif not parser.prog.endswith("help"):
            return

        # continue with the 'root' help
        root_parser = _HelpTarget.root_parser
        # print the help
        root_parser.print_help()

        # retrieve subparsers from parser
        subparsers_actions = [action for action in root_parser._actions
                              if isinstance(action, argparse._SubParsersAction)]

        # there will probably only be one subparser_action,
        # but better save than sorry
        for subparsers_action in subparsers_actions:
            # get all subparsers and print help
            for choice, subparser in subparsers_action.choices.items():
                # ignore the help target parser
                if subparser == parser:
                    continue

                print()
                print("==========")
                print(">   Target '{}'".format(choice))
                print(textwrap.indent(subparser.format_help(), '    '))

        root_parser.exit()


@cmd("run")
@group("Django management")
@help("Run the Django server locally.")
class _RunTarget(_Target):
    @classmethod
    def execute_target(cls, unused1, unused2, argv_rest):
        if _CommonTargets.is_development:
            # start the development server
            _CommonTargets.exec_django_cmd("runserver", argv_rest, settings=DJANGO_SETTINGS_MODULE)
        else:
            _CommonTargets.activate_virtual_environment()

            # start celery (worker and beat)
            Popen(["celery", "-A", "common", "worker", "-l", "info", "--pidfile="],
                  stderr=STDOUT, stdout=open(os.path.join(LOG_DIR, "celery-worker.log"), "w+"), bufsize=0,
                  cwd=IGUANA_BASE_DIR)
            Popen(["celery", "-A", "common", "beat", "-l", "info", "--pidfile="],
                  stderr=STDOUT, stdout=open(os.path.join(LOG_DIR, "celery-beat.log"), "w+"), bufsize=0,
                  cwd=IGUANA_BASE_DIR)

            # start gunicorn
            subprocess.run(["gunicorn", "-w", "8", "common.wsgi:application",
                            "--bind", "unix:" + os.path.join(BASE_DIR, "gunicorn.sock"),
                            "--access-logfile", os.path.join(LOG_DIR, "gunicorn-accesslog.log"),
                            "--error-logfile", os.path.join(LOG_DIR, "gunicorn-errorlog.log")], cwd=IGUANA_BASE_DIR)


@cmd("create-app")
@group("Django management")
@help("Create a new Django application.")
class _CreateAppTarget(_Target):
    @arg("APP_NAME")
    @required
    @help("The name of the new application.")
    class AppName(_Argument):
        pass

    @classmethod
    def execute_target(cls, unused1, argument_values, unused2):
        # create the new django application
        # a change to the Django directory is necessary because of a bug in the current 'startapp [destination]'
        #   implementation
        os.chdir(IGUANA_BASE_DIR)
        _CommonTargets.exec_django_cmd("startapp", argument_values["APP_NAME"], settings=DJANGO_SETTINGS_MODULE)
        os.chdir(BASE_DIR)


@cmd("migrations")
@group("Django management")
@help("Manage the Django migrations.")
class _MigrationsTarget(_Target):
    @cmd("create")
    @help("Create the Django migrations.")
    class Create(_Target):
        @classmethod
        def execute_target(cls, *unused):
            _CommonTargets.exec_django_cmd("makemigrations", settings=DJANGO_SETTINGS_MODULE)

    @cmd("apply")
    @help("Apply the Django migrations.")
    class Apply(_Target):
        @classmethod
        def execute_target(cls, *unused):
            _CommonTargets.exec_django_cmd("migrate", settings=DJANGO_SETTINGS_MODULE)


@cmd("test")
@group("Django management")
@help("Execute the Django tests.")
class _TestTarget(_Target):
    @arg("app")
    @multiple
    @help("The Django application name to test.")
    class AppName(_Argument):
        pass

    @arg("func-tests")
    @boolean
    @help("Execute the functional tests.")
    class FuncTests(_Argument):
        pass

    @arg("ign-imp-errs")
    @boolean
    @help("Run the Django tests without error-messages from imported packages.")
    class IgnImpErrs(_Argument):
        pass

    @arg("complete-test")
    @boolean
    @help("Run Django application AND functional tests.")
    class CompleteTest(_Argument):
        pass

    class IgnImpErrsOutWrapper(StringIO):
        def __init__(self, stdout):
            self.__stdout = stdout
            StringIO.__init__(self)

        def write(self, *args, **kwargs):
            # only write output, that does not contain 'virtualenv'
            write = False
            if isinstance(args, tuple):
                for arg in args:
                    if "virtualenv" not in arg:
                        write = True
            elif "virtualenv" not in args:
                write = True

            if write:
                self.__stdout.write(*args, **kwargs)
                StringIO.write(self, *args, **kwargs)

        def read(self, *args, **kwargs):
            self.seek(0)
            self.__stdout.write(StringIO.read(self, *args, **kwargs))

    @classmethod
    def execute_target(cls, unused1, argument_values, unused2):
        if _CommonTargets.is_development:
            nomigrations = True
        else:
            nomigrations = False

        # check if functional tests should be run
        if argument_values["func-tests"]:
            if argument_values["app"]:
                # execute only the functional tests of the specified app
                argument_values["app"] = ["functional_tests.%s" % app for app in argument_values["app"]]
            else:
                # execute all functional tests
                argument_values["app"] = ["functional_tests"]

        # override stdout and stderr
        if argument_values["ign-imp-errs"]:
            sys_stdout = sys.stdout
            sys_stderr = sys.stderr
            sys.stdout = _TestTarget.IgnImpErrsOutWrapper(sys.stdout)
            sys.stderr = _TestTarget.IgnImpErrsOutWrapper(sys.stderr)

        # execute the tests
        if argument_values["complete-test"]:
            # execute all tests (including the functional ones)
            _CommonTargets.exec_django_cmd("test", IGUANA_BASE_DIR, interactive=False, nomigrations=nomigrations,
                                           settings=DJANGO_SETTINGS_MODULE)
        else:
            if not argument_values["app"]:
                # execute all tests except the functional ones
                _CommonTargets.exec_django_cmd("test", IGUANA_BASE_DIR, exclude_tags=["functional"], interactive=False,
                                               nomigrations=nomigrations, settings=DJANGO_SETTINGS_MODULE)
            else:
                # execute only the specific application tests
                _CommonTargets.exec_django_cmd("test", *argument_values["app"], interactive=False,
                                               nomigrations=nomigrations, settings=DJANGO_SETTINGS_MODULE)

        # restore stdout and stderr
        if argument_values["ign-imp-errs"]:
            sys.stdout = sys_stdout
            sys.stderr = sys_stderr


@cmd("messages")
@group("Django management")
@help("Manage the Django messages.")
class _MessagesTarget(_Target):
    @cmd("create")
    @help("Create the Django messages.")
    class Create(_Target):
        @arg("lang-code")
        @default("en")
        @help("The language code for the messages.")
        class LangCode(_Argument):
            pass

        @classmethod
        def execute_target(cls, unused1, argument_values, unused2):
            _CommonTargets.exec_django_cmd("makemessages", "-l", argument_values["lang-code"],
                                           settings=DJANGO_SETTINGS_MODULE)

    @cmd("compile")
    @help("Compile the Django messages.")
    class Compile(_Target):
        @classmethod
        def execute_target(cls, *unused):
            _CommonTargets.exec_django_cmd("compilemessages", settings=DJANGO_SETTINGS_MODULE)


@cmd("collectstatic")
@group("Django management")
@help("Collect static files and copy them into /static_files.")
class _CollectionTarget(_Target):
    @classmethod
    def execute_target(cls, *unused):
        # create the output directory if it doesn't exist
        os.makedirs(STATIC_FILES, exist_ok=True)

        # collect the static files
        _CommonTargets.exec_django_cmd("collectstatic", "--noinput", settings=DJANGO_SETTINGS_MODULE)


@cmd("requirements")
@group("Source code management")
@help("Manage the requirements for this project.")
class _RequirementsTarget(_Target):
    @cmd("check")
    @help("Check if there are any updates of the requirements.")
    class Check(_Target):
        @classmethod
        def execute_target(cls, *unused):
            _CommonTargets.activate_virtual_environment()

            # import piprot
            from piprot import piprot
            # get the requirements file
            requirements_file = _CommonTargets.get_requirements_file()
            # execute piprot
            piprot.main([open(requirements_file, 'r')], verbose=True, outdated=True)

    @cmd("install")
    @help("(Re-)Install the requirements.")
    class Install(_Target):
        @classmethod
        def execute_target(cls, *unused):
            # check which requirements should be installed
            requirements_file = _CommonTargets.get_requirements_file()

            # install the requirements
            _CommonTargets.activate_virtual_environment()
            # fix for pip versions below 10.0
            try:
                from pip._internal import main as pipmain
            except ImportError:
                from pip import main as pipmain
            code = pipmain(["install", "-r", requirements_file])

            # check for possible errors
            if code != 0:
                _CommonTargets.exit("Failed while installing the requirements! Please check the errors above.", code)

            # reload the installed packages
            importlib.reload(site)


@cmd("setup-virtualenv")
@group("Source code management")
@call_after(_RequirementsTarget.Install)
@help("Create the virtual environment for Django.")
class _SetupVirtualenvTarget(_Target):
    @classmethod
    def execute_target(cls, *unused):
        # check if already a virtual environment is present
        virt_python = os.path.join(VIRTUALENV_BASE_DIR, "bin", "python")
        if not os.path.isfile(virt_python):
            # create a new environment
            venv.create(VIRTUALENV_BASE_DIR, with_pip=True)


@cmd("set-webdriver")
@group("Source code management")
@help("Set the browser that should be used for the functional tests.")
class _SetWebdriverTarget(_Target):
    @cmd("chrome")
    @help("Use Chrome browser for the webdriver.")
    class Chrome(_Target):
        @classmethod
        def execute_target(cls, *unused):
            cls.parent_target.use_browser(cls.cmd)

    @cmd("firefox")
    @help("Use Firefox browser for the webdriver.")
    class Firefox(_Target):
        @classmethod
        def execute_target(cls, *unused):
            cls.parent_target.use_browser(cls.cmd)

    @cmd("safari")
    @help("Use Safari browser for the webdriver.")
    class Safari(_Target):
        @classmethod
        def execute_target(cls, *unused):
            cls.parent_target.use_browser(cls.cmd)

    @classmethod
    def __install_chromedriver(cls):
        system = platform.system()
        if system == "Linux":
            # only 64bit driver is available for chromedriver
            system += "64"

        # the link destination
        dest_file = os.path.join(VIRTUALENV_BASE_DIR, "bin", "chromedriver")

        # the binary driver file (relative to the destination path above)
        driver_file = os.path.join(VIRTUALENV_BASE_DIR,
                                   "src", "chromedriver", "chromedriver", "bin", "chromedriver-" + system)
        driver_file = os.path.relpath(driver_file, os.path.dirname(dest_file))

        # link the driver binary
        if os.path.islink(dest_file) or \
                os.path.isfile(dest_file):
            os.remove(dest_file)
        os.symlink(driver_file, dest_file)

    @classmethod
    def __install_geckodriver(cls):
        response = urlopen("https://api.github.com/repos/mozilla/geckodriver/releases/latest")
        if response.code != 200:
            _CommonTargets.exit("No connection to the GitHub API is possible! Please try again later.", 1)
        # the API returns json
        github_json = json.loads(response.read().decode(response.info().get_param("charset") or "utf-8"))

        # get the underlining system and architecture
        system = platform.system()
        if system == "Linux":
            system = "linux" + platform.architecture()[0].replace("bit", '')
        elif system == "Darwin":
            # the driver for OSX is 32 and 64bit
            system = "macos"
        elif system == "Windows":
            system = "win" + platform.architecture()[0].replace("bit", '')

        # find the right driver for the system
        for asset in github_json["assets"]:
            if system in asset["browser_download_url"]:
                # download the driver
                print("Downloading geckodriver...")
                driver_archive = os.path.join(VIRTUALENV_BASE_DIR, "geckodriver.tar.gz")
                response = urlopen(asset["browser_download_url"])
                with open(driver_archive, 'wb') as out:
                    out.write(response.read())

                # extract the driver
                print("Extracting geckodriver...")
                driver_file = "geckodriver"
                with tarfile.open(driver_archive, "r:gz") as archive_file:
                    archive_file.extract(driver_file, path=VIRTUALENV_BASE_DIR)

                # remove the archive
                os.remove(driver_archive)
                # move the driver to the virtualenv bin directory
                print("Installing geckodriver...")
                shutil.move(os.path.join(VIRTUALENV_BASE_DIR, driver_file),
                            os.path.join(VIRTUALENV_BASE_DIR, "bin", driver_file))

                # nothing more to do
                print("...done")
                break

    @classmethod
    def use_browser(cls, browser):
        if browser not in ("chrome", "firefox", "safari"):
            _CommonTargets.exit("No valid webdriver specified! Choose between 'chrome', 'firefox' and 'safari'.", 1)

        # write the config file
        config_text = ("# This file is automatically generated by the Makefile.\n"
                       "# Do not manually edit it!\n"
                       "\n"
                       "WEBDRIVER = \"{webdriver}\"\n".format(webdriver=browser))
        with open(WEBDRIVER_CONF_FILE, 'w') as f:
            f.write(config_text)

        # install the selenium driver for chrome and firefox
        if browser == "chrome":
            cls.__install_chromedriver()
        elif browser == "firefox":
            cls.__install_geckodriver()

    @classmethod
    def execute_target(cls, parser, argument_values, argv_rest):
        if "webdriver" in argument_values:
            cls.use_browser(argument_values["webdriver"])
        else:
            _HelpTarget.execute_target(parser, argument_values, argv_rest)


@cmd("coverage")
@group("Source code management")
@help("Execute coverage on the source code.")
class _CoverageTarget(_Target):
    @arg("app")
    @multiple
    @help("The Django application name to test.")
    class AppName(_Argument):
        pass

    @arg("func-tests")
    @boolean
    @help("Execute the functional tests.")
    class FuncTests(_Argument):
        pass

    @arg("complete-test")
    @boolean
    @help("Run Django application AND functional tests.")
    class CompleteTest(_Argument):
        pass

    @cmd("report")
    @help("Create the coverage report.")
    class Report(_Target):
        @classmethod
        def execute_target(cls, *unused):
            cov = cls.parent_target.load_coverage()
            cov.report()

    @cmd("html")
    @help("Create the coverage report as HTML.")
    class Html(_Target):
        @classmethod
        def execute_target(cls, *unused):
            cov = cls.parent_target.load_coverage()
            cov.html_report()

    @cmd("xml")
    @help("Create the coverage report as XML.")
    class Xml(_Target):
        @classmethod
        def execute_target(cls, *unused):
            cov = cls.parent_target.load_coverage()
            cov.xml_report()

    @cmd("erase")
    @help("Erase a previously created coverage report.")
    class Erase(_Target):
        @classmethod
        def execute_target(cls, *unused):
            cov = cls.parent_target.load_coverage()
            cov.erase()

    @classmethod
    def __initialize_coverage(cls):
        _CommonTargets.activate_virtual_environment()
        from coverage import Coverage
        return Coverage(data_file=COVERAGE_DATA_FILE, config_file=COVERAGE_SETTINGS_FILE,
                        source=[IGUANA_BASE_DIR])

    @classmethod
    def load_coverage(cls):
        # get coverage
        cov = cls.__initialize_coverage()

        # check if coverage was executed before
        if not os.path.isfile(COVERAGE_DATA_FILE):
            _CommonTargets.exit("No coverage file found! Please perform a coverage run first.", 1)

        # load the coverage file
        cov.load()

        return cov

    @classmethod
    def execute_target(cls, parser, argument_values, argv_rest):
        cov = cls.__initialize_coverage()
        # start the coverage process
        cov._auto_save = True
        cov.start()
        # perform the tests
        argument_values["ign-imp-errs"] = False
        _TestTarget.execute_target(parser, argument_values, argv_rest)


@cmd("css")
@group("Source code management")
@call_after(_CollectionTarget)
@help("Compile the CSS-files with SASSC.")
class _CSSTarget(_Target):
    @classmethod
    def execute_target(cls, *unused):
        _CommonTargets.activate_virtual_environment()
        import sass

        out_css_dir = os.path.join(IGUANA_STATIC_FILES_DIR, "css")

        # use python libsass for compiling the sccs files
        # output them to the iguana static files css directory
        sass.compile(dirname=(IGUANA_SCSS_DIR, out_css_dir),
                     output_style="compressed")


@cmd("list")
@group("Source code management")
@help("List bugs and missing testcases defined in the code.")
class _ListTarget(_Target):
    @cmd("bugs")
    @help("List bugs.")
    class _ListBugsTarget(_Target):
        @classmethod
        def execute_target(cls, *unused):
            subprocess.run('grep --color -n -i -R -H "TODO BUG" --exclude=' + os.path.basename(__file__) + ' *',
                           shell=True, cwd=IGUANA_BASE_DIR)

    @cmd("missing-testcases")
    @help("List missing testcases.")
    class _ListMissingTestcasesTarget(_Target):
        @classmethod
        def execute_target(cls, *unused):
            subprocess.run('grep --color -n -i -R -H "TODO TESTCASE" --exclude=' + os.path.basename(__file__) + ' *',
                           shell=True, cwd=IGUANA_BASE_DIR)


@cmd("add-license")
@group("Source code management")
@help("Add the license header to the source files.")
class _AddLicenseTarget(_Target):
    @classmethod
    def execute_target(cls, *unused):
        subprocess.run([os.path.join(IGUANA_BASE_DIR, "add_header.sh")], cwd=IGUANA_BASE_DIR)


@cmd("new-release")
@group("Source code management")
@help("Tag the current commit as a production release.")
class _NewReleaseTarget(_Target):
    @classmethod
    def execute_target(cls, *unused):
        _CommonTargets.activate_virtual_environment()

        # get the git repository
        from git import Repo
        repo = Repo(BASE_DIR)
        # get only tags that start with 'production-' and sort them descending by their number
        tags = sorted(repo.tags, key=lambda t: t.name.startswith("production-") and int(t.name.split('-')[1]),
                      reverse=True)

        # get the latest tag number
        if not tags:
            latest_tag_number = 0
        else:
            latest_tag_number = int(tags[0].name.split('-')[1])

        # add a new release tag on the master branch
        repo.create_tag("production-" + str(latest_tag_number + 1), ref="master")


@cmd("production")
@group("Main")
@call_after(_SetupVirtualenvTarget, _CSSTarget, _MigrationsTarget.Create, _MigrationsTarget.Apply, _CollectionTarget)
@help("Configure everything to be ready for production.")
class _ProductionTarget(_Target):
    @classmethod
    def execute_target(cls, unused1, argument_values, unused2):
        # write the production settings
        _CommonTargets.save_make_settings(development=argument_values.get("development", False))

        # initialize the rest of the settings
        _CommonTargets.initialize_settings()


@cmd("staging")
@group("Main")
@call_after(_ProductionTarget)
@help("Configure everything to be ready for staging.")
class _StagingTarget(_Target):
    # this target is basically the same as production
    @classmethod
    def execute_target(cls, parser, argument_values, argv_rest):
        # this method must be overridden, because otherwise it prints the help message when it's called
        pass


@cmd("development")
@group("Main")
@call_after(_ProductionTarget, _SetWebdriverTarget)
@help("Configure everything to be ready for development.")
class _DevelopmentTarget(_Target):
    @arg("webdriver")
    @default("chrome")
    @help("Specify the webdriver for the development process.")
    class Webdriver(_Argument):
        pass

    @classmethod
    def execute_target(cls, unused1, argument_values, unused2):
        argument_values["development"] = True

        # link the git hooks
        _CommonTargets.link_git_hooks()


def __get_parser_group(parser, target):
    if isinstance(parser, _SubParsersCustom) and \
            target.group and \
            not target.has_parent:
        # check if group already exists
        if target.group in MAIN_TARGET_GROUPS.keys():
            return MAIN_TARGET_GROUPS[target.group]

        # add the new group to the parser
        group = parser.add_parser_group(target.group + ':')
        MAIN_TARGET_GROUPS[target.group] = group
        return group
    else:
        # if no group is specified with the target, just return the parser
        return parser


def __add_target_to_parser(subparser, target):
    group = __get_parser_group(subparser, target)
    target_parser = group.add_parser(target.cmd, action=target, help=target.help, add_help=target.is_help_needed)

    for argument_cls in target.argument_classes:
        if argument_cls.is_required:
            target_parser.add_argument(argument_cls.arg, action=argument_cls, help=argument_cls.help)
        else:
            target_parser.add_argument(argument_cls.arg_short, argument_cls.arg_long, action=argument_cls,
                                       default=argument_cls.default, help=argument_cls.help)

    # add the child targets
    if target.child_targets:
        target_sp = target_parser.add_subparsers(action=_SubParsersCustom)
    for child_target in target.child_targets:
        __add_target_to_parser(target_sp, child_target)

    if target.child_targets or target.argument_classes:
        def sort_actions(elem):
            if isinstance(elem, target):
                return 1
            elif isinstance(elem, _SubParsersCustom):
                return 2
            else:
                return 0

        # sort the actions so the argument and child actions get called before the target actions
        target_parser._actions.sort(key=sort_actions)


# custom git hooks (relative to the BASE_DIR path)
CUSTOM_GIT_HOOKS = [os.path.relpath(os.path.join(CUSTOM_GIT_HOOK_DIR, hook), BASE_DIR)
                    for hook in os.listdir(CUSTOM_GIT_HOOK_DIR)
                    if os.path.isfile(os.path.join(CUSTOM_GIT_HOOK_DIR, hook))]

# list of groups for the main targets
MAIN_TARGET_GROUPS = {}


# run this script
if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Makefile for the Iguana project", add_help=False)
    subparser = parser.add_subparsers(action=_SubParsersCustom)

    # special case help target
    __add_target_to_parser(subparser, _HelpTarget)
    _HelpTarget.root_parser = parser

    # get all target classes
    for _, cls in inspect.getmembers(sys.modules[__name__]):
        if inspect.isclass(cls) and \
                issubclass(cls, _Target) and \
                cls not in [_Target, _HelpTarget]:
            __add_target_to_parser(subparser, cls)

    parser.parse_args(args=None if sys.argv[1:] else ["help"])