# -*- coding: utf-8 -*-
# -----------------------------------------------------------------------------
# Copyright 2017 Anaconda, Inc.
#
# All Rights Reserved.
# -----------------------------------------------------------------------------
import codecs
import collections
import email.parser
import hashlib
import json
import os.path
import re
import tarfile
import zipfile

from packaging.requirements import Requirement

from anaconda_enterprise.cli.internal import yaml

# 16 kB - amount of data to read from disk in one go
CHUNK_SIZE = 16384
# The license families that conda-build knows about
# https://github.com/conda/conda-build/blob/933a2178ffcd25ea3e96cc7e5d1904ec6cd9bab8/conda_build/license_family.py#L8
allowed_license_families = """
AGPL
LGPL
GPL3
GPL2
GPL
BSD
MIT
APACHE
PSF
CC
PUBLIC-DOMAIN
PROPRIETARY
OTHER
NONE
""".split()

# How to convert pep241 (Metadata 1.0) licenses to conda license families
pep241_licenses = {
    'BSD': 'BSD',
    'GNU GPL': 'GPL',
    'GNU LGPL': 'LGPL',
    'MIT': 'MIT',
    'public domain': 'PUBLIC-DOMAIN',
    'Python': 'PSF',
    'unknown': 'UNKNOWN',
}

# How to convert pypi license trove classifiers to conda license families
license_mappings = [
    ('License :: CC0 1.0 Universal (CC0 1.0) Public Domain Dedication',
     'PUBLIC-DOMAIN'),
    ('License :: OSI Approved :: Apache Software License', 'APACHE'),
    ('License :: OSI Approved :: BSD License', 'BSD'),
    ('License :: OSI Approved :: GNU Affero General Public License v3',
     'AGPL'),
    ('License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)',
     'AGPL'),
    ('License :: OSI Approved :: GNU General Public License (GPL)', 'GPL'),
    ('License :: OSI Approved :: GNU General Public License v2 (GPLv2)',
     'GPL2'),
    ('License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)',
     'GPL2'),
    ('License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
     'GPL3'),
    ('License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
     'GPL3'),
    ('License :: OSI Approved :: GNU Lesser General Public License v2 (LGPLv2)',
     'LGPL'),
    ('License :: OSI Approved :: GNU Lesser General Public License v2 or later (LGPLv2+)',
     'LGPL'),
    ('License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)',
     'LGPL'),
    ('License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)',
     'LGPL'),
    ('License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)',
     'LGPL'),
    ('License :: OSI Approved :: MIT License', 'MIT'),
    ('License :: OSI Approved :: Python Software Foundation License', 'PSF'),
    ('License :: Other/Proprietary License', 'PROPRIETARY'),
    ('License :: Public Domain', 'PUBLIC-DOMAIN'),
]

# The fields defined in https://packaging.python.org/specifications/core-metadata/
# and prior PEPs as multiple use - meaning they can appear more than once.
multiple_use_fields = """\
platform
supported_platform
classifier
requires
requires_dist
provides_dist
obsoletes
obsoletes_dist
requires_external
project_url
provides
provides_extra""".split()

# The way that requirements in PKG-INFO/METADATA are marked as for "extras"
EXTRA_RE = re.compile('extra == [\'\"](.*)[\'\"]')

# https://www.python.org/dev/peps/pep-0440/#version-scheme
VERSION_PATTERN = r"""
    v?
    (?:
        (?:(?P<epoch>[0-9]+)!)?                           # epoch
        (?P<release>[0-9]+(?:\.[0-9]+)*)                  # release segment
        (?P<pre>                                          # pre-release
            [-_\.]?
            (?P<pre_l>(a|b|c|rc|alpha|beta|pre|preview))
            [-_\.]?
            (?P<pre_n>[0-9]+)?
        )?
        (?P<post>                                         # post release
            (?:-(?P<post_n1>[0-9]+))
            |
            (?:
                [-_\.]?
                (?P<post_l>post|rev|r)
                [-_\.]?
                (?P<post_n2>[0-9]+)?
            )
        )?
        (?P<dev>                                          # dev release
            [-_\.]?
            (?P<dev_l>dev)
            [-_\.]?
            (?P<dev_n>[0-9]+)?
        )?
    )
    (?:\+(?P<local>[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
"""

# https://www.python.org/dev/peps/pep-0508/#names
NAME_PATTERN = r"(?P<name>[A-Z0-9]|[A-Z0-9][A-Z0-9._-]*?[A-Z0-9])"

EXE_PATTERN = r"^" + NAME_PATTERN + r"-(?P<version>" + VERSION_PATTERN + r""")
    \.
    (?P<plat_name>win-amd64|win32)
    (?:-py(?P<python_version>[0-9]\.[0-9]))?
    \.exe$
"""

BDIST_WININST_RE = re.compile(EXE_PATTERN, re.VERBOSE | re.IGNORECASE)


def digest_file(filename):
    size = 0
    md5 = hashlib.md5()
    sha1 = hashlib.sha1()
    sha256 = hashlib.sha256()

    with open(filename, 'rb') as fd:
        for chunk in iter(lambda: fd.read(CHUNK_SIZE), b''):
            size += len(chunk)
            md5.update(chunk)
            sha1.update(chunk)
            sha256.update(chunk)

    return {
        'size': size,
        'md5': md5.hexdigest(),
        'sha1': sha1.hexdigest(),
        'sha256': sha256.hexdigest(),
    }


def parse_conda(filename, ignore_name_validation=False):
    """Open a conda package and extract information from the metada data

    Keywords:
    * filename -- path to the conda package
    * ingnore_name_validation -- if True this will not check that the name
      of the file is the same as declared on the metadata. This is useful
      for importing, since export might change the name of the package.
    """

    if not ignore_name_validation and not filename.endswith('.tar.bz2'):
        raise ValueError('Not a conda package (invalid extension)')

    with tarfile.open(filename, mode="r|bz2") as tf:
        found_info = False
        index_attributes = None
        about_attributes = None
        recipe_attributes = None
        for info in tf:
            is_info = info.name.startswith('info/')
            found_info = found_info or is_info
            if found_info and not is_info:
                break

            if info.name == "info/index.json":
                index = tf.extractfile(info)
                index_attributes = json.loads(index.read().decode())
            elif info.name == 'info/recipe.json':
                # recipe.json is deprecated and only packages built with older
                # versions of conda-build contain that file.
                recipe = tf.extractfile(info)
                recipe = json.loads(recipe.read().decode())
                about_attributes = recipe.pop('about', about_attributes)
            elif info.name == 'info/about.json':
                # about.json replaces recipe.json in newer versions of conda-build
                about = tf.extractfile(info)
                about_attributes = json.loads(about.read().decode())
            elif info.name in [
                    'info/recipe/meta.yaml.rendered', 'info/recipe/meta.yaml',
                    'info/meta.yaml'
            ]:
                try:
                    recipe = tf.extractfile(info)
                    recipe_attributes = yaml.load(recipe.read().decode())
                    if bool(recipe_attributes.get('app', {}).get('icon')):
                        recipe_attributes['has-icon'] = True

                except Exception:
                    # Pass yaml parsing exception.
                    # This exception doesn't affect uploading package
                    pass

        if index_attributes is None:
            raise ValueError('Not a conda package (missing info/index.json)')

        fn = '%(name)s-%(version)s-%(build)s.tar.bz2' % index_attributes
        if fn != os.path.basename(filename) and not ignore_name_validation:
            raise ValueError(
                'package has wrong filename (expected {0})'.format(fn))

    attributes = {
        'subdir': get_subdir(index_attributes),
        'version': index_attributes['version'],
        'filename': fn,
        'index-attributes': index_attributes,
        'about-attributes': about_attributes or {},
        'recipe-attributes': recipe_attributes or {},
    }
    attributes.update(digest_file(filename))
    return attributes


def get_subdir(index):
    try:
        return index['subdir']
    except KeyError:
        plat = index.get('platform')
        arch = index.get('arch')
        if arch is None:
            return 'noarch'
        intel_map = {'x86': '32', 'x86_64': '64'}
        return '%s-%s' % (plat, intel_map.get(arch, arch))


def canonicalize_license(license_name):
    # metadata 1.0 defines a fixed set of licenses:
    return pep241_licenses.get(license_name, 'OTHER')


def canonicalize_license_trove(classifiers):
    for expected_classifier, license in license_mappings:
        if expected_classifier in classifiers:
            return license
    return 'OTHER'


def make_attributes(metadata):
    assert not metadata.is_multipart(), "Unexpected multipart encoded PKG-INFO"
    json_metadata = {}
    # https://www.python.org/dev/peps/pep-0566/#json-compatible-metadata
    for key in metadata.keys():
        canonical_key = key.lower().replace('-', '_')
        if canonical_key == 'keywords':
            value = metadata.get(key).split()
        elif canonical_key in multiple_use_fields:
            value = metadata.get_all(key)
        else:
            value = metadata.get(key)
        json_metadata[canonical_key] = value

    payload = metadata.get_payload()
    if payload:
        json_metadata['description'] = payload

    metadata_version = metadata['Metadata-Version']
    if metadata_version == '1.0':
        license_family = canonicalize_license(metadata['License'])
    else:
        classifiers = json_metadata.get('classifier', [])
        license_family = canonicalize_license_trove(classifiers)

    about_attributes = {
        'license_family': license_family,
    }
    if 'Home-page' in metadata:
        about_attributes['home'] = metadata['Home-page']
    if 'Summary' in metadata:
        about_attributes['summary'] = metadata['Summary']

    index_attributes = {
        'name': json_metadata['name'],
    }
    if 'requires_python' in json_metadata:
        index_attributes['requires_python'] = json_metadata['requires_python']

    attributes = {
        'version': metadata['Version'],
        'index-attributes': index_attributes,
        'about-attributes': about_attributes,
        'pkg-info': json_metadata,
    }
    attributes.update(parse_metadata_requirements(json_metadata))
    return attributes


def safe_extra(extra):
    return re.sub('[^A-Za-z0-9.-]+', '_', extra.strip()).lower()


def safe_name(name):
    return re.sub('[^A-Za-z0-9.]+', '-', name)


def convert_requirements_to_attributes(common, extras):
    requirements = []
    extra_requirements = {}
    for req in common:
        requirements.append({
            'name':
            safe_name(req.name),
            'specs': [(spec.operator, spec.version) for spec in req.specifier],
            'value':
            str(req),
        })

    for extra in extras:
        extra_requirements[extra] = []
        for req in extras[extra]:
            extra_requirements[extra].append({
                'name':
                safe_name(req.name),
                'specs':
                [(spec.operator, spec.version) for spec in req.specifier],
                'value':
                str(req),
            })

    return {
        'requirements': requirements,
        'extra-requirements': extra_requirements,
    }


def yield_lines(strs):
    if isinstance(strs, (list, tuple)):
        for ss in strs:
            for s in yield_lines(ss):
                yield s
    else:
        assert isinstance(strs, str)
        for s in strs.splitlines():
            s = s.strip()
            if s and not s.startswith('#'):
                yield s


def split_sections(s):
    section = None
    content = []
    for line in yield_lines(s):
        if line.startswith("["):
            if line.endswith("]"):
                if section or content:
                    yield section, content
                section = line[1:-1].strip()
                content = []
            else:
                raise ValueError("Invalid section heading", line)
        else:
            content.append(line)
    yield section, content


def parse_requirements(strs):
    lines = iter(yield_lines(strs))
    for line in lines:
        # Drop comments -- a hash without a space may be in a URL.
        if ' #' in line:
            line = line[:line.find(' #')]
        if line.endswith('\\'):
            line = line[:-2].strip()
            try:
                line += next(lines)
            except StopIteration:
                return
        yield Requirement(line)


def parse_requirements_txt(requires_txt):
    sections = split_sections(requires_txt)
    common = []
    extras = collections.defaultdict(list)
    for section_name, content in sections:
        if section_name is None:
            common.extend(parse_requirements(content))
        else:
            extras[safe_extra(section_name)].extend(
                parse_requirements(content))

    return convert_requirements_to_attributes(common, extras)


def parse_metadata_requirements(metadata):
    if metadata['metadata_version'] in ('1.0', '1.1'):
        requires = metadata.get('requires', [])
    else:
        requires = metadata.get('requires_dist', [])

    def reqs_for_extra(extra):
        for req in reqs:
            if not req.marker or req.marker.evaluate({'extra': extra}):
                yield req

    reqs = list(parse_requirements(requires))
    common = frozenset(reqs_for_extra(None))
    extras = collections.defaultdict(list)

    for extra in metadata.get('provides_extra', []):
        extras[safe_extra(extra)].extend(
            frozenset(reqs_for_extra(extra)) - common)

    return convert_requirements_to_attributes(common, extras)


def parse_pypi(filename):
    if filename.endswith('.tar.gz') or filename.endswith('.tar.bz2'):
        attrs = parse_pypi_sdist(filename)
    elif filename.endswith('.whl'):
        attrs = parse_pypi_whl(filename)
    elif filename.endswith('.egg'):
        attrs = parse_pypi_egg(filename)
    elif filename.endswith('.zip'):
        attrs = parse_pypi_zip(filename)
    elif filename.endswith('.exe'):
        attrs = parse_pypi_exe(filename)
    else:
        _, ext = os.path.splitext(filename)
        raise ValueError(
            "Can not inspect pypi package with file extension %s" % ext)

    attrs['filename'] = os.path.basename(filename)
    attrs.update(digest_file(filename))
    return attrs


def parse_pypi_egg(filename):
    parser = email.parser.Parser()
    have_pkg_info = False
    attrs = {}

    with zipfile.ZipFile(filename, "r") as ar:
        for member in ar.infolist():
            if member.filename == 'EGG-INFO/PKG-INFO' and not have_pkg_info:
                with ar.open(member) as metadata:
                    with codecs.getreader('utf-8')(metadata) as wrapper:
                        fields = parser.parse(wrapper)
                attrs.update(make_attributes(fields))
                have_pkg_info = True
            elif member.filename.endswith('EGG-INFO/requires.txt'):
                requires_txt = ar.read(member)
                attrs.update(
                    parse_requirements_txt(requires_txt.decode('utf-8')))

    if not have_pkg_info:
        raise ValueError(
            'Could not parse egg package, missing EGG-INFO/PKG-INFO')
    return attrs


def parse_pypi_zip(filename):
    parser = email.parser.Parser()
    have_pkg_info = False
    attrs = {}

    with zipfile.ZipFile(filename, "r") as ar:
        for member in ar.infolist():
            if member.filename.endswith('/PKG-INFO') and not have_pkg_info:
                with ar.open(member) as metadata:
                    with codecs.getreader('utf-8')(metadata) as wrapper:
                        fields = parser.parse(wrapper)
                attrs.update(make_attributes(fields))
                have_pkg_info = True
            elif member.filename.endswith('.egg-info/requires.txt'):
                requires_txt = ar.read(member)
                attrs.update(
                    parse_requirements_txt(requires_txt.decode('utf-8')))

    if not have_pkg_info:
        raise ValueError('Could not parse sdist package, missing PKG-INFO')
    return attrs


def parse_pypi_sdist(filename):
    parser = email.parser.Parser()
    have_pkg_info = False
    attrs = {}

    with tarfile.open(filename, "r|*") as ar:
        for member in ar:
            if member.name.endswith('/PKG-INFO') and not have_pkg_info:
                metadata = ar.extractfile(member)
                with codecs.getreader('utf-8')(metadata) as wrapper:
                    fields = parser.parse(wrapper)
                attrs.update(make_attributes(fields))
                have_pkg_info = True
            elif member.name.endswith('.egg-info/requires.txt'):
                requires_txt = ar.extractfile(member).read()
                attrs.update(
                    parse_requirements_txt(requires_txt.decode('utf-8')))

    if not have_pkg_info:
        raise ValueError('Could not parse sdist package, missing PKG-INFO')
    return attrs


def parse_pypi_whl(filename):
    parser = email.parser.Parser()

    with zipfile.ZipFile(filename) as ar:
        for member in ar.infolist():
            if member.filename.endswith('.dist-info/METADATA'):
                with ar.open(member) as metadata:
                    with codecs.getreader('utf-8')(metadata) as wrapper:
                        fields = parser.parse(wrapper)
                return make_attributes(fields)

    raise ValueError('Missing .dist-info')


def parse_pypi_exe(filename):
    basename = os.path.basename(filename)
    match = BDIST_WININST_RE.match(basename)
    if match:
        name = match.group('name')
        version = match.group('version')
    else:
        raise ValueError(
            "Could not extract name & version from filename: {}".format(
                basename))

    return {
        'version': version,
        'index-attributes': {
            'name': name
        },
        'about-attributes': {},
        'pkg-info': {},
    }
