# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
# Copyright (C) 2016-2017 Canonical Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 3 as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

"""The python plugin can be used for python 2 or 3 based parts.

It can be used for python projects where you would want to do:

    - import python modules with a requirements.txt
    - build a python project that has a setup.py
    - install packages straight from pip

This plugin uses the common plugin keywords as well as those for "sources".
For more information check the 'plugins' topic for the former and the
'sources' topic for the latter.

Additionally, this plugin uses the following plugin-specific keywords:

    - requirements:
      (string)
      Path to a requirements.txt file
    - constraints:
      (string)
      Path to a constraints file
    - process-dependency-links:
      (bool; default: false)
      Enable the processing of dependency links in pip, which allow one
      project to provide places to look for another project
    - python-packages:
      (list)
      A list of dependencies to get from PyPI
    - python-version:
      (string; default: python3)
      The python version to use. Valid options are: python2 and python3

If the plugin finds a python interpreter with a basename that matches
`python-version` in the <stage> directory on the following fixed path:
`<stage-dir>/usr/bin/<python-interpreter>` then this interpreter would
be preferred instead and no interpreter would be brought in through
`stage-packages` mechanisms.
"""

import collections
import contextlib
import os
import re
from shutil import which
import subprocess
from textwrap import dedent

import requests

import snapcraft
from snapcraft.common import isurl
from snapcraft.internal import mangling
from snapcraft.plugins import _python


class UnsupportedPythonVersionError(snapcraft.internal.errors.SnapcraftError):

    fmt = 'Unsupported python version: {python_version!r}'


class PythonPlugin(snapcraft.BasePlugin):

    @classmethod
    def schema(cls):
        schema = super().schema()
        schema['properties']['requirements'] = {
            'type': 'string',
        }
        schema['properties']['constraints'] = {
            'type': 'string',
        }
        schema['properties']['python-packages'] = {
            'type': 'array',
            'minitems': 1,
            'uniqueItems': True,
            'items': {
                'type': 'string'
            },
            'default': [],
        }
        schema['properties']['process-dependency-links'] = {
            'type': 'boolean',
            'default': False,
        }
        schema['properties']['python-version'] = {
            'type': 'string',
            'default': 'python3',
            'enum': ['python2', 'python3']
        }
        schema.pop('required')

        return schema

    @classmethod
    def get_pull_properties(cls):
        # Inform Snapcraft of the properties associated with pulling. If these
        # change in the YAML Snapcraft will consider the pull step dirty.
        return [
            'requirements',
            'constraints',
            'python-packages',
            'python-version',
        ]

    @property
    def plugin_build_packages(self):
        if self.options.python_version == 'python3':
            return [
                'python3-dev',
                'python3-pip',
                'python3-pkg-resources',
                'python3-setuptools',
            ]
        elif self.options.python_version == 'python2':
            return [
                'python-dev',
                'python-pip',
                'python-pkg-resources',
                'python-setuptools',
            ]

    @property
    def plugin_stage_packages(self):
        if self.options.python_version == 'python3':
            return ['python3']
        elif self.options.python_version == 'python2':
            return ['python']

    # ignore mypy error: Read-only property cannot override read-write property
    @property  # type: ignore
    def stage_packages(self):
        try:
            _python.get_python_command(
                self._python_major_version, stage_dir=self.project.stage_dir,
                install_dir=self.installdir)
        except _python.errors.MissingPythonCommandError:
            return super().stage_packages + self.plugin_stage_packages
        else:
            return super().stage_packages

    @property
    def _pip(self):
        if not self.__pip:
            self.__pip = _python.Pip(
                python_major_version=self._python_major_version,
                part_dir=self.partdir,
                install_dir=self.installdir,
                stage_dir=self.project.stage_dir)
        return self.__pip

    def __init__(self, name, options, project):
        super().__init__(name, options, project)
        self.build_packages.extend(self.plugin_build_packages)
        self._manifest = collections.OrderedDict()

        # Pip requires only the major version of python rather than the command
        # name like our option requires.
        match = re.match(
            'python(?P<major_version>\d).*', self.options.python_version)
        if not match:
            raise UnsupportedPythonVersionError(
                python_version=self.options.python_version)

        self._python_major_version = match.group('major_version')
        self.__pip = None

    def pull(self):
        super().pull()

        self._pip.setup()

        with simple_env_bzr(os.path.join(self.installdir, 'bin')):
            # Download this project, using its setup.py if present. This will
            # also download any python-packages requested.
            self._download_project()

    def clean_pull(self):
        super().clean_pull()
        self._pip.clean_packages()

    def build(self):
        super().build()

        with simple_env_bzr(os.path.join(self.installdir, 'bin')):
            # Install the packages that have already been downloaded
            installed_pipy_packages = self._install_project()

        # We record the requirements and constraints files only if they are
        # remote. If they are local, they are already tracked with the source.
        if self.options.requirements:
            self._manifest['requirements-contents'] = (
                self._get_file_contents(self.options.requirements))
        if self.options.constraints:
            self._manifest['constraints-contents'] = (
                self._get_file_contents(self.options.constraints))
        self._manifest['python-packages'] = [
            '{}={}'.format(name, installed_pipy_packages[name])
            for name in installed_pipy_packages
        ]

        _python.generate_sitecustomize(
            self._python_major_version, stage_dir=self.project.stage_dir,
            install_dir=self.installdir)

    def _get_setup_py_dir(self):
        setup_py_dir = None
        setup_py = 'setup.py'
        if os.listdir(self.sourcedir):
            setup_py = os.path.join(self.sourcedir, 'setup.py')
        if os.path.exists(setup_py):
            setup_py_dir = os.path.dirname(setup_py)

        return setup_py_dir

    def _get_constraints(self):
        constraints = None
        if self.options.constraints:
            if isurl(self.options.constraints):
                constraints = {self.options.constraints}
            else:
                constraints = {os.path.join(
                    self.sourcedir, self.options.constraints)}
        return constraints

    def _get_requirements(self):
        requirements = None
        if self.options.requirements:
            if isurl(self.options.requirements):
                requirements = {self.options.requirements}
            else:
                requirements = {os.path.join(
                    self.sourcedir, self.options.requirements)}

        return requirements

    def _install_wheels(self, wheels):
        installed = self._pip.list()
        wheel_names = [os.path.basename(w).split('-')[0]
                       for w in wheels]

        # we want to avoid installing what is already provided in
        # stage-packages
        need_install = [k for k in wheel_names if k not in installed]
        self._pip.install(
            need_install, upgrade=True, install_deps=False,
            process_dependency_links=self.options.process_dependency_links)

    def _download_project(self):
        setup_py_dir = self._get_setup_py_dir()
        constraints = self._get_constraints()
        requirements = self._get_requirements()

        self._pip.download(
            self.options.python_packages, setup_py_dir=setup_py_dir,
            constraints=constraints, requirements=requirements,
            process_dependency_links=self.options.process_dependency_links)

    def _install_project(self):
        setup_py_dir = self._get_setup_py_dir()
        constraints = self._get_constraints()
        requirements = self._get_requirements()

        wheels = self._pip.wheel(
            self.options.python_packages, setup_py_dir=setup_py_dir,
            constraints=constraints, requirements=requirements,
            process_dependency_links=self.options.process_dependency_links)

        self._install_wheels(wheels)

        if setup_py_dir is not None:
            setup_py_path = os.path.join(setup_py_dir, 'setup.py')
            if os.path.exists(setup_py_path):
                # pbr and others don't work using `pip install .`
                # LP: #1670852
                # There is also a chance that this setup.py is distutils based
                # in which case we will rely on the `pip install .` ran before
                #  this.
                with contextlib.suppress(subprocess.CalledProcessError):
                    self._setup_tools_install(setup_py_path)

        return self._pip.list()

    def _setup_tools_install(self, setup_file):
        command = [
            _python.get_python_command(
                self._python_major_version, stage_dir=self.project.stage_dir,
                install_dir=self.installdir),
            os.path.basename(setup_file), '--no-user-cfg', 'install',
            '--single-version-externally-managed',
            '--user', '--record', 'install.txt']
        self.run(
            command, env=self._pip.env(),
            cwd=os.path.dirname(setup_file))

        # Fix all shebangs to use the in-snap python. The stuff installed from
        # pip has already been fixed, but anything done in this step has not.
        mangling.rewrite_python_shebangs(self.installdir)

    def _get_file_contents(self, path):
        if isurl(path):
            return requests.get(path).text
        else:
            file_path = os.path.join(self.sourcedir, path)
            with open(file_path) as _file:
                return _file.read()

    def get_manifest(self):
        return self._manifest

    def snap_fileset(self):
        fileset = super().snap_fileset()
        fileset.append('-bin/pip')
        fileset.append('-bin/pip2')
        fileset.append('-bin/pip3')
        fileset.append('-bin/pip2.7')
        fileset.append('-bin/pip3.*')
        fileset.append('-bin/easy_install*')
        fileset.append('-bin/wheel')
        # Holds all the .pyc files. It is a major cause of inter part
        # conflict.
        fileset.append('-**/__pycache__')
        fileset.append('-**/*.pyc')
        # The RECORD files include hashes useful when uninstalling packages.
        # In the snap they will cause conflicts when more than one part uses
        # the python plugin.
        fileset.append('-lib/python*/site-packages/*/RECORD')
        return fileset


@contextlib.contextmanager
def simple_env_bzr(bin_dir):
    """Create an appropriate environment to run bzr.

       The python plugin sets up PYTHONUSERBASE and PYTHONHOME which
       conflicts with bzr when using python3 as those two environment
       variables will make bzr look for modules in the wrong location.
       """
    os.makedirs(bin_dir, exist_ok=True)
    bzr_bin = os.path.join(bin_dir, 'bzr')
    real_bzr_bin = which('bzr')
    if real_bzr_bin:
        exec_line = 'exec {} "$@"'.format(real_bzr_bin)
    else:
        exec_line = 'echo bzr needs to be in PATH; exit 1'
    with open(bzr_bin, 'w') as f:
        f.write(dedent(
            """#!/bin/sh
               unset PYTHONUSERBASE
               unset PYTHONHOME
               {}
            """.format(exec_line)))
    os.chmod(bzr_bin, 0o777)
    try:
        yield
    finally:
        os.remove(bzr_bin)
        if not os.listdir(bin_dir):
            os.rmdir(bin_dir)
