"""
Create a wheel (.whl) distribution.

A wheel is a built archive format.
"""

import csv
import hashlib
import os
import subprocess
import warnings
import shutil
import json
import sys
import re

try:
    import sysconfig
except ImportError:  # pragma nocover
    # Python < 2.7
    import distutils.sysconfig as sysconfig

import pkg_resources

safe_name = pkg_resources.safe_name
safe_version = pkg_resources.safe_version

from shutil import rmtree
from email.generator import Generator

from distutils.core import Command
from distutils.sysconfig import get_python_version

from distutils import log as logger

from .pep425tags import get_abbr_impl, get_impl_ver, get_abi_tag, get_platform
from .util import native, open_for_csv
from .archive import archive_wheelfile
from .pkginfo import read_pkg_info, write_pkg_info
from .metadata import pkginfo_to_dict
from . import pep425tags, metadata
from . import __version__ as wheel_version

PY_LIMITED_API_PATTERN = r'cp3\d'

def safer_name(name):
    return safe_name(name).replace('-', '_')

def safer_version(version):
    return safe_version(version).replace('-', '_')

class bdist_wheel(Command):

    description = 'create a wheel distribution'

    user_options = [('bdist-dir=', 'b',
                     "temporary directory for creating the distribution"),
                    ('plat-name=', 'p',
                     "platform name to embed in generated filenames "
                     "(default: %s)" % get_platform()),
                    ('keep-temp', 'k',
                     "keep the pseudo-installation tree around after " +
                     "creating the distribution archive"),
                    ('dist-dir=', 'd',
                     "directory to put final built distributions in"),
                    ('skip-build', None,
                     "skip rebuilding everything (for testing/debugging)"),
                    ('relative', None,
                     "build the archive using relative paths"
                     "(default: false)"),
                    ('owner=', 'u',
                     "Owner name used when creating a tar file"
                     " [default: current user]"),
                    ('group=', 'g',
                     "Group name used when creating a tar file"
                     " [default: current group]"),
                    ('universal', None,
                     "make a universal wheel"
                     " (default: false)"),
                    ('python-tag=', None,
                     "Python implementation compatibility tag"
                     " (default: py%s)" % get_impl_ver()[0]),
                    ('py-limited-api=', None,
                     "Python tag (cp32|cp33|cpNN) for abi3 wheel tag"
                     " (default: false)"),
                    ]

    boolean_options = ['keep-temp', 'skip-build', 'relative', 'universal']

    def initialize_options(self):
        self.bdist_dir = None
        self.data_dir = None
        self.plat_name = None
        self.plat_tag = None
        self.format = 'zip'
        self.keep_temp = False
        self.dist_dir = None
        self.distinfo_dir = None
        self.egginfo_dir = None
        self.root_is_pure = None
        self.skip_build = None
        self.relative = False
        self.owner = None
        self.group = None
        self.universal = False
        self.python_tag = 'py' + get_impl_ver()[0]
        self.py_limited_api = False
        self.plat_name_supplied = False

    def finalize_options(self):
        if self.bdist_dir is None:
            bdist_base = self.get_finalized_command('bdist').bdist_base
            self.bdist_dir = os.path.join(bdist_base, 'wheel')

        self.data_dir = self.wheel_dist_name + '.data'
        self.plat_name_supplied = self.plat_name is not None

        need_options = ('dist_dir', 'plat_name', 'skip_build')

        self.set_undefined_options('bdist',
                                   *zip(need_options, need_options))

        self.root_is_pure = not (self.distribution.has_ext_modules()
                                 or self.distribution.has_c_libraries())

        if self.py_limited_api and not re.match(PY_LIMITED_API_PATTERN, self.py_limited_api):
            raise ValueError("py-limited-api must match '%s'" % PY_LIMITED_API_PATTERN)

        # Support legacy [wheel] section for setting universal
        wheel = self.distribution.get_option_dict('wheel')
        if 'universal' in wheel:
            # please don't define this in your global configs
            val = wheel['universal'][1].strip()
            if val.lower() in ('1', 'true', 'yes'):
                self.universal = True

    @property
    def wheel_dist_name(self):
        """Return distribution full name with - replaced with _"""
        return '-'.join((safer_name(self.distribution.get_name()),
                         safer_version(self.distribution.get_version())))

    def get_tag(self):
        # bdist sets self.plat_name if unset, we should only use it for purepy
        # wheels if the user supplied it.
        if self.plat_name_supplied:
            plat_name = self.plat_name
        elif self.root_is_pure:
            plat_name = 'any'
        else:
            plat_name = self.plat_name or get_platform()
            if plat_name in ('linux-x86_64', 'linux_x86_64') and sys.maxsize == 2147483647:
                plat_name = 'linux_i686'
        plat_name = plat_name.replace('-', '_').replace('.', '_')


        if self.root_is_pure:
            if self.universal:
                impl = 'py2.py3'
            else:
                impl = self.python_tag
            tag = (impl, 'none', plat_name)
        else:
            impl_name = get_abbr_impl()
            impl_ver = get_impl_ver()
            impl = impl_name + impl_ver
            # We don't work on CPython 3.1, 3.0.
            if self.py_limited_api and (impl_name + impl_ver).startswith('cp3'):
                impl = self.py_limited_api
                abi_tag = 'abi3'
            else:
                abi_tag = str(get_abi_tag()).lower()
            tag = (impl, abi_tag, plat_name)
            supported_tags = pep425tags.get_supported(
                supplied_platform=plat_name if self.plat_name_supplied else None)
            # XXX switch to this alternate implementation for non-pure:
            if not self.py_limited_api:
                assert tag == supported_tags[0], "%s != %s" % (tag, supported_tags[0])
            assert tag in supported_tags, "would build wheel with unsupported tag %s" % tag
        return tag

    def get_archive_basename(self):
        """Return archive name without extension"""

        impl_tag, abi_tag, plat_tag = self.get_tag()

        archive_basename = "%s-%s-%s-%s" % (
            self.wheel_dist_name,
            impl_tag,
            abi_tag,
            plat_tag)
        return archive_basename

    def run(self):
        build_scripts = self.reinitialize_command('build_scripts')
        build_scripts.executable = 'python'

        if not self.skip_build:
            self.run_command('build')

        install = self.reinitialize_command('install',
                                            reinit_subcommands=True)
        install.root = self.bdist_dir
        install.compile = False
        install.skip_build = self.skip_build
        install.warn_dir = False

        # A wheel without setuptools scripts is more cross-platform.
        # Use the (undocumented) `no_ep` option to setuptools'
        # install_scripts command to avoid creating entry point scripts.
        install_scripts = self.reinitialize_command('install_scripts')
        install_scripts.no_ep = True

        # Use a custom scheme for the archive, because we have to decide
        # at installation time which scheme to use.
        for key in ('headers', 'scripts', 'data', 'purelib', 'platlib'):
            setattr(install,
                    'install_' + key,
                    os.path.join(self.data_dir, key))

        basedir_observed = ''

        if os.name == 'nt':
            # win32 barfs if any of these are ''; could be '.'?
            # (distutils.command.install:change_roots bug)
            basedir_observed = os.path.normpath(os.path.join(self.data_dir, '..'))
            self.install_libbase = self.install_lib = basedir_observed

        setattr(install,
                'install_purelib' if self.root_is_pure else 'install_platlib',
                basedir_observed)

        logger.info("installing to %s", self.bdist_dir)

        self.run_command('install')

        archive_basename = self.get_archive_basename()

        pseudoinstall_root = os.path.join(self.dist_dir, archive_basename)
        if not self.relative:
            archive_root = self.bdist_dir
        else:
            archive_root = os.path.join(
                self.bdist_dir,
                self._ensure_relative(install.install_base))

        self.set_undefined_options(
            'install_egg_info', ('target', 'egginfo_dir'))
        self.distinfo_dir = os.path.join(self.bdist_dir,
                                         '%s.dist-info' % self.wheel_dist_name)
        self.egg2dist(self.egginfo_dir,
                      self.distinfo_dir)

        self.write_wheelfile(self.distinfo_dir)

        self.write_record(self.bdist_dir, self.distinfo_dir)

        # Make the archive
        if not os.path.exists(self.dist_dir):
            os.makedirs(self.dist_dir)
        wheel_name = archive_wheelfile(pseudoinstall_root, archive_root)

        # Sign the archive
        if 'WHEEL_TOOL' in os.environ:
            subprocess.call([os.environ['WHEEL_TOOL'], 'sign', wheel_name])

        # Add to 'Distribution.dist_files' so that the "upload" command works
        getattr(self.distribution, 'dist_files', []).append(
            ('bdist_wheel', get_python_version(), wheel_name))

        if not self.keep_temp:
            if self.dry_run:
                logger.info('removing %s', self.bdist_dir)
            else:
                rmtree(self.bdist_dir)

    def write_wheelfile(self, wheelfile_base, generator='bdist_wheel (' + wheel_version + ')'):
        from email.message import Message
        msg = Message()
        msg['Wheel-Version'] = '1.0'  # of the spec
        msg['Generator'] = generator
        msg['Root-Is-Purelib'] = str(self.root_is_pure).lower()

        # Doesn't work for bdist_wininst
        impl_tag, abi_tag, plat_tag = self.get_tag()
        for impl in impl_tag.split('.'):
            for abi in abi_tag.split('.'):
                for plat in plat_tag.split('.'):
                    msg['Tag'] = '-'.join((impl, abi, plat))

        wheelfile_path = os.path.join(wheelfile_base, 'WHEEL')
        logger.info('creating %s', wheelfile_path)
        with open(wheelfile_path, 'w') as f:
            Generator(f, maxheaderlen=0).flatten(msg)

    def _ensure_relative(self, path):
        # copied from dir_util, deleted
        drive, path = os.path.splitdrive(path)
        if path[0:1] == os.sep:
            path = drive + path[1:]
        return path

    def _pkginfo_to_metadata(self, egg_info_path, pkginfo_path):
        return metadata.pkginfo_to_metadata(egg_info_path, pkginfo_path)

    def license_file(self):
        """Return license filename from a license-file key in setup.cfg, or None."""
        metadata = self.distribution.get_option_dict('metadata')
        if not 'license_file' in metadata:
            return None
        return metadata['license_file'][1]

    def setupcfg_requirements(self):
        """Generate requirements from setup.cfg as
        ('Requires-Dist', 'requirement; qualifier') tuples. From a metadata
        section in setup.cfg:

        [metadata]
        provides-extra = extra1
            extra2
        requires-dist = requirement; qualifier
            another; qualifier2
            unqualified

        Yields

        ('Provides-Extra', 'extra1'),
        ('Provides-Extra', 'extra2'),
        ('Requires-Dist', 'requirement; qualifier'),
        ('Requires-Dist', 'another; qualifier2'),
        ('Requires-Dist', 'unqualified')
        """
        metadata = self.distribution.get_option_dict('metadata')

        # our .ini parser folds - to _ in key names:
        for key, title in (('provides_extra', 'Provides-Extra'),
                           ('requires_dist', 'Requires-Dist')):
            if not key in metadata:
                continue
            field = metadata[key]
            for line in field[1].splitlines():
                line = line.strip()
                if not line:
                    continue
                yield (title, line)

    def add_requirements(self, metadata_path):
        """Add additional requirements from setup.cfg to file metadata_path"""
        additional = list(self.setupcfg_requirements())
        if not additional: return
        pkg_info = read_pkg_info(metadata_path)
        if 'Provides-Extra' in pkg_info or 'Requires-Dist' in pkg_info:
            warnings.warn('setup.cfg requirements overwrite values from setup.py')
            del pkg_info['Provides-Extra']
            del pkg_info['Requires-Dist']
        for k, v in additional:
            pkg_info[k] = v
        write_pkg_info(metadata_path, pkg_info)

    def egg2dist(self, egginfo_path, distinfo_path):
        """Convert an .egg-info directory into a .dist-info directory"""
        def adios(p):
            """Appropriately delete directory, file or link."""
            if os.path.exists(p) and not os.path.islink(p) and os.path.isdir(p):
                shutil.rmtree(p)
            elif os.path.exists(p):
                os.unlink(p)

        adios(distinfo_path)

        if not os.path.exists(egginfo_path):
            # There is no egg-info. This is probably because the egg-info
            # file/directory is not named matching the distribution name used
            # to name the archive file. Check for this case and report
            # accordingly.
            import glob
            pat = os.path.join(os.path.dirname(egginfo_path), '*.egg-info')
            possible = glob.glob(pat)
            err = "Egg metadata expected at %s but not found" % (egginfo_path,)
            if possible:
                alt = os.path.basename(possible[0])
                err += " (%s found - possible misnamed archive file?)" % (alt,)

            raise ValueError(err)

        if os.path.isfile(egginfo_path):
            # .egg-info is a single file
            pkginfo_path = egginfo_path
            pkg_info = self._pkginfo_to_metadata(egginfo_path, egginfo_path)
            os.mkdir(distinfo_path)
        else:
            # .egg-info is a directory
            pkginfo_path = os.path.join(egginfo_path, 'PKG-INFO')
            pkg_info = self._pkginfo_to_metadata(egginfo_path, pkginfo_path)

            # ignore common egg metadata that is useless to wheel
            shutil.copytree(egginfo_path, distinfo_path,
                            ignore=lambda x, y: set(('PKG-INFO',
                                                     'requires.txt',
                                                     'SOURCES.txt',
                                                     'not-zip-safe',)))

            # delete dependency_links if it is only whitespace
            dependency_links_path = os.path.join(distinfo_path, 'dependency_links.txt')
            with open(dependency_links_path, 'r') as dependency_links_file:
                dependency_links = dependency_links_file.read().strip()
            if not dependency_links:
                adios(dependency_links_path)

        write_pkg_info(os.path.join(distinfo_path, 'METADATA'), pkg_info)

        # XXX deprecated. Still useful for current distribute/setuptools.
        metadata_path = os.path.join(distinfo_path, 'METADATA')
        self.add_requirements(metadata_path)

        # XXX intentionally a different path than the PEP.
        metadata_json_path = os.path.join(distinfo_path, 'metadata.json')
        pymeta = pkginfo_to_dict(metadata_path,
                                 distribution=self.distribution)

        if 'description' in pymeta:
            description_filename = 'DESCRIPTION.rst'
            description_text = pymeta.pop('description')
            description_path = os.path.join(distinfo_path,
                                            description_filename)
            with open(description_path, "wb") as description_file:
                description_file.write(description_text.encode('utf-8'))
            pymeta['extensions']['python.details']['document_names']['description'] = description_filename

        # XXX heuristically copy any LICENSE/LICENSE.txt?
        license = self.license_file()
        if license:
            license_filename = 'LICENSE.txt'
            shutil.copy(license, os.path.join(self.distinfo_dir, license_filename))
            pymeta['extensions']['python.details']['document_names']['license'] = license_filename

        with open(metadata_json_path, "w") as metadata_json:
            json.dump(pymeta, metadata_json, sort_keys=True)

        adios(egginfo_path)

    def write_record(self, bdist_dir, distinfo_dir):
        from .util import urlsafe_b64encode

        record_path = os.path.join(distinfo_dir, 'RECORD')
        record_relpath = os.path.relpath(record_path, bdist_dir)

        def walk():
            for dir, dirs, files in os.walk(bdist_dir):
                dirs.sort()
                for f in sorted(files):
                    yield os.path.join(dir, f)

        def skip(path):
            """Wheel hashes every possible file."""
            return (path == record_relpath)

        with open_for_csv(record_path, 'w+') as record_file:
            writer = csv.writer(record_file)
            for path in walk():
                relpath = os.path.relpath(path, bdist_dir)
                if skip(relpath):
                    hash = ''
                    size = ''
                else:
                    with open(path, 'rb') as f:
                        data = f.read()
                    digest = hashlib.sha256(data).digest()
                    hash = 'sha256=' + native(urlsafe_b64encode(digest))
                    size = len(data)
                record_path = os.path.relpath(
                    path, bdist_dir).replace(os.path.sep, '/')
                writer.writerow((record_path, hash, size))
