#!/usr/bin/env python
"""Script to create self contained install.

The goal of this script is simple:

  * Create a self contained install of the CLI that
  has requires no external resources during installation.

It does this by using all the normal python tooling
(virtualenv, pip) but provides a simple, easy to use
interface for those not familiar with the python
ecosystem.

"""

import os
import sys
import subprocess
import shutil
import tempfile
import zipfile
from contextlib import contextmanager


EXTRA_RUNTIME_DEPS = [
    # Use an up to date virtualenv/pip/setuptools on > 2.6.
    ('virtualenv', '16.7.8'),
    ('jmespath', '0.10.0'),
]
PINNED_RUNTIME_DEPS = [
    # The CLI has a relaxed pin for colorama, but versions >0.4.5
    # require extra build time dependencies. We are pinning it to
    # a version that does not need those.
    ('colorama', '0.4.5'),
    # 2.0.0 of urllib3 started requiring hatchling as well
    ('urllib3', '1.26.20'),
]
BUILDTIME_DEPS = [
    ('setuptools', '75.4.0'),  # start of >= 3.9
    ('setuptools-scm', '3.3.3'),
    ('wheel', '0.45.1'),  # 0.46.0+ requires packaging
]
PIP_DOWNLOAD_ARGS = '--no-build-isolation --no-binary :all:'


class BadRCError(Exception):
    pass


@contextmanager
def cd(dirname):
    original = os.getcwd()
    os.chdir(dirname)
    try:
        yield
    finally:
        os.chdir(original)


def run(cmd):
    sys.stdout.write(f"Running cmd: {cmd}\n")
    p = subprocess.Popen(
        cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
    )
    stdout, stderr = p.communicate()
    rc = p.wait()
    if p.returncode != 0:
        raise BadRCError(f"Bad rc ({rc}) for cmd '{cmd}': {stderr + stdout}")
    return stdout


def create_scratch_dir():
    # This creates the dir where all the bundling occurs.
    # First we need a top level dir.
    dirname = tempfile.mkdtemp(prefix='bundle')
    # Then we need to create a dir where all the packages
    # will come from.
    os.mkdir(os.path.join(dirname, 'packages'))
    os.mkdir(os.path.join(dirname, 'packages', 'setup'))
    return dirname


def download_package_tarballs(dirname, packages):
    with cd(dirname):
        for package, package_version in packages:
            run(
                f'{sys.executable} -m pip download {package}=={package_version}'
                f' {PIP_DOWNLOAD_ARGS}'
            )


def download_package_wheels(dirname, packages):
    with cd(dirname):
        for package, package_version in packages:
            run(
                f'{sys.executable} -m pip download {package}=={package_version}'
                f' --only-binary :all:'
            )


def validate_that_wheels_are_universal(dirname):
    with cd(dirname):
        for wheel_path in os.listdir():
            if not wheel_path.endswith('py3-none-any.whl'):
                raise ValueError(f'Found a non universal wheel: {wheel_path}')


def download_cli_deps(scratch_dir, packages):
    # pip download will always download a more recent version of a package
    # even if one exists locally. The list of packages supplied in `packages`
    # forces the use of a specific runtime dependency.
    awscli_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
    pinned_packages = " ".join(
        f"{name}=={version}" for (name, version) in packages
    )
    with cd(scratch_dir):
        run(f"pip download {PIP_DOWNLOAD_ARGS} {pinned_packages} {awscli_dir}")


def _remove_cli_zip(scratch_dir):
    clidir = [f for f in os.listdir(scratch_dir) if f.startswith('awscli')]
    assert len(clidir) == 1
    os.remove(os.path.join(scratch_dir, clidir[0]))


def add_cli_sdist(scratch_dir):
    awscli_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
    if os.path.exists(os.path.join(awscli_dir, 'dist')):
        shutil.rmtree(os.path.join(awscli_dir, 'dist'))
    with cd(awscli_dir):
        run(f'{sys.executable} setup.py sdist')
        filename = os.listdir('dist')[0]
        shutil.move(
            os.path.join('dist', filename), os.path.join(scratch_dir, filename)
        )


def create_bootstrap_script(scratch_dir):
    install_script = os.path.join(
        os.path.dirname(os.path.abspath(__file__)), 'install'
    )
    shutil.copy(install_script, os.path.join(scratch_dir, 'install'))


def zip_dir(scratch_dir):
    basename = 'awscli-bundle.zip'
    dirname, tmpdir = os.path.split(scratch_dir)
    final_dir_name = os.path.join(dirname, 'awscli-bundle')
    if os.path.isdir(final_dir_name):
        shutil.rmtree(final_dir_name)
    shutil.move(scratch_dir, final_dir_name)
    with cd(dirname):
        with zipfile.ZipFile(basename, 'w', zipfile.ZIP_DEFLATED) as zipped:
            for root, dirnames, filenames in os.walk('awscli-bundle'):
                for filename in filenames:
                    zipped.write(os.path.join(root, filename))
    return os.path.join(dirname, basename)


def verify_preconditions():
    # The pip version looks like:
    # 'pip 1.4.1 from ....'
    pip_version = run(f'{sys.executable} -m pip --version').strip().split()[1]
    # Virtualenv version just has the version string: '1.14.5\n'
    virtualenv_version = run(
        f'{sys.executable} -m virtualenv --version'
    ).strip()
    _min_version_required('9.0.1', pip_version, 'pip')
    _min_version_required('15.1.0', virtualenv_version, 'virtualenv')


def _min_version_required(min_version, actual_version, name):
    # precondition: min_version is major.minor.patch
    #               actual_version is major.minor.patch
    min_split = min_version.split('.')
    actual_split = actual_version.decode('utf-8').split('.')
    for min_version_part, actual_version_part in zip(min_split, actual_split):
        if int(actual_version_part) >= int(min_version_part):
            return
    raise ValueError(
        f'{name} requires at least version {min_version}, '
        f'but version {actual_version} was found.'
    )


def main():
    verify_preconditions()
    scratch_dir = create_scratch_dir()
    package_dir = os.path.join(scratch_dir, 'packages')
    print(f"Bundle dir at: {scratch_dir}")
    download_package_tarballs(
        package_dir,
        packages=EXTRA_RUNTIME_DEPS,
    )

    # Some packages require setup time dependencies, and so we will need to
    # manually install them. We isolate them to a particular directory so we
    # can run the install before the things they're dependent on. We have to do
    # this because pip won't actually find them since it doesn't handle build
    # dependencies. We use wheels for this, to avoid bootstrapping setuptools
    # in 3.12+ where it's no longer included by default.
    setup_dir = os.path.join(package_dir, 'setup')
    download_package_wheels(
        setup_dir,
        packages=BUILDTIME_DEPS,
    )
    validate_that_wheels_are_universal(setup_dir)
    download_cli_deps(package_dir, packages=PINNED_RUNTIME_DEPS)
    add_cli_sdist(package_dir)
    create_bootstrap_script(scratch_dir)
    zip_filename = zip_dir(scratch_dir)
    print(f"Zipped bundle installer is at: {zip_filename}")


if __name__ == '__main__':
    main()
