from __future__ import absolute_import
from __future__ import print_function
from __future__ import unicode_literals

import io
import itertools
import os.path
import sys
from operator import attrgetter

import pkg_resources
import pytest


installed_things = {
    pkg.key: pkg
    for pkg in pkg_resources.working_set
}
REQUIREMENTS_FILES = frozenset(('requirements.txt', 'requirements-dev.txt'))


def parse_requirement(req):
    dumb_parse = pkg_resources.Requirement.parse(req)
    if dumb_parse.extras:
        extras = '[{}]'.format(','.join(dumb_parse.extras))
    else:
        extras = ''
    return pkg_resources.Requirement.parse(
        dumb_parse.project_name + extras + ','.join(
            operator + pkg_resources.safe_version(version)
            for operator, version in dumb_parse.specs
        ),
    )


def get_lines_from_file(filename):
    with io.open(filename) as requirements_file:
        return [
            line.strip() for line in requirements_file
            if line.strip() and not line.startswith('#')
        ]


def get_raw_requirements(filename):
    ret = []
    for line in get_lines_from_file(filename):
        # allow something to editably install itself
        if line.strip() == '-e .':
            continue
        try:
            ret.append((parse_requirement(line), filename))
        except pkg_resources.RequirementParseError as e:
            raise AssertionError(
                'Requirements must be <<pkg>> or <<pkg>>==<<version>>\n'
                ' - git / http / etc. urls may be mutable (unpinnable)\n'
                ' - transitive dependencies from urls are not traceable\n'
                ' - line of error: {}\n'
                ' - inner exception: {!r}\n'.format(line.strip(), e),
            )
    return ret


def to_version(requirement):
    """Given a requirement spec, return the pinned version.

    Returns None if no single version is pinned.
    """
    if len(requirement.specs) != 1:
        return
    if requirement.specs[0][0] != '==':
        return
    return requirement.specs[0][1]


def to_equality_str(requirement):
    return '{}=={}'.format(requirement.key, to_version(requirement))


def to_pinned_versions(requirements):
    return {req.key: to_version(req) for req, _ in requirements}


def find_unpinned_requirements(requirements):
    """
    :param requirements: list of (requirement, filename)
    :return: Unpinned packages: list of
        (package_name, requiring_package, filename)
    """
    pinned_versions = to_pinned_versions(requirements)

    unpinned = {
        # unpinned packages already listed in requirements.txt
        (requirement.key, requirement, filename)
        for requirement, filename in requirements
        if not pinned_versions[requirement.key]
    }

    # unpinned packages which are needed but not listed in requirements.txt
    for requirement, filename in requirements:
        package_info = installed_things[requirement.key]

        for sub_requirement in package_info.requires(requirement.extras):
            if sub_requirement.key not in pinned_versions:
                unpinned.add(
                    (sub_requirement.key, requirement, filename),
                )

    return unpinned


def format_unpinned_requirements(unpinned_requirements):
    return '\t' + '\n\t'.join(
        '{} (required by {} in {})\n\t\tmaybe you want "{}"?'.format(
            package,
            requirement,
            filename,
            '{}=={}'.format(package, installed_things[package].version),
        )
        for package, requirement, filename in sorted(
            unpinned_requirements,
            key=str,
        )
    )


def _check_requirements_is_only_for_applications_impl():
    if not os.path.exists('requirements.txt'):
        raise AssertionError(
            'check-requirements is designed specifically with applications '
            'in mind (and does not properly work for libraries).\n'
            "Either remove check-requirements (if you're a library) or "
            '`touch requirements.txt`.',
        )


@pytest.fixture(autouse=True, scope='session')
def check_requirements_is_only_for_applications():  # pragma: no cover
    """separated as fixtures are not callable in pytest 4+"""
    _check_requirements_is_only_for_applications_impl()


def _get_all_raw_requirements(requirements_files=REQUIREMENTS_FILES):
    # for compatibility with repos that haven't started using
    # requirements-dev-minimal.txt, we don't want to force pinning
    # requirements-dev.txt until they use minimal
    if not os.path.exists('requirements-dev-minimal.txt'):
        requirements_files -= {'requirements-dev.txt'}

    if all(
            not os.path.exists(reqfile)
            for reqfile in requirements_files
    ):  # pragma: no cover
        return

    return list(
        itertools.chain.from_iterable([
            get_raw_requirements(reqfile)
            for reqfile in requirements_files
            if os.path.exists(reqfile)
        ]),
    )


def _check_requirements_integrity_impl():
    raw_requirements = _get_all_raw_requirements()
    if not raw_requirements:
        raise AssertionError(
            'check-requirements expects at least requirements-minimal.txt '
            'and requirements.txt',
        )

    incorrect = []
    for req, filename in raw_requirements:
        version = to_version(req)
        if version is None:  # Not pinned, just skip
            continue
        if req.key not in installed_things:
            raise AssertionError(
                '{} is required in {}, but is not installed'.format(
                    req.key, filename,
                ),
            )
        installed_version = to_version(
            parse_requirement(
                '{}=={}'.format(req.key, installed_things[req.key].version),
            ),
        )
        if installed_version != version:
            incorrect.append((filename, req.key, version, installed_version))
    if incorrect:
        raise AssertionError(
            'Installed requirements do not match requirement files!\n'
            'Rebuild your virtualenv:\n{}'.format(
                ''.join(
                    ' - ({}) {}=={} (installed) {}=={}\n'.format(
                        filename, pkg, depped, pkg, installed,
                    )
                    for filename, pkg, depped, installed in incorrect
                ),
            ),
        )


@pytest.fixture(autouse=True, scope='session')
def check_requirements_integrity():  # pragma: no cover
    """separated as fixtures are not callable in pytest 4+"""
    _check_requirements_integrity_impl()


def test_no_duplicate_requirements():
    duplicates = []
    for filename in (
        'requirements-minimal.txt',
        'requirements.txt',
        'requirements-dev-minimal.txt',
        'requirements-dev.txt',
    ):
        if not os.path.exists(filename):
            continue
        found = set()
        for req, _ in get_raw_requirements(filename):
            if req.key in found:
                duplicates.append((req.key, filename))
            else:
                found.add(req.key)

    if duplicates:
        raise AssertionError(
            'Requirements appeared more than once in the same file:\n'
            '{}'.format(
                ''.join(
                    '- {} ({})\n'.format(*duplicate)
                    for duplicate in duplicates
                ),
            ),
        )


def test_requirements_pinned():
    raw_requirements = _get_all_raw_requirements()
    if raw_requirements is None:  # pragma: no cover
        pytest.skip('No requirements files found')

    unpinned_requirements = find_unpinned_requirements(raw_requirements)
    if unpinned_requirements:
        raise AssertionError(
            'Unpinned requirements detected!\n\n{}'.format(
                format_unpinned_requirements(unpinned_requirements),
            ),
        )


def get_pinned_versions_from_requirement(requirement):
    expected_pinned = set()
    requirements_to_parse = [requirement]
    already_parsed = {(requirement.key, requirement.extras)}
    while requirements_to_parse:
        req = requirements_to_parse.pop()
        installed_req = installed_things[req.key]
        for sub_requirement in installed_req.requires(req.extras):
            key = (sub_requirement.key, sub_requirement.extras)
            if key not in already_parsed:
                requirements_to_parse.append(sub_requirement)
                already_parsed.add(key)
            try:
                installed = installed_things[sub_requirement.key]
            except KeyError:
                raise AssertionError(
                    'Unmet dependency detected!\n'
                    'Somehow `{}` is not installed!\n'
                    '  (from {})\n'
                    'Are you suffering from '
                    'https://github.com/pypa/pip/issues/3903?'.format(
                        sub_requirement.key,
                        '{}[{}]'.format(req.key, ','.join(req.extras))
                        if req.extras else req.key,
                    ),
                )
            expected_pinned.add(
                '{}=={}'.format(installed.key, installed.version),
            )
    return expected_pinned


def format_versions_on_lines_with_dashes(versions):
    return '\n'.join(
        '\t- {}'.format(req)
        for req in sorted(versions, key=attrgetter('key'))
    )


def _expected_pinned(filename, pin_filename):
    ret = set()
    for req, _ in get_raw_requirements(filename):
        if req.key not in installed_things:
            raise AssertionError(
                'A dependency listed in {} is not installed.\n'
                'Is it missing from {}?\n'
                '\t- {}\n'.format(filename, pin_filename, req.key),
            )
        ret.add('{}=={}'.format(req.key, installed_things[req.key].version))
        ret |= get_pinned_versions_from_requirement(req)
    return ret


def test_top_level_dependencies():
    """Test that top-level requirements (reqs-minimal and reqs-dev-minimal)
    are consistent with the pinned requirements.
    """
    if all(
            not os.path.exists(path) for path in (
                'requirements-minimal.txt',
                'requirements.txt',
                'requirements-dev-minimal.txt',
                'requirements-dev.txt',
            )
    ):  # pragma: no cover
        pytest.skip('No requirements files')

    expected_pinned_prod = _expected_pinned(
        'requirements-minimal.txt', 'requirements.txt',
    )
    environments = [
        (
            expected_pinned_prod,
            'requirements.txt',
            'requirements-minimal.txt',
        ),
    ]

    if os.path.exists('requirements-dev-minimal.txt'):
        expected_pinned_dev = _expected_pinned(
            'requirements-dev-minimal.txt', 'requirements-dev.txt',
        )
        # if there are overlapping prod/dev deps, only list in prod
        # requirements
        expected_pinned_dev -= expected_pinned_prod
        environments.append((
            expected_pinned_dev,
            'requirements-dev.txt',
            'requirements-dev-minimal.txt',
        ))
    else:
        print(
            '\033[93;1m'
            'Warning: check-requirements is *not* checking your dev '
            'dependencies.\n'
            '\033[0m\033[93m'
            'To have your dev dependencies checked, create a file named\n'
            'requirements-dev-minimal.txt listing your minimal dev '
            'dependencies.\n'
            'See https://github.com/Yelp/requirements-tools'
            '\033[0m',
        )

    for expected_pinned, pin_filename, minimal_filename in environments:
        expected_pinned = {parse_requirement(s) for s in expected_pinned}
        if os.path.exists(pin_filename):
            requirements = {
                req for req, _ in get_raw_requirements(pin_filename)
            }
        else:
            requirements = set()

        pinned_but_not_required = requirements - expected_pinned
        required_but_not_pinned = expected_pinned - requirements

        if pinned_but_not_required:
            raise AssertionError(
                'Requirements are pinned in {pin} but are not depended on in {minimal}!\n'  # noqa
                '\n'
                'Usually this happens because you upgraded some other dependency, and now no longer require these.\n'  # noqa
                "If that's the case, you should remove these from {pin}.\n"  # noqa
                'Otherwise, if you *do* need these packages, then add them to {minimal}.\n'  # noqa
                '{}'.format(
                    format_versions_on_lines_with_dashes(
                        pinned_but_not_required,
                    ),
                    pin=pin_filename,
                    minimal=minimal_filename,
                ),
            )

        if required_but_not_pinned:
            raise AssertionError(
                'Dependencies derived from {minimal} are not pinned in '
                '{pin}\n'
                '(Probably need to add something to {pin}):\n'
                '{}'.format(
                    format_versions_on_lines_with_dashes(
                        required_but_not_pinned,
                    ),
                    pin=pin_filename,
                    minimal=minimal_filename,
                ),
            )


def test_no_underscores_all_dashes(requirements_files=REQUIREMENTS_FILES):
    if all(
            not os.path.exists(reqfile)
            for reqfile in requirements_files
    ):  # pragma: no cover
        pytest.skip('No requirements files found')

    for requirement_file in requirements_files:
        if not os.path.exists(requirement_file):
            continue
        for line in get_lines_from_file(requirement_file):
            # ignore the markers for underscore check
            if '_' in line.split(';')[0]:
                raise AssertionError(
                    'Use dashes for package names {}: {}'.format(
                        requirement_file, line,
                    ),
                )


def bold(text):  # pragma: no cover
    if sys.stderr.isatty():
        return '\033[1m{}\033[0m'.format(text)
    else:
        return text


def main():  # pragma: no cover
    print('Checking requirements...')
    # Forces quiet output and overrides pytest.ini
    os.environ['PYTEST_ADDOPTS'] = '-q -s --tb=short'
    return pytest.main([__file__.replace('pyc', 'py')] + sys.argv[1:])


if __name__ == '__main__':
    exit(main())