import os
import re
import shutil
import itertools
import functools
from os.path import exists, relpath, dirname, basename, abspath, isabs
from os.path import join as pjoin
from subprocess import check_call, check_output, CalledProcessError
from distutils.spawn import find_executable
from typing import Dict, Optional
import logging

from .policy import get_replace_platforms
from .wheeltools import InWheelCtx, add_platforms
from .wheel_abi import get_wheel_elfdata
from .elfutils import elf_read_rpaths, elf_read_dt_needed
from .hashfile import hashfile


logger = logging.getLogger(__name__)


# Copied from wheel 0.31.1
WHEEL_INFO_RE = re.compile(
    r"""^(?P<namever>(?P<name>.+?)-(?P<ver>\d.*?))(-(?P<build>\d.*?))?
     -(?P<pyver>[a-z].+?)-(?P<abi>.+?)-(?P<plat>.+?)(\.whl|\.dist-info)$""",
    re.VERBOSE).match


@functools.lru_cache()
def verify_patchelf():
    """This function looks for the ``patchelf`` external binary in the PATH,
    checks for the required version, and throws an exception if a proper
    version can't be found. Otherwise, silcence is golden
    """
    if not find_executable('patchelf'):
        raise ValueError('Cannot find required utility `patchelf` in PATH')
    try:
        version = check_output(['patchelf', '--version']).decode('utf-8')
    except CalledProcessError:
        raise ValueError('Could not call `patchelf` binary')

    m = re.match(r'patchelf\s+(\d+(.\d+)?)', version)
    if m and tuple(int(x) for x in m.group(1).split('.')) >= (0, 9):
        return
    raise ValueError(('patchelf %s found. auditwheel repair requires '
                      'patchelf >= 0.9.') %
                     version)


def repair_wheel(wheel_path: str, abi: str, lib_sdir: str, out_dir: str,
                 update_tags: bool) -> Optional[str]:

    external_refs_by_fn = get_wheel_elfdata(wheel_path)[1]

    # Do not repair a pure wheel, i.e. has no external refs
    if not external_refs_by_fn:
        return

    soname_map = {}  # type: Dict[str, str]
    if not isabs(out_dir):
        out_dir = abspath(out_dir)

    wheel_fname = basename(wheel_path)

    with InWheelCtx(wheel_path) as ctx:
        ctx.out_wheel = pjoin(out_dir, wheel_fname)

        dest_dir = WHEEL_INFO_RE(wheel_fname).group('name') + lib_sdir

        if not exists(dest_dir):
            os.mkdir(dest_dir)

        # here, fn is a path to a python extension library in
        # the wheel, and v['libs'] contains its required libs
        for fn, v in external_refs_by_fn.items():

            ext_libs = v[abi]['libs']  # type: Dict[str, str]
            for soname, src_path in ext_libs.items():
                if src_path is None:
                    raise ValueError(('Cannot repair wheel, because required '
                                      'library "%s" could not be located') %
                                     soname)

                new_soname, new_path = copylib(src_path, dest_dir)
                soname_map[soname] = (new_soname, new_path)
                check_call(['patchelf', '--replace-needed', soname,
                            new_soname, fn])

            if len(ext_libs) > 0:
                patchelf_set_rpath(fn, dest_dir)

        # we grafted in a bunch of libraries and modified their sonames, but
        # they may have internal dependencies (DT_NEEDED) on one another, so
        # we need to update those records so each now knows about the new
        # name of the other.
        for old_soname, (new_soname, path) in soname_map.items():
            needed = elf_read_dt_needed(path)
            for n in needed:
                if n in soname_map:
                    check_call(['patchelf', '--replace-needed', n,
                                soname_map[n][0], path])

        if update_tags:
            ctx.out_wheel = add_platforms(ctx, [abi],
                                          get_replace_platforms(abi))
    return ctx.out_wheel


def copylib(src_path, dest_dir):
    """Graft a shared library from the system into the wheel and update the
    relevant links.

    1) Copy the file from src_path to dest_dir/
    2) Rename the shared object from soname to soname.<unique>
    3) If the library has a RUNPATH/RPATH, clear it and set RPATH to point to
    its new location.
    """
    # Copy the a shared library from the system (src_path) into the wheel
    # if the library has a RUNPATH/RPATH we clear it and set RPATH to point to
    # its new location.

    with open(src_path, 'rb') as f:
        shorthash = hashfile(f)[:8]

    src_name = os.path.basename(src_path)
    base, ext = src_name.split('.', 1)
    if not base.endswith('-%s' % shorthash):
        new_soname = '%s-%s.%s' % (base, shorthash, ext)
    else:
        new_soname = src_name

    dest_path = os.path.join(dest_dir, new_soname)
    if os.path.exists(dest_path):
        return new_soname, dest_path

    logger.debug('Grafting: %s -> %s', src_path, dest_path)
    rpaths = elf_read_rpaths(src_path)
    shutil.copy2(src_path, dest_path)

    verify_patchelf()
    check_call(['patchelf', '--set-soname', new_soname, dest_path])

    if any(itertools.chain(rpaths['rpaths'], rpaths['runpaths'])):
        patchelf_set_rpath(dest_path, dest_dir)

    return new_soname, dest_path


def patchelf_set_rpath(fn, libdir):
    rpath = pjoin('$ORIGIN', relpath(libdir, dirname(fn)))
    logger.debug('Setting RPATH: %s to "%s"', fn, rpath)
    check_call(['patchelf', '--remove-rpath', fn])
    check_call(['patchelf', '--force-rpath', '--set-rpath', rpath, fn])
