# encoding: utf-8
"""
Data Model, start here to not get mad
=====================================

Main entity will be distribution, like Flask. Per key
Pundle tracks three state parts:
    1. requirement, like Flask>0.12.2.
    2. frozen version, like ==0.12.2
    3. installed distributions, like [flask==0.12.2, flask==0.10.0]

Requirement basically is from file, like requirements.txt, setup.py or Pipfile. This requirements
have source like `requirements.txt`. And base requirements can have dependencies. This
dependencies are turned to requirements too with source like `Flask-Admin << requirements.txt`.
To track requirements we use `CustomReq` class. It can work with PyPi and VCS requirements.
CustomReq can have `self.req = pkg_resources.Requirement` or custom vcs line.

Distribution is one of VCSDist or pkg_resources.DistInfoDistribution. VCSDist is to
track installed VCS packages and pkg_resources.DistInfoDistribution is for PyPi packages.

All three states of distribution are tracked inside `RequirementState` object that includes info
about requirement, frozen version and installed versions.

`Suite` keeps state of all distributions like a dictionary of RequirentStates.

To populate Suite and to parse all requirements, frozen versions and what we have installed pundle
uses `Parser`. There is plenty of them - `Parser` that works with `requirements.txt`,
`SetupParser` that works with `setup.py`, PipfileParser that works with Pipfile and Pipfile.lock.
"""

from __future__ import print_function
import re
try:
    from urllib.parse import urlparse, parse_qsl
except ImportError:  # pragma: no cover
    from urlparse import urlparse, parse_qsl
from collections import defaultdict
from base64 import b64encode, b64decode
import platform
import os.path as op
import os
from os import makedirs
import stat
import tempfile
import shutil
import subprocess
import sys
import shlex
import json
import hashlib
import pkg_resources
try:
    from pip import main as pip_exec
except ImportError:
    from pip._internal import main as pip_exec
    from types import ModuleType

    if isinstance(pip_exec, ModuleType):
        pip_exec = pip_exec.main

# TODO bundle own version of distlib. Perhaps
try:
    from pip._vendor.distlib import locators
except ImportError:  # pragma: no cover
    from pip.vendor.distlib import locators

try:
    str_types = (basestring,)
except NameError:  # pragma: no cover
    str_types = (str, bytes)

try:
    pkg_resources_parse_version = pkg_resources.SetuptoolsVersion
except AttributeError:  # pragma: no cover
    pkg_resources_parse_version = pkg_resources.packaging.version.Version


def print_message(*a, **kw):
    print(*a, **kw)


class PundleException(Exception):
    pass


def python_version_string():
    """We use it to generate per python folder name, where
    we will install all packages.
    """
    if platform.python_implementation() == 'PyPy':
        version_info = sys.pypy_version_info
    else:
        version_info = sys.version_info
    version_string = '{v.major}.{v.minor}.{v.micro}'.format(v=version_info)
    build, _ = platform.python_build()
    build = build.replace(':', '_')  # windows do not understand `:` in path
    return '{}-{}-{}'.format(platform.python_implementation(), version_string, build)


def parse_file(filename):
    """Helper to parse requirements.txt or frozen.txt.
    """
    res = []
    with open(filename) as f:
        for line in f:
            s = line.strip()
            if s and not s.startswith('#'):
                if s.startswith('-r'):
                    continue
                if s.startswith('-e '):
                    s = s[3:].strip()
                if parse_vcs_requirement(s):
                    res.append(s)
                else:
                    req = shlex.split(s, comments=True)
                    res.append(req[0])
    return res


def test_vcs(req):
    """Checks if requirement line is for VCS.
    """
    return '+' in req and req.index('+') == 3


def parse_vcs_requirement(req):
    """Parses VCS line to egg name, version etc.
    """
    if '+' not in req:
        return None
    vcs, url = req.split('+', 1)
    if vcs not in ('git', 'svn', 'hg'):
        return None
    parsed_url = urlparse(url)
    parsed = dict(parse_qsl(parsed_url.fragment))
    if 'egg' not in parsed:
        return None
    egg = parsed['egg'].rsplit('-', 1)
    if len(egg) > 1:
        try:
            pkg_resources_parse_version(egg[1])
        except pkg_resources._vendor.packaging.version.InvalidVersion:
            return parsed['egg'].lower(), req, None
        return egg[0].lower(), req, egg[1]
    else:
        return parsed['egg'].lower(), req, None


def parse_frozen_vcs(req):
    res = parse_vcs_requirement(req)
    if not res:
        return
    return res[0], res[1]


class VCSDist(object):
    """ Represents installed VCS distribution.
    """
    def __init__(self, directory):
        self.dir = directory
        name = op.split(directory)[-1]
        key, encoded = name.split('+', 1)
        self.key = key.lower()
        self.line = b64decode(encoded).decode('utf-8')
        egg, req, version = parse_vcs_requirement(self.line)
        version = version or '0.0.0'
        self.hashcmp = (pkg_resources_parse_version(version), -1, egg, self.dir)
        self.version = self.line
        self.pkg_resource = next(iter(pkg_resources.find_distributions(self.dir, True)), None)
        self.location = self.pkg_resource.location

    def requires(self, extras=[]):
        return self.pkg_resource.requires(extras=extras)

    def activate(self):
        return self.pkg_resource.activate()

    def __lt__(self, other):
        return self.hashcmp.__lt__(other.hashcmp)


class CustomReq(object):
    """Represents PyPi or VCS requirement.
    Can locate and install it.
    """
    def __init__(self, line, env, source=None):
        self.line = line
        self.egg = None
        if isinstance(line, pkg_resources.Requirement):
            self.req = line
        elif test_vcs(line):
            res = parse_vcs_requirement(line)
            if not res:
                raise PundleException('Bad url %r' % line)
            egg, req, version = res
            self.egg = egg
            self.req = None  # pkg_resources.Requirement.parse(res)
        else:
            self.req = pkg_resources.Requirement.parse(line)
        self.sources = set([source])
        self.envs = set()
        self.add_env(env)

    def __contains__(self, something):
        if self.req:
            return (something in self.req)
        elif self.egg:
            return something == self.line
        else:
            return False

    def __repr__(self):
        return '<CustomReq %r>' % self.__dict__

    def why_str(self):
        if len(self.sources) < 2:
            return '{} << {}'.format(self.line, self.why_str_one(list(self.sources)[0]))
        causes = list(sorted(self.why_str_one(source) for source in self.sources))
        return '{} << [{}]'.format(self.line, ' | '.join(causes))

    def why_str_one(self, source):
        if isinstance(source, str_types):
            return source
        elif isinstance(source, CustomReq):
            return source.why_str()
        return '?'

    def adjust_with_req(self, req):
        if not self.req:
            return
            raise PundleException('VCS')
        versions = ','.join(''.join(t) for t in set(self.req.specs + req.req.specs))
        self.requirement = pkg_resources.Requirement.parse('{} {}'.format(
            self.req.project_name, versions
        ))
        self.sources.update(req.sources)
        self.add_env(req.envs)

    @property
    def key(self):
        return self.req.key if self.req else self.egg

    @property
    def extras(self):
        return self.req.extras if self.req else []

    def locate(self, suite, prereleases=False):
        # requirements can have something after `;` symbol that `locate` does not understand
        req = str(self.req).split(';', 1)[0]
        dist = suite.locate(req, prereleases=prereleases)
        if not dist:
            # have not find any releases so search for pre
            dist = suite.locate(req, prereleases=True)
        if not dist:
            raise PundleException('%s can not be located' % self.req)
        return dist

    def locate_and_install(self, suite, installed=None, prereleases=False):
        if self.egg:
            key = b64encode(self.line.encode('utf-8')).decode()
            target_dir = op.join(suite.parser.directory, '{}+{}'.format(self.egg, key))
            target_req = self.line
            ready = [
                installation
                for installation in (installed or [])
                if getattr(installation, 'line', None) == self.line
            ]
        else:
            loc_dist = self.locate(suite, prereleases=prereleases)
            ready = [
                installation
                for installation in (installed or [])
                if installation.version == loc_dist.version
            ]
            target_dir = op.join(suite.parser.directory, '{}-{}'.format(loc_dist.key, loc_dist.version))
            # DEL? target_req = '%s==%s' % (loc_dist.name, loc_dist.version)
            # If we use custom index, then we want not to configure PIP with it
            # and just give it URL
            target_req = loc_dist.download_url
        if ready:
            return ready[0]
        try:
            makedirs(target_dir)
        except OSError:
            pass
        tmp_dir = tempfile.mkdtemp()
        print('Use temp dir', tmp_dir)
        try:
            print('pip install --no-deps -t %s %s' % (tmp_dir, target_req))
            pip_exec([
                'install',
                '--no-deps',
                '-t', tmp_dir,
                '-v',
                target_req
            ])
            for item in os.listdir(tmp_dir):
                shutil.move(op.join(tmp_dir, item), op.join(target_dir, item))
        except Exception as exc:
            raise PundleException('%s was not installed due error %s' % (self.egg or loc_dist.name, exc))
        finally:
            shutil.rmtree(tmp_dir, ignore_errors=True)
        return next(iter(pkg_resources.find_distributions(target_dir, True)), None)

    def add_env(self, env):
        if isinstance(env, str):
            self.envs.add(env)
        else:
            self.envs.update(env)


class RequirementState(object):
    """Holds requirement state, like what version do we have installed, is
    some version frozen or not, what requirement constrains do we have.
    """
    def __init__(self, key, req=None, frozen=None, installed=None, hashes=None):
        self.key = key
        self.requirement = req
        self.frozen = frozen
        self.hashes = hashes
        self.installed = installed or []
        self.installed.sort()
        self.installed.reverse()

    def __repr__(self):
        return '<RequirementState %r>' % self.__dict__

    def adjust_with_req(self, req):
        if self.requirement:
            self.requirement.adjust_with_req(req)
        else:
            self.requirement = req

    def has_correct_freeze(self):
        return self.requirement and self.frozen and self.frozen in self.requirement

    def check_installed_version(self, suite, install=False):
        # install version of package if not installed
        dist = None
        if self.has_correct_freeze():
            dist = [
                installation
                for installation in self.installed
                if pkg_resources.parse_version(installation.version) == pkg_resources.parse_version(self.frozen)
            ]
            dist = dist[0] if dist else None
            if install and not dist:
                dist = self.install_frozen(suite)
        if install and not dist:
            dist = self.requirement.locate_and_install(suite, installed=self.get_installed())
            if dist is None:
                raise PundleException('Package %s was not installed due some error' % self.key)
            self.frozen = dist.version
            self.installed.append(dist)
            self.frozen = dist.version
        return dist

    def get_installed(self):
        return [installation for installation in self.installed if installation.version in self.requirement]

    def upgrade(self, suite, prereleases=False):
        # check if we have fresh packages on PIPY
        dists = self.get_installed()
        dist = dists[0] if dists else None
        latest = self.requirement.locate(suite, prereleases=prereleases)
        if not dist or pkg_resources.parse_version(latest.version) > pkg_resources.parse_version(dist.version):
            print_message('Upgrade to', latest)
            dist = self.requirement.locate_and_install(suite, installed=self.get_installed(), prereleases=prereleases)
        # Anyway use latest available dist
        self.frozen = dist.version
        self.installed.append(dist)
        return dist

    def reveal_requirements(self, suite, install=False, upgrade=False, already_revealed=None, prereleases=False):
        already_revealed = already_revealed.copy() if already_revealed is not None else set()
        if self.key in already_revealed:
            return
        if upgrade:
            dist = self.upgrade(suite, prereleases=prereleases)
        else:
            dist = self.check_installed_version(suite, install=install)
        if not dist:
            return
        already_revealed.add(self.key)
        for req in dist.requires(extras=self.requirement.extras):
            suite.adjust_with_req(
                CustomReq(str(req), self.requirement.envs, source=self.requirement),
                install=install,
                upgrade=upgrade,
                already_revealed=already_revealed,
            )

    def frozen_dump(self):
        if self.requirement.egg:
            return self.requirement.line
        main = '{}=={}'.format(self.key, self.frozen)
        comment = self.requirement.why_str()
        return '{:20s} # {}'.format(main, comment)

    def frozen_dist(self):
        if not self.frozen:
            return
        for dist in self.installed:
            if pkg_resources.parse_version(dist.version) == pkg_resources.parse_version(self.frozen):
                return dist

    def install_frozen(self, suite):
        if self.frozen_dist() or not self.frozen:
            return
        envs = self.requirement.envs if self.requirement else ''
        if test_vcs(self.frozen):
            frozen_req = CustomReq(self.frozen, envs)
        else:
            frozen_req = CustomReq("{}=={}".format(self.key, self.frozen), envs)
        dist = frozen_req.locate_and_install(suite)
        self.installed.append(dist)
        return dist

    def activate(self):
        dist = self.frozen_dist()
        if not dist:
            raise PundleException('Distribution is not installed %s' % self.key)
        dist.activate()
        pkg_resources.working_set.add_entry(dist.location)
        # find end execute *.pth
        sitedir = dist.location  # noqa some PTH search for sitedir
        for filename in os.listdir(dist.location):
            if not filename.endswith('.pth'):
                continue
            try:
                for line in open(op.join(dist.location, filename)):
                    if line.startswith('import '):
                        exec(line.strip())
            except Exception as e:
                print('Erroneous pth file %r' % op.join(dist.location, filename))
                print(e)


class AggregatingLocator(object):
    def __init__(self, locators):
        self.locators = locators

    def locate(self, req, **kw):
        for locator in self.locators:
            print_message('try', locator, 'for', req)
            revealed = locator.locate(req, **kw)
            if revealed:
                return revealed


class Suite(object):
    """Main object that represents current directory pundle state.
    It tracks RequirementStates, envs, urls for package locator.
    """
    def __init__(self, parser, envs=[], urls=None):
        self.states = {}
        self.parser = parser
        self.envs = envs
        self.urls = urls or ['https://pypi.python.org/simple/']
        if 'PIP_EXTRA_INDEX_URL' in os.environ:
            self.urls.append(os.environ['PIP_EXTRA_INDEX_URL'])
        self.locators = []
        for url in self.urls:
            self.locators.append(
                locators.SimpleScrapingLocator(url, timeout=3.0, scheme='legacy')
            )
        self.locators.append(locators.JSONLocator(scheme='legacy'))
        self.locator = AggregatingLocator(self.locators)

    def use(self, key):
        """For single mode
        You can call suite.use('arrow') and then `import arrow`

        :key: package name
        """
        self.adjust_with_req(CustomReq(key, ''))
        self.install()
        self.activate_all()

    def locate(self, *a, **kw):
        return self.locator.locate(*a, **kw)

    def add(self, key, state):
        self.states[key] = state

    def __repr__(self):
        return '<Suite %r>' % self.states

    def required_states(self):
        return [state for state in self.states.values() if state.requirement]

    def need_freeze(self, verbose=False):
        self.install(install=False)
        not_correct = not all(state.has_correct_freeze() for state in self.required_states())
        if not_correct and verbose:
            for state in self.required_states():
                if not state.has_correct_freeze():
                    print(
                        state.key,
                        'Need',
                        state.requirement,
                        'have not been frozen',
                        state.frozen,
                        ', installed',
                        state.installed
                    )
        # TODO
        # unneeded = any(state.frozen for state in self.states.values() if not state.requirement)
        # if unneeded:
        #     print('!!! Unneeded', [state.key for state in self.states.values() if not state.requirement])
        return not_correct  # or unneeded

    def adjust_with_req(self, req, install=False, upgrade=False, already_revealed=None):
        state = self.states.get(req.key)
        if not state:
            state = RequirementState(req.key, req=req)
            self.add(req.key, state)
        else:
            state.adjust_with_req(req)
        state.reveal_requirements(self, install=install, upgrade=upgrade, already_revealed=already_revealed or set())

    def install(self, install=True):
        for state in self.required_states():
            state.reveal_requirements(self, install=install)

    def upgrade(self, key=None, prereleases=False):
        states = [self.states[key]] if key else self.required_states()
        for state in states:
            print('Check', state.requirement.req)
            state.reveal_requirements(self, upgrade=True, prereleases=prereleases)

    def get_frozen_states(self, env):
        return [
            state
            for state in self.required_states()
            if state.requirement and env in state.requirement.envs
        ]

    def need_install(self):
        return not all(state.frozen_dist() for state in self.states.values() if state.frozen)

    def install_frozen(self):
        for state in self.states.values():
            state.install_frozen(self)

    def activate_all(self, envs=('',)):
        for state in self.required_states():
            if '' in state.requirement.envs or any(env in state.requirement.envs for env in envs):
                state.activate()

    def save_frozen(self):
        "Saves frozen files to disk"
        states_by_env = dict(
            (env, self.get_frozen_states(env))
            for env in self.parser.envs()
        )
        self.parser.save_frozen(states_by_env)


class Parser(object):
    """Gather environment info, requirements,
    frozen packages and create Suite object
    """
    def __init__(
            self,
            base_path=None,
            directory='Pundledir',
            requirements_files=None,
            frozen_files=None,
            package=None,
    ):
        self.base_path = base_path or '.'
        self.directory = directory
        self.requirements_files = requirements_files
        if frozen_files is None:
            self.frozen_files = {'': 'frozen.txt'}
        else:
            self.frozen_files = frozen_files
        self.package = package
        self.package_envs = set([''])

    def envs(self):
        if self.requirements_files:
            return list(self.requirements_files.keys()) or ['']
        elif self.package:
            return self.package_envs
        return ['']

    def get_frozen_file(self, env):
        if env in self.frozen_files:
            return self.frozen_files[env]
        else:
            return os.path.join(self.base_path, 'frozen_%s.txt' % env)

    def create_suite(self):
        reqs = self.parse_requirements()
        freezy = self.parse_frozen()
        hashes = self.parse_frozen_hashes()
        diry = self.parse_directory()
        state_keys = set(list(reqs.keys()) + list(freezy.keys()) + list(diry.keys()))
        suite = Suite(self, envs=self.envs())
        for key in state_keys:
            suite.add(
                key,
                RequirementState(
                    key,
                    req=reqs.get(key),
                    frozen=freezy.get(key),
                    installed=diry.get(key, []),
                    hashes=hashes.get(key),
                ),
            )
        return suite

    def parse_directory(self):
        if not op.exists(self.directory):
            return {}
        dists = [
            # this magic takes first element or None
            next(iter(
                pkg_resources.find_distributions(op.join(self.directory, item), True)
            ), None)
            for item in os.listdir(self.directory) if '-' in item
        ]
        dists.extend(
            VCSDist(op.join(self.directory, item))
            for item in os.listdir(self.directory) if '+' in item
        )
        dists = filter(None, dists)
        result = defaultdict(list)
        for dist in dists:
            result[dist.key].append(dist)
        return result

    def parse_frozen(self):
        frozen_versions = {}
        for env in self.envs():
            frozen_file = self.get_frozen_file(env)
            if op.exists(frozen_file):
                frozen = [
                    (parse_frozen_vcs(line) or line.split('=='))
                    for line in parse_file(frozen_file)
                ]
            else:
                frozen = []
            for name, version in frozen:
                frozen_versions[name.lower()] = version
        return frozen_versions

    def parse_frozen_hashes(self):
        """This implementation does not support hashes yet
        """
        return {}

    def parse_requirements(self):
        all_requirements = {}
        for env, req_file in self.requirements_files.items():
            requirements = parse_file(req_file)
            if env:
                source = 'requirements %s file' % env
            else:
                source = 'requirements file'
            for line in requirements:
                req = CustomReq(line, env, source=source)
                if req.key in all_requirements:
                    # if requirements exists in other env, then add this env too
                    all_requirements[req.key].add_env(env)
                else:
                    all_requirements[req.key] = req
        return all_requirements

    def save_frozen(self, states_by_env):
        for env, states in states_by_env.items():
            data = '\n'.join(sorted(
                state.frozen_dump()
                for state in states
            )) + '\n'
            frozen_file = self.get_frozen_file(env)
            with open(frozen_file, 'w') as f:
                f.write(data)


class SingleParser(Parser):
    """Parser for console mode.
    """
    def parse_requirements(self):
        return {}


class SetupParser(Parser):
    """Parser for `setup.py`. Because it mostly used to develop package, we
    do not freeze packages to setup.py. We use `frozen.txt`.
    """
    def parse_requirements(self):
        setup_info = get_info_from_setup(self.package)
        if setup_info is None:
            raise PundleException('There is no requirements.txt nor setup.py')
        install_requires = setup_info.get('install_requires') or []
        reqs = [
            CustomReq(str(req), '', source='setup.py')
            for req in install_requires
        ]
        requirements = dict((req.key, req) for req in reqs)
        # we use `feature` as environment for pundle
        extras_require = (setup_info.get('extras_require') or {})
        for feature, feature_requires in extras_require.items():
            for req_line in feature_requires:
                req = CustomReq(req_line, feature, source='setup.py')
                # if this req already in dict, then add our feature as env
                if req.key in requirements:
                    requirements[req.key].add_env(feature)
                else:
                    requirements[req.key] = req
            self.package_envs.add(feature)
        return requirements


class PipfileParser(Parser):
    """Parser for Pipfile and Pipfile.lock.
    """
    DEFAULT_PIPFILE_SOURCES = [
        {
            'name': 'pypi',
            'url': 'https://pypi.python.org/simple',
            'verify_ssl': True,
        },
    ]

    def __init__(self, **kw):
        self.pipfile = kw.pop('pipfile')
        self.pipfile_envs = set([''])
        super(PipfileParser, self).__init__(**kw)
        # cache
        self.loaded_pipfile = None
        self.loaded_pipfile_lock = None

    def envs(self):
        return self.pipfile_envs

    def pipfile_content(self):
        import toml
        if self.loaded_pipfile:
            return self.loaded_pipfile
        self.loaded_pipfile = toml.load(open(self.pipfile))
        return self.loaded_pipfile

    def pipfile_lock_content(self):
        if self.loaded_pipfile_lock:
            return self.loaded_pipfile_lock
        try:
            self.loaded_pipfile_lock = json.load(open(self.pipfile + '.lock'))
        except Exception:
            pass
        return self.loaded_pipfile_lock

    def parse_requirements(self):
        info = self.pipfile_content()
        all_requirements = {}
        for info_key in info:
            if not info_key.endswith('packages'):
                continue
            if '-' in info_key:
                env, _ = info_key.split('-', 1)
            else:
                env = ''
            self.pipfile_envs.add(env)
            for key, details in info[info_key].items():
                if isinstance(details, str_types):
                    if details != '*':
                        key = key + details  # details is a version requirement
                    req = CustomReq(key, env, source='Pipfile')
                else:
                    # a dict
                    if 'file' in details or 'path' in details:
                        raise PundleException('Unsupported Pipfile feature yet %s: %r' % (key, details))
                    if 'git' in details:
                        # wow, this as a git package!
                        req = CustomReq('git+%s#egg=%s' % (details['git'], key), env, source='Pipfile')
                    else:
                        # else just simple requirement
                        req = CustomReq(key + details['version'], env, source='Pipfile')
                if req.key in all_requirements:
                    # if requirements exists in other env, then add this env too
                    all_requirements[req.key].add_env(env)
                else:
                    all_requirements[req.key] = req
        return all_requirements

    def parse_frozen(self):
        parsed_frozen = self.pipfile_lock_content()
        if parsed_frozen is None:
            return {}
        frozen_versions = {}
        for env in parsed_frozen:
            if env.startswith('_'):
                # this is not an env
                continue
            for key, details in parsed_frozen[env].items():
                if 'vcs' in details:
                    frozen_versions[key] = details['vcs']
                else:
                    frozen_versions[key] = details.get('version', '0.0.0').lstrip('=')
        return frozen_versions

    def parse_frozen_hashes(self):
        parsed_frozen = self.pipfile_lock_content()
        if parsed_frozen is None:
            return {}
        frozen_versions = {}
        for env in parsed_frozen:
            if env.startswith('_'):
                # this is not an env
                continue
            for key, details in parsed_frozen[env].items():
                frozen_versions[key] = details.get('hashes', [])
        return frozen_versions

    def hash(self):
        """Returns the SHA256 of the pipfile's data.
        From pipfile.
        """
        pipfile_content = self.pipfile_content()
        data = {
            '_meta': {
                'sources': pipfile_content.get('sources') or self.DEFAULT_PIPFILE_SOURCES,
                'requires': pipfile_content.get('requires') or {},
            },
            'default': pipfile_content.get('packages') or {},
            'develop': pipfile_content.get('dev-packages') or {},
        }
        content = json.dumps(data, sort_keys=True, separators=(",", ":"))
        return hashlib.sha256(content.encode("utf8")).hexdigest()

    def save_frozen(self, states_by_env):
        """Implementation is not complete.
        """
        data = self.pipfile_lock_content() or {}
        data.setdefault('_meta', {
            'pipfile-spec': 5,
            'requires': {},
            'sources': self.DEFAULT_PIPFILE_SOURCES,
        })
        data.setdefault('_meta', {}).setdefault('hash', {})['sha256'] = self.hash()
        for env, states in states_by_env.items():
            if env == '':
                env_key = 'default'
            elif env == 'dev':
                env_key = 'develop'
            else:
                env_key = env
            reqs = data.setdefault(env_key, {})
            for state in states:
                if state.requirement.egg:
                    egg, url, version = parse_vcs_requirement(state.requirement.line)
                    reqs[state.key] = {
                        'vcs': url,
                    }
                else:
                    reqs[state.key] = {
                        'version': '==' + state.frozen,
                        'hashes': state.hashes or [],
                    }
        with open(self.pipfile + '.lock', 'w') as f:
            f.write(json.dumps(data, sort_keys=True, indent=4))


def create_parser(**parser_args):
    """Utility function that tried to figure out what Parser to use
    in current directory.
    """
    if parser_args.get('requirements_files'):
        return Parser(**parser_args)
    elif parser_args.get('package'):
        return SetupParser(**parser_args)
    elif parser_args.get('pipfile'):
        return PipfileParser(**parser_args)
    return SingleParser(**parser_args)


# Utilities
def get_info_from_setup(path):
    """Mock setuptools.setup(**kargs) to get
    package information about requirements and extras
    """
    preserve = {}

    def _save_info(**setup_args):
        preserve['args'] = setup_args

    import setuptools
    original_setup = setuptools.setup
    setuptools.setup = _save_info
    import runpy
    runpy.run_path(os.path.join(path, 'setup.py'), run_name='__main__')
    setuptools.setup = original_setup
    return preserve.get('args')


def search_files_upward(start_path=None):
    "Search for requirements.txt, setup.py or Pipfile upward"
    if not start_path:
        start_path = op.abspath(op.curdir)
    if any(
            op.exists(op.join(start_path, filename))
            for filename in ('requirements.txt', 'setup.py', 'Pipfile')
    ):
        return start_path
    up_path = op.abspath(op.join(start_path, '..'))
    if op.samefile(start_path, up_path):
        return None
    return search_files_upward(start_path=up_path)


def find_all_prefixed_files(directory, prefix):
    "find all requirements_*.txt files"
    envs = {}
    for entry in os.listdir(directory):
        if not entry.startswith(prefix):
            continue
        name, ext = op.splitext(entry)
        env = name[len(prefix):].lstrip('_')
        envs[env] = op.join(directory, entry)
    return envs


def create_parser_parameters():
    base_path = search_files_upward()
    if not base_path:
        raise PundleException('Can not find requirements.txt nor setup.py nor Pipfile')
    py_version_path = python_version_string()
    pundledir_base = os.environ.get('PUNDLEDIR') or op.join(op.expanduser('~'), '.pundledir')
    if op.exists(op.join(base_path, 'requirements.txt')):
        requirements_files = find_all_prefixed_files(base_path, 'requirements')
    else:
        requirements_files = {}
    envs = list(requirements_files.keys()) or ['']
    params = {
        'base_path': base_path,
        'frozen_files': {
            env: op.join(base_path, 'frozen_%s.txt' % env if env else 'frozen.txt')
            for env in envs
        },
        'directory': op.join(pundledir_base, py_version_path),
    }
    if requirements_files:
        params['requirements_files'] = requirements_files
    elif op.exists(op.join(base_path, 'setup.py')):
        params['package'] = base_path
    elif op.exists(op.join(base_path, 'Pipfile')):
        params['pipfile'] = op.join(base_path, 'Pipfile')
    else:
        return
    return params


def create_parser_or_exit():
    parser_kw = create_parser_parameters()
    if not parser_kw:
        print_message('You have not requirements.txt. Create it and run again.')
        exit(1)
    return parser_kw


# Commands
def upgrade_all(**kw):
    key = kw.pop('key')
    prereleases = kw.pop('prereleases')
    suite = create_parser(**kw).create_suite()
    suite.need_freeze()
    suite.upgrade(key=key, prereleases=prereleases)
    suite.install()
    suite.save_frozen()


def install_all(**kw):
    suite = create_parser(**kw).create_suite()
    if suite.need_freeze() or suite.need_install():
        print_message('Install some packages')
        suite.install()
    else:
        print_message('Nothing to do, all packages installed')
    suite.save_frozen()
    return suite


def activate():
    parser_kw = create_parser_parameters()
    if not parser_kw:
        raise PundleException('Can`t create parser parameters')
    suite = create_parser(**parser_kw).create_suite()
    if suite.need_freeze(verbose=True):
        raise PundleException('frozen file is outdated')
    if suite.need_install():
        raise PundleException('Some dependencies not installed')
    envs = (os.environ.get('PUNDLEENV') or '').split(',')
    suite.activate_all(envs=envs)
    return suite


FIXATE_TEMPLATE = """
# pundle user customization start
import pundle; pundle.activate()
# pundle user customization end
"""


def fixate():
    "puts activation code to usercustomize.py for user"
    print_message('Fixate')
    import site
    userdir = site.getusersitepackages()
    if not userdir:
        raise PundleException('Can`t fixate due user have not site package directory')
    try:
        makedirs(userdir)
    except OSError:
        pass
    template = FIXATE_TEMPLATE.replace('op.dirname(__file__)', "'%s'" % op.abspath(op.dirname(__file__)))
    usercustomize_file = op.join(userdir, 'usercustomize.py')
    print_message('Will edit %s file' % usercustomize_file)
    if op.exists(usercustomize_file):
        content = open(usercustomize_file).read()
        if '# pundle user customization start' in content:
            regex = re.compile(r'\n# pundle user customization start.*# pundle user customization end\n', re.DOTALL)
            content, res = regex.subn(template, content)
            open(usercustomize_file, 'w').write(content)
        else:
            open(usercustomize_file, 'a').write(content)
    else:
        open(usercustomize_file, 'w').write(template)
    link_file = op.join(userdir, 'pundle.py')
    if op.lexists(link_file):
        print_message('Remove exist link to pundle')
        os.unlink(link_file)
    print_message('Create link to pundle %s' % link_file)
    os.symlink(op.abspath(__file__), link_file)
    print_message('Complete')


def entry_points():
    suite = activate()
    entries = {}
    for r in suite.states.values():
        d = r.frozen_dist()
        if not d:
            continue
        if isinstance(d, VCSDist):
            continue
        scripts = d.get_entry_map().get('console_scripts', {})
        for name in scripts:
            entries[name] = d
    return entries


class CmdRegister:
    commands = {}
    ordered = []

    @classmethod
    def cmdline(cls, *cmd_aliases):
        def wrap(func):
            for alias in cmd_aliases:
                cls.commands[alias] = func
                cls.ordered.append(alias)
        return wrap

    @classmethod
    def help(cls):
        for alias in cls.ordered:
            if not alias:
                continue
            print("{:15s} {}".format(alias, cls.commands[alias].__doc__))

    @classmethod
    def main(cls):
        alias = '' if len(sys.argv) == 1 else sys.argv[1]
        if alias == 'help':
            cls.help()
            return
        if alias not in cls.commands:
            print('Unknown command\nTry this:')
            cls.help()
            sys.exit(1)
        cls.commands[alias]()


@CmdRegister.cmdline('', 'install')
def cmd_install():
    "Install packages by frozen.txt and resolve ones that was not frozen"
    install_all(**create_parser_or_exit())


@CmdRegister.cmdline('upgrade')
def cmd_upgrade():
    """
    [package [pre]] if package provided will upgrade it and dependencies or all packages from PyPI.
    If `pre` provided will look for prereleases.
    """
    key = sys.argv[2] if len(sys.argv) > 2 else None
    prereleases = sys.argv[3] == 'pre' if len(sys.argv) > 3 else False
    upgrade_all(key=key, prereleases=prereleases, **create_parser_or_exit())


CmdRegister.cmdline('fixate')(fixate)


@CmdRegister.cmdline('exec')
def cmd_exec():
    "executes setuptools entry"
    cmd = sys.argv[2]
    args = sys.argv[3:]
    entries = entry_points()
    if cmd not in entries:
        print_message('Script is not found. Check if package is installed, or look at the `pundle entry_points`')
        sys.exit(1)
    exc = entries[cmd].get_entry_info('console_scripts', cmd).load()
    sys.path.insert(0, '')
    sys.argv = [cmd] + args
    exc()


@CmdRegister.cmdline('entry_points')
def cmd_entry_points():
    "prints available setuptools entries"
    for entry, package in entry_points().items():
        print('%s (%s)' % (entry, package))


@CmdRegister.cmdline('edit')
def cmd_edit():
    "prints directory path to package"
    parser_kw = create_parser_parameters()
    suite = create_parser(**parser_kw).create_suite()
    if suite.need_freeze():
        raise PundleException('%s file is outdated' % suite.parser.frozen_file)
    print(suite.states[sys.argv[2]].frozen_dist().location)


@CmdRegister.cmdline('info')
def cmd_info():
    "prints info about Pundle state"
    parser_kw = create_parser_parameters()
    suite = create_parser(**parser_kw).create_suite()
    if suite.need_freeze():
        print('frozen.txt is outdated')
    else:
        print('frozen.txt is up to date')
    for state in suite.required_states():
        print(
            'Requirement "{}", frozen {}, {}'.format(
                state.key,
                state.frozen,
                state.requirement.line if state.requirement else 'None'
            )
        )
        print('Installed versions:')
        for dist in state.installed:
            print('    ', repr(dist))
        if not state.installed:
            print('     None')


def run_console(glob):
    import readline
    import rlcompleter
    import atexit
    import code

    history_path = os.path.expanduser("~/.python_history")

    def save_history(history_path=history_path):
        readline.write_history_file(history_path)
    if os.path.exists(history_path):
        readline.read_history_file(history_path)

    atexit.register(save_history)

    readline.set_completer(rlcompleter.Completer(glob).complete)
    readline.parse_and_bind("tab: complete")
    code.InteractiveConsole(locals=glob).interact()


@CmdRegister.cmdline('console')
def cmd_console():
    "[ipython|bpython|ptpython] starts python console with activated pundle environment"
    suite = activate()
    glob = {
        'pundle_suite': suite,
    }
    interpreter = sys.argv[2] if len(sys.argv) > 2 else None
    if not interpreter:
        run_console(glob)
    elif interpreter == 'ipython':
        from IPython import embed
        embed()
    elif interpreter == 'ptpython':
        from ptpython.repl import embed
        embed(glob, {})
    elif interpreter == 'bpython':
        from bpython import embed
        embed(glob)
    else:
        raise PundleException('Unknown interpreter: {}. Choose one of None, ipython, bpython, ptpython.')


@CmdRegister.cmdline('run')
def cmd_run():
    "executes given script"
    activate()
    import runpy
    sys.path.insert(0, '')
    script = sys.argv[2]
    sys.argv = [sys.argv[2]] + sys.argv[3:]
    runpy.run_path(script, run_name='__main__')


@CmdRegister.cmdline('module')
def cmd_module():
    "executes module like `python -m`"
    activate()
    import runpy
    sys.path.insert(0, '')
    module = sys.argv[2]
    sys.argv = [sys.argv[2]] + sys.argv[3:]
    runpy.run_module(module, run_name='__main__')


@CmdRegister.cmdline('env')
def cmd_env():
    "populates PYTHONPATH with packages paths and executes command line in subprocess"
    activate()
    aug_env = os.environ.copy()
    aug_env['PYTHONPATH'] = ':'.join(sys.path)
    subprocess.call(sys.argv[2:], env=aug_env)


@CmdRegister.cmdline('print_env')
def cmd_print_env():
    "Prints PYTHONPATH. For usage with mypy and MYPYPATH"
    suite = activate()
    path = ':'.join(
        state.frozen_dist().location
        for state in suite.states.values()
        if state.frozen_dist()
    )
    print(path)


ENTRY_POINT_TEMPLATE = '''#! /usr/bin/env python
import pundle; pundle.activate()
pundle.entry_points()['{entry_point}'].get_entry_info('console_scripts', '{entry_point}').load(require=False)()
'''


@CmdRegister.cmdline('linkall')
def link_all():
    "links all packages to `.pundle_local` dir"
    local_dir = '.pundle_local'
    suite = activate()

    try:
        makedirs(local_dir)
    except OSError:
        pass
    local_dir_info = {de.name: de for de in os.scandir(local_dir)}
    for r in suite.states.values():
        d = r.frozen_dist()
        if not d:
            continue
        for dir_entry in os.scandir(d.location):
            if dir_entry.name.startswith('__') or dir_entry.name.startswith('.') or dir_entry.name == 'bin':
                continue
            dest_path = os.path.join(local_dir, dir_entry.name)
            if dir_entry.name in local_dir_info:
                sym = local_dir_info.pop(dir_entry.name)
                existed = op.realpath(sym.path)
                if existed == dir_entry.path:
                    continue
                os.remove(sym.path)
            os.symlink(dir_entry.path, dest_path)
    # create entry_points binaries
    try:
        makedirs(os.path.join(local_dir, 'bin'))
    except OSError:
        pass
    for bin_name, entry_point in entry_points().items():
        bin_filename = os.path.join(local_dir, 'bin', bin_name)
        open(bin_filename, 'w').write(ENTRY_POINT_TEMPLATE.format(entry_point=bin_name))
        file_stat = os.stat(bin_filename)
        os.chmod(bin_filename, file_stat.st_mode | stat.S_IEXEC)
    local_dir_info.pop('bin')

    # remove extra links
    for de in local_dir_info:
        os.remove(de.path)


@CmdRegister.cmdline('show_requirements')
def show_requirements():
    "shows details requirements info"
    suite = activate()
    for name, state in suite.states.items():
        if state.requirement:
            print(
                name,
                'frozen:',
                state.frozen,
                'required:',
                state.requirement.req if state.requirement.req else 'VCS',
                state.requirement.envs,
            )


# Single mode that you can use in console
_single_mode_suite = {}  # cache variable to keep current suite for single_mode


def single_mode():
    """ Create, cache and return Suite instance for single_mode.
    """
    if not _single_mode_suite:
        py_version_path = python_version_string()
        pundledir_base = os.environ.get('PUNDLEDIR') or op.join(op.expanduser('~'), '.pundledir')
        directory = op.join(pundledir_base, py_version_path)
        _single_mode_suite['cache'] = create_parser(directory=directory).create_suite()
    return _single_mode_suite['cache']


def use(key):
    """ Installs `key` requirement, like `django==1.11` or just `django`
    """
    suite = single_mode()
    suite.use(key)


if __name__ == '__main__':
    CmdRegister.main()