import os
import re
import sh
import shutil
import sys

from argparse import ArgumentParser
from configparser import RawConfigParser

import krux.cli

from . import __version__

PYVER = '/usr/bin/python3'

DEFAULT_PACKAGE_FORMAT = 'deb'
# Before 2019, we standardized on requirements.pip; after that we switched to requirements.txt.
DEFAULT_REQUIREMENTS_FILES = ('requirements.txt',)


def is_exe(fpath_):  # type: (str) -> bool
    return os.path.isfile(fpath_) and os.access(fpath_, os.X_OK)


class VEPackagerError(Exception):
    pass


class Application(krux.cli.Application):

    setup_option_names = {
        'name': 'package_name',
        'url': 'repo_url',
        'version': 'package_version',
    }

    def __init__(self, name, **kwargs):  # type: (str, dict[str]) -> Application
        # Call to the superclass to bootstrap.
        super(Application, self).__init__(name=name, **kwargs)

        self.build_dir = os.path.join(self.args.directory, ".build")
        self.dependencies = self.args.dependency
        self.pip_cache = self.args.pip_cache
        self.package_dir = self.args.package_name
        self.target = os.path.join(self.build_dir, 'virtualenv')
        self._find_vetools()
        self.python = self.args.python
        self.extra_paths = self.args.extra_path

        self._power_on_self_test()
        self.setup_options = {
            'name': None,
            'url': None,
            'version': None,
        }

    # pass-through function to enable line-wise output from commands called via sh.
    # you would think you could just pass "print" as the callback function, but that
    # does not produce meaningful output. So we have this wrapper.
    def print_line(self, line):  # type: (str) -> None
        self.logger.info(line.rstrip())

    def _find_vetools(self):  # type: () -> None
        self.vetools = "%s/virtualenv-tools" % os.path.dirname(sys.executable)

    def _power_on_self_test(self):  # type: () -> None
        """
        double-check that required parts are in place.
        :return: bool
        """
        # path to python has to be a link/real file, not a symlink; if it is a
        # symlink, follow it and use that.
        python_real_path = os.path.realpath(self.python)
        if python_real_path == self.python:
            pass
        else:
            self.logger.info("you asked to use {0}, which is a link to {1}, so we are using that.".format(
                    self.python, python_real_path
                )
            )
            self.python = python_real_path

    def get_setup_option(self, option):  # type: (str) -> str
        pycmd = sh.Command(os.path.join(self.target, 'bin', 'python'))

        if self.setup_options[option] is not None:
            option = self.setup_options[option]
        elif getattr(self.args, self.setup_option_names[option]) is not None:
            option = getattr(self.args, self.setup_option_names[option], '')
        else:
            option = pycmd('setup.py', "--%s" % option).strip()

        self.setup_options[option] = option
        return option

    def add_cli_arguments(self, parser):  # type: (ArgumentParser) -> None
        group = krux.cli.get_group(parser, self.name)

        group.add_argument(
            '--package-prefix',
            default='/usr/local',
            help="Path to prefix the entire package with"
        )

        group.add_argument(
            '--repo-url',
            default=None,
            help="Repo URL to pass through to fpm"
        )

        group.add_argument(
            '--package-format',
            default=DEFAULT_PACKAGE_FORMAT,
            help="The package format, if not deb"
        )

        group.add_argument(
            '--package-name',
            default=None,
            help="The package name, as seen in apt"
        )

        group.add_argument(
            '--package-version',
            default=None,
            help="The package version."
        )

        group.add_argument(
            '--python',
            default=PYVER,
            help="The path to python to use. Must be a real file, not a symlink."
        )

        group.add_argument(
            '--skip-scripts',
            action='store_true',
            help="Skip installing all the scripts in all the setup.py files in all the requirements"
        )

        group.add_argument(
            '--shim-script',
            default=None,
            help="An extra script to run between the build and package steps. "
                 "If you need to do unnatural things to make your package work, this is the place to do them. "
                 "The script will be called via the sh module and therefore needs a shebang line."
        )

        group.add_argument(
            '--build-number',
            default=None,
            help="A build number, ie from your CI, if you want it to be appended the version number."
        )

        group.add_argument(
            '--pip-requirements',
            default=None,
        )

        group.add_argument(
            '--pip-version',
            default='latest',
            help='Version of pip to install in the virtualenv where your application is built.',
        )

        group.add_argument(
            '--pipenv-version',
            default='latest',
            help='Version of pipenv to install in the virtualenv where your application is built.'
        )

        group.add_argument(
            '--build-version',
            default='latest',
            help='Version of "build" to install in the virtualenv where your application is built.',
        )

        group.add_argument(
            '--setuptools-version',
            default='latest',
            help='Version of setuptools to install in the virtualenv where your application is built.',
        )

        group.add_argument(
            '--wheel-version',
            default='latest',
            help='Version of wheel to install in the virtualenv where your application is built.',
        )

        group.add_argument(
            '--directory',
            default=os.getcwd(),
            help="Path to look in for the code you want to virtualenv-packageify. default to current directory."
        )

        group.add_argument(
            '--dependency',
            default=[],
            action='append',
            help="a package on which your package should depend. Passed through to fpm as -d. Pass multiple "
                 "times for additional dependencies."
        )

        group.add_argument(
            '--extra-path',
            default=[],
            action='append',
            help="Additional paths *in your project* that you want added in to the package."
        )

        group.add_argument(
            '--pip-cache',
            default=os.environ.get('PIP_CACHE', None),
            help="directory to use as the pip cache; passed to pip as --cache-dir, which may not be available on "
                 "older versions of pip."
        )

        group.add_argument(
            '--pipenv',
            action='store_true',
            help="use pipenv to install dependencies.",
        )

        group.add_argument(
            '--version',
            action='store_true',
            help="print ve-packager version and exit.",
        )

    def update_paths(self):  # type: () -> None
        vetools = sh.Command(self.vetools)
        package_name = self.get_setup_option('name')
        # this path is where the package will be installed on a target host
        new_path = os.path.join(self.args.package_prefix, package_name)

        # this path is the updated target in the build environment
        new_target = os.path.join(os.path.dirname(self.target), package_name)
        # rename target from 'virtualenv' to the package name; update self.target
        shutil.move(self.target, new_target)
        self.target = new_target
        self.logger.info("updating paths in %s to %s", self.target, new_path)
        vetools('--update-path', new_path, _cwd=self.target)

    def clean_target(self):  # type: () -> None
        find = sh.Command("find")
        # delete *.pyc and *.pyo files
        self.logger.info("removing .pyc and .pyo files in %s" % self.target)
        find(self.target, '-iname', '*.pyo', '-o', '-iname', '*.pyc' '-delete')

    def symlink_entry_points(self):  # type: () -> None
        self.logger.info("sym-linking entry points")
        # make a directory at .build/bin, which will show up in self.package_prefix/bin, ie defaults to /usr/local/bin
        mkdir = sh.Command('mkdir')
        mkdir('-p', "%s/bin" % self.build_dir)
        rcp = RawConfigParser()
        # someone could be foolish enough to use a hyphen in their package name, needs to be a _.
        egg = "%s.egg-info" % re.sub('-', '_', self.get_setup_option('name'))

        entry_points = os.path.join(egg, 'entry_points.txt')
        if not os.path.exists(egg) or not os.path.exists(entry_points):
            self.logger.info("no entry points, so no symlinks to create")
            return
        rcp.read(entry_points)
        if 'console_scripts' not in rcp.sections():
            return
        os.chdir("%s/bin" % self.build_dir)
        for item in rcp.items('console_scripts'):
            self.logger.info('linking %s', item[0])
            src = os.path.join('..', self.get_setup_option('name'), 'bin', item[0])
            dest = item[0]
            self.logger.info('sym-linking ' + src + ' to ' + dest)
            if os.path.exists(dest):
                os.remove(dest)
            os.symlink(src, dest)
        os.chdir(self.args.directory)

    def package(self):  # type: () -> None
        os.chdir(self.args.directory)
        fpm = sh.Command("fpm")
        # if present, append the build number to the version number
        version_string = self.get_setup_option('version')
        if self.args.build_number:
            version_string = "{0}~{1}".format(self.args.package_version, self.args.build_number)
        # add a --depends for each package dependency
        # '.' is the directory to start out in, before the -C directory and is where the package file is created
        fpm_args = [
            '--deb-no-default-config-files',  # suppress a warning about files in /etc, which we won't have
            '--verbose',
            '--input-type', 'dir',
            '--output-type', self.args.package_format,
            '--name', self.get_setup_option('name'),
            '--prefix', self.args.package_prefix,
            '--version', version_string,
            '--url', self.get_setup_option('url'),
            '--chdir', os.path.join(self.args.directory, self.build_dir),
        ]
        for dependency in self.dependencies:
            fpm_args += ['--depends', dependency]
        fpm_args += ['.']
        self.logger.info('calling fpm: %s', fpm_args)
        fpm(_out=self.print_line, *fpm_args)

    def install_build_tools(self, pip_path):  # type: (str) -> None
        """
        :param pip_path: a sh Command pointing to the target virtualenv's pip
        :return:
        """
        # bionic's pip3 has a bug related to using multiple pip repos,
        # so we restrict it to just one in order to upgrade pip.
        pip = sh.Command(pip_path)
        env = os.environ.copy()
        env['PIP_EXTRA_INDEX_URL'] = ''
        pip('--no-color --no-input install --upgrade pip'.split(' '),
            _env=env, _out=self.print_line)

        # we need to use the upgraded pip now
        pip = sh.Command(pip_path)
        tools = []
        for tool in ['build', 'setuptools', 'wheel']:
            version = getattr(self.args, '{}_version'.format(tool))
            if version == 'latest':
                tools.append(tool)
            else:
                tools.append(f'{tool}=={version}')
        tools = ' '.join(tools)
        pip(f'--no-color --no-input install --upgrade {tools}'.split(' '),
            _out=self.print_line)

    def _pip_requirements_filename(self, path='.'):  # type: (str) -> str
        """
        Returns filename of pip requirements file, & verifies that the file exists.

        :param path: Path to dir to search. Default it current dir.
        :type path: str
        :raises VEPackagerError
        :return: Filename of pip requirements
        :rtype: str
        """
        if self.args.pip_requirements:
            filenames = list(self.args.pip_requirements)
        else:
            filenames = DEFAULT_REQUIREMENTS_FILES

        path_filename = None
        found = False
        for filename in filenames:
            path_filename = os.path.join(path, filename)
            if os.path.isfile(path_filename):
                found = True
                break

        if not found:
            raise VEPackagerError(
                'could not find any of these pip requirements files: {}'.format(
                    ', '.join(filenames)))
        return path_filename

    def install_requirements_pip(self, pip_path):  # type: (str) -> None
        self.logger.info("installing requirements using pip")
        # if there is a requirements.txt, go ahead and install all the things
        pip_requirements_filename = self._pip_requirements_filename()

        pip = sh.Command(pip_path)
        # installing requirements can take a spell, print output line-wise
        pip_args = ['install', '-r', pip_requirements_filename, '-I', ]
        if self.pip_cache is not None:
            pip_args += ['--cache-dir', self.pip_cache]
        pip(_out=self.print_line, *pip_args)

    def install_requirements_pipenv(self, pipenv_path, python_path):  # type: (str, str) -> None
        env = os.environ.copy()
        # ensure that pipenv doesn't use these env. vars.
        env['PIP_INDEX_URL'] = ''
        env['PIP_EXTRA_INDEX_URL'] = ''
        # because pipenv is installed in a different virtualenv from the target,
        # we need to tell it which virtualenv to act upon.
        # As of v2020.11.15 this feature is undocumented.
        # https://github.com/pypa/pipenv/blob/v2020.11.15/pipenv/project.py#L461
        target_virtualenv_path = os.path.split(os.path.dirname(python_path))[0]
        env['VIRTUAL_ENV'] = target_virtualenv_path

        pipenv = sh.Command(pipenv_path)
        self.logger.info("installing requirements using pipenv")
        pipenv('sync', _env=env, _out=self.print_line)

    @staticmethod
    def which(program):  # type: (str) -> str
        fpath, fname = os.path.split(program)
        if fpath:
            if is_exe(program):
                return program
        else:
            for path in os.environ["PATH"].split(os.pathsep):
                exe_file = os.path.join(path, program)
                if is_exe(exe_file):
                    return exe_file
        raise FileNotFoundError(f'Could not find executable: {program}')

    def create_virtualenv(self):  # type: () -> None
        rm = sh.Command('rm')
        self.logger.info("deleting previous virtual environment")
        rm('-f', '-r', self.target)

        self.logger.info("creating new virtual environment")
        # the virtualenv tool for the python environment we are currently running in.
        base_virtualenv_path = f'{os.path.dirname(self.python)}/virtualenv'
        if os.path.exists(base_virtualenv_path):
            virtualenv_path = base_virtualenv_path
        else:
            # use the virtualenv tool that is in $PATH.
            # if there are multiple, use the 1st one found.
            virtualenv_path = self.which('virtualenv')
        self.logger.info(f'using {virtualenv_path}')
        virtualenv = sh.Command(virtualenv_path)

        # bionic's python3 gets confused about multiple repos,
        # so we suppress PIP_EXTRA_INDEX_URL.
        env = os.environ.copy()
        env['PIP_EXTRA_INDEX_URL'] = ''
        virtualenv('-p', self.python, self.target, _env=env, _out=self.print_line)

        # the sh module does not provide a way to create a shell with a virtualenv
        # activated, the next best thing is to set up a shortcut for pip and python
        # in the target virtualenv
        target_pip_path = os.path.join(self.target, 'bin', 'pip')
        target_pipenv_path = os.path.join(os.path.dirname(virtualenv_path), 'pipenv')
        target_python_path = os.path.join(self.target, 'bin', 'python')

        self.install_build_tools(target_pip_path)

        self.logger.info("installing requirements")
        if self.args.pipenv:
            self.install_requirements_pipenv(target_pipenv_path, target_python_path)
        else:
            self.install_requirements_pip(target_pip_path)
        self.logger.info("running setup.py for %s" % self.args.package_name)
        sh.Command(target_python_path)('setup.py', 'install', _out=self.print_line)

    def run(self):  # type: () -> None
        if self.args.version:
            print(f've-packager version {__version__}')
            exit()

        os.chdir(self.args.directory)
        if not os.path.isfile("setup.py"):
            raise VEPackagerError("no setup.py in %s; can't proceed; try --help" % self.args.directory)
        # destroy & create a virtualenv for the build; we can't do much before the virtualenv is
        # created, as we need to have a python environment that can run setup.py, which sometimes requires
        # loading __init__.py, which might load other non-stdlib modules.
        os.environ['PIP_NO_COLOR'] = os.environ['PIP_NO_INPUT'] = os.environ['PIP_QUIET'] = '1'
        self.create_virtualenv()
        for name in self.setup_option_names:
            self.get_setup_option(name)
        self.logger.info(self.setup_options)

        self.update_paths()
        self.clean_target()
        if not self.args.skip_scripts:
            self.symlink_entry_points()
        if self.args.shim_script is not None:
            # copy the existing environment variables
            env_vars = os.environ.copy()
            # set some environment variables the script might need
            env_vars['PACKAGE_PREFIX'] = self.args.package_prefix
            env_vars['PACKAGE_NAME'] = self.get_setup_option('name')
            env_vars['PACKAGE_DIR'] = self.package_dir
            env_vars['TARGET'] = self.target
            env_vars['BUILD_DIR'] = self.build_dir
            self.logger.info("running shim script: %s" % self.args.shim_script)
            shim = sh.Command("%s" % self.args.shim_script)
            shim(_env=env_vars, _out=self.print_line)
        os.chdir(self.args.directory)
        if self.extra_paths:
            for path in self.extra_paths:
                dst = os.path.join(self.build_dir, self.get_setup_option('name'), os.path.basename(path))
                self.logger.info("copying %s to %s" % (path, dst))
                shutil.copytree(path, dst)
        self.package()


def main():  # type: () -> None
    app = Application(name='ve-packager')
    app.run()


if __name__ == '__main__':
    main()
