import os
import shlex
import shutil
import subprocess
import sys
import tempfile
from os import PathLike
from pathlib import Path
from typing import Dict, List, NamedTuple, Optional, Sequence, Union

from .environment import ParsedEnvironment
from .util import (BuildOptions, BuildSelector, NonPlatformWheelError, download,
                   get_build_verbosity_extra_flags, get_pip_script,
                   prepare_command)


def call(args: Union[str, Sequence[Union[str, PathLike]]], env: Optional[Dict[str, str]] = None, cwd: Optional[str] = None, shell: bool = False) -> int:
    # print the command executing for the logs
    if shell:
        print(f'+ {args}')
    else:
        print('+ ' + ' '.join(shlex.quote(str(a)) for a in args))

    return subprocess.check_call(args, env=env, cwd=cwd, shell=shell)


class PythonConfiguration(NamedTuple):
    version: str
    identifier: str
    url: str


def get_python_configurations(build_selector: BuildSelector) -> List[PythonConfiguration]:
    python_configurations = [
        # CPython
        PythonConfiguration(version='2.7', identifier='cp27-macosx_x86_64', url='https://www.python.org/ftp/python/2.7.18/python-2.7.18-macosx10.9.pkg'),
        PythonConfiguration(version='3.5', identifier='cp35-macosx_x86_64', url='https://www.python.org/ftp/python/3.5.4/python-3.5.4-macosx10.6.pkg'),
        PythonConfiguration(version='3.6', identifier='cp36-macosx_x86_64', url='https://www.python.org/ftp/python/3.6.8/python-3.6.8-macosx10.9.pkg'),
        PythonConfiguration(version='3.7', identifier='cp37-macosx_x86_64', url='https://www.python.org/ftp/python/3.7.8/python-3.7.8-macosx10.9.pkg'),
        PythonConfiguration(version='3.8', identifier='cp38-macosx_x86_64', url='https://www.python.org/ftp/python/3.8.4/python-3.8.4-macosx10.9.pkg'),
        PythonConfiguration(version='3.9', identifier='cp39-macosx_x86_64', url='https://www.python.org/ftp/python/3.9.0/python-3.9.0rc1-macosx10.9.pkg'),
        # PyPy
        PythonConfiguration(version='2.7', identifier='pp27-macosx_x86_64', url='https://downloads.python.org/pypy/pypy2.7-v7.3.1-osx64.tar.bz2'),
        PythonConfiguration(version='3.6', identifier='pp36-macosx_x86_64', url='https://downloads.python.org/pypy/pypy3.6-v7.3.1-osx64.tar.bz2'),
    ]

    # skip builds as required
    return [c for c in python_configurations if build_selector(c.identifier)]


SYMLINKS_DIR = Path('/tmp/cibw_bin')


def make_symlinks(installation_bin_path: Path, python_executable: str, pip_executable: str) -> None:
    assert (installation_bin_path / python_executable).exists()

    # Python bin folders on Mac don't symlink `python3` to `python`, and neither
    # does PyPy for `pypy` or `pypy3`, so we do that so `python` and `pip` always
    # point to the active configuration.
    if SYMLINKS_DIR.exists():
        shutil.rmtree(SYMLINKS_DIR)
    SYMLINKS_DIR.mkdir(parents=True)

    (SYMLINKS_DIR / 'python').symlink_to(installation_bin_path / python_executable)
    (SYMLINKS_DIR / 'python-config').symlink_to(installation_bin_path / (python_executable + '-config'))
    (SYMLINKS_DIR / 'pip').symlink_to(installation_bin_path / pip_executable)


def install_cpython(version: str, url: str) -> Path:
    installed_system_packages = subprocess.check_output(['pkgutil', '--pkgs'], universal_newlines=True).splitlines()

    # if this version of python isn't installed, get it from python.org and install
    python_package_identifier = f'org.python.Python.PythonFramework-{version}'
    if python_package_identifier not in installed_system_packages:
        # download the pkg
        download(url, Path('/tmp/Python.pkg'))
        # install
        call(['sudo', 'installer', '-pkg', '/tmp/Python.pkg', '-target', '/'])
        # patch open ssl
        if version == '3.5':
            open_ssl_patch_url = f'https://github.com/mayeut/patch-macos-python-openssl/releases/download/v1.0.2u/patch-macos-python-{version}-openssl-v1.0.2u.tar.gz'
            download(open_ssl_patch_url, Path('/tmp/python-patch.tar.gz'))
            call(['sudo', 'tar', '-C', f'/Library/Frameworks/Python.framework/Versions/{version}/', '-xmf', '/tmp/python-patch.tar.gz'])

    installation_bin_path = Path(f'/Library/Frameworks/Python.framework/Versions/{version}/bin')
    python_executable = 'python3' if version[0] == '3' else 'python'
    pip_executable = 'pip3' if version[0] == '3' else 'pip'
    make_symlinks(installation_bin_path, python_executable, pip_executable)

    return installation_bin_path


def install_pypy(version: str, url: str) -> Path:
    pypy_tar_bz2 = url.rsplit('/', 1)[-1]
    extension = ".tar.bz2"
    assert pypy_tar_bz2.endswith(extension)
    pypy_base_filename = pypy_tar_bz2[:-len(extension)]
    installation_path = Path('/tmp') / pypy_base_filename
    if not installation_path.exists():
        downloaded_tar_bz2 = Path("/tmp") / pypy_tar_bz2
        download(url, downloaded_tar_bz2)
        call(['tar', '-C', '/tmp', '-xf', downloaded_tar_bz2])

    installation_bin_path = installation_path / 'bin'
    python_executable = 'pypy3' if version[0] == '3' else 'pypy'
    pip_executable = 'pip3' if version[0] == '3' else 'pip'
    make_symlinks(installation_bin_path, python_executable, pip_executable)

    return installation_bin_path


def setup_python(python_configuration: PythonConfiguration,
                 dependency_constraint_flags: Sequence[Union[str, PathLike]],
                 environment: ParsedEnvironment) -> Dict[str, str]:
    if python_configuration.identifier.startswith('cp'):
        installation_bin_path = install_cpython(python_configuration.version, python_configuration.url)
    elif python_configuration.identifier.startswith('pp'):
        installation_bin_path = install_pypy(python_configuration.version, python_configuration.url)
    else:
        raise ValueError("Unknown Python implementation")

    env = os.environ.copy()
    env['PATH'] = os.pathsep.join([
        str(SYMLINKS_DIR),
        str(installation_bin_path),
        env['PATH'],
    ])

    # Fix issue with site.py setting the wrong `sys.prefix`, `sys.exec_prefix`,
    # `sys.path`, ... for PyPy: https://foss.heptapod.net/pypy/pypy/issues/3175
    # Also fix an issue with the shebang of installed scripts inside the
    # testing virtualenv- see https://github.com/theacodes/nox/issues/44 and
    # https://github.com/pypa/virtualenv/issues/620
    # Also see https://github.com/python/cpython/pull/9516
    env.pop('__PYVENV_LAUNCHER__', None)
    env = environment.as_dictionary(prev_environment=env)

    # check what version we're on
    call(['which', 'python'], env=env)
    call(['python', '--version'], env=env)
    which_python = subprocess.check_output(['which', 'python'], env=env, universal_newlines=True).strip()
    if which_python != '/tmp/cibw_bin/python':
        print("cibuildwheel: python available on PATH doesn't match our installed instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert python above it.", file=sys.stderr)
        exit(1)

    # install pip & wheel
    call(['python', get_pip_script, *dependency_constraint_flags], env=env, cwd="/tmp")
    assert (installation_bin_path / 'pip').exists()
    call(['which', 'pip'], env=env)
    call(['pip', '--version'], env=env)
    which_pip = subprocess.check_output(['which', 'pip'], env=env, universal_newlines=True).strip()
    if which_pip != '/tmp/cibw_bin/pip':
        print("cibuildwheel: pip available on PATH doesn't match our installed instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert pip above it.", file=sys.stderr)
        exit(1)
    call(['pip', 'install', '--upgrade', 'setuptools', 'wheel', 'delocate', *dependency_constraint_flags], env=env)

    # Set MACOSX_DEPLOYMENT_TARGET to 10.9, if the user didn't set it.
    # CPython 3.5 defaults to 10.6, and pypy defaults to 10.7, causing
    # inconsistencies if it's left unset.
    env.setdefault('MACOSX_DEPLOYMENT_TARGET', '10.9')

    if python_configuration.version == '3.5':
        # Cross-compilation platform override - CPython 3.5 has an
        # i386/x86_64 version of Python, but we only want a x64_64 build
        env.setdefault('_PYTHON_HOST_PLATFORM', 'macosx-10.9-x86_64')
        # https://github.com/python/cpython/blob/a5ed2fe0eedefa1649aa93ee74a0bafc8e628a10/Lib/_osx_support.py#L260
        env.setdefault('ARCHFLAGS', '-arch x86_64')

    return env


def build(options: BuildOptions) -> None:
    temp_dir = Path(tempfile.mkdtemp(prefix='cibuildwheel'))
    built_wheel_dir = temp_dir / 'built_wheel'
    repaired_wheel_dir = temp_dir / 'repaired_wheel'

    if options.before_all:
        env = options.environment.as_dictionary(prev_environment=os.environ)
        before_all_prepared = prepare_command(options.before_all, project='.', package=options.package_dir)
        call([before_all_prepared], shell=True, env=env)

    python_configurations = get_python_configurations(options.build_selector)

    for config in python_configurations:
        dependency_constraint_flags: Sequence[Union[str, PathLike]] = []
        if options.dependency_constraints:
            dependency_constraint_flags = [
                '-c', options.dependency_constraints.get_for_python_version(config.version)
            ]

        env = setup_python(config, dependency_constraint_flags, options.environment)

        # run the before_build command
        if options.before_build:
            before_build_prepared = prepare_command(options.before_build, project='.', package=options.package_dir)
            call(before_build_prepared, env=env, shell=True)

        # build the wheel
        if built_wheel_dir.exists():
            shutil.rmtree(built_wheel_dir)
        built_wheel_dir.mkdir(parents=True)

        # Path.resolve() is needed. Without it pip wheel may try to fetch package from pypi.org
        # see https://github.com/joerick/cibuildwheel/pull/369
        call([
            'pip', 'wheel',
            options.package_dir.resolve(),
            '-w', built_wheel_dir,
            '--no-deps',
            *get_build_verbosity_extra_flags(options.build_verbosity)
        ], env=env)

        built_wheel = next(built_wheel_dir.glob('*.whl'))

        # repair the wheel
        if repaired_wheel_dir.exists():
            shutil.rmtree(repaired_wheel_dir)
        repaired_wheel_dir.mkdir(parents=True)

        if built_wheel.name.endswith('none-any.whl'):
            raise NonPlatformWheelError()

        if options.repair_command:
            repair_command_prepared = prepare_command(options.repair_command, wheel=built_wheel, dest_dir=repaired_wheel_dir)
            call(repair_command_prepared, env=env, shell=True)
        else:
            shutil.move(str(built_wheel), repaired_wheel_dir)

        repaired_wheel = next(repaired_wheel_dir.glob('*.whl'))

        if options.test_command:
            # set up a virtual environment to install and test from, to make sure
            # there are no dependencies that were pulled in at build time.
            call(['pip', 'install', 'virtualenv', *dependency_constraint_flags], env=env)
            venv_dir = Path(tempfile.mkdtemp())

            # Use --no-download to ensure determinism by using seed libraries
            # built into virtualenv
            call(['python', '-m', 'virtualenv', '--no-download', venv_dir], env=env)

            virtualenv_env = env.copy()
            virtualenv_env['PATH'] = os.pathsep.join([
                str(venv_dir / 'bin'),
                virtualenv_env['PATH'],
            ])

            # check that we are using the Python from the virtual environment
            call(['which', 'python'], env=virtualenv_env)

            if options.before_test:
                before_test_prepared = prepare_command(options.before_test, project='.', package=options.package_dir)
                call(before_test_prepared, env=virtualenv_env, shell=True)

            # install the wheel
            call(['pip', 'install', str(repaired_wheel) + options.test_extras], env=virtualenv_env)

            # test the wheel
            if options.test_requires:
                call(['pip', 'install'] + options.test_requires, env=virtualenv_env)

            # run the tests from $HOME, with an absolute path in the command
            # (this ensures that Python runs the tests against the installed wheel
            # and not the repo code)
            test_command_prepared = prepare_command(
                options.test_command,
                project=Path('.').resolve(),
                package=options.package_dir.resolve()
            )
            call(test_command_prepared, cwd=os.environ['HOME'], env=virtualenv_env, shell=True)

            # clean up
            shutil.rmtree(venv_dir)

        # we're all done here; move it to output (overwrite existing)
        shutil.move(str(repaired_wheel), options.output_dir)
