"""Generates a python package from auto-generated gRPC python source protocols"""
import subprocess
from pathlib import Path
import sys
import re
import warnings
import shutil
import os
import glob
from datetime import datetime
import tempfile
import string
import random


def random_string(stringLength=10):
    """Generate a random string of fixed length """
    letters = string.ascii_lowercase
    return ''.join(random.choice(letters) for i in range(stringLength))


def random_tmp_path():
    """Create a temporary path within the temp directory"""
    tmpdir = tempfile.gettempdir()
    rndpath = os.path.join(tmpdir, random_string(10))
    os.mkdir(rndpath)
    return rndpath


def parse_version(version):
    """Expects a version string like "0.2.0" and returns a tuple of ints"""
    try:
        ver_maj, ver_min, ver_pat = [int(val) for val in version.split('.')]
    except (ValueError, AttributeError):
        raise ValueError('Invalid version string "%s".  ' % str(version) +
                         'Needs to be "major.minor.patch" like "0.1.0"')
    return ver_maj, ver_min, ver_pat


# generic readme
def readme_text(package_name, version_str):
    now_str = datetime.now().strftime("%H:%M:%S on %d %B %Y")
    return f"""### {package_name} gRPC Interface Package

This Python package contains the auto-generated gRPC python interface files.

Version: {version_str}
Auto-generated on: {now_str}

"""

def setup_text(name, package_name, version):
    now_str = datetime.now().strftime("%H:%M:%S on %d %B %Y")
    return f"""\"""Installation file for the {name} package\"""

import os
from setuptools import setup

install_requires = ['grpcio',
                    'google-api-python-client',
                    'protobuf']

# Get the long description from the README file
HERE = os.path.abspath(os.path.dirname(__file__))
with open(os.path.join(HERE, 'README.rst'), encoding='utf-8') as f:
    long_description = f.read()

desc = 'Autogenerated python gRPC interface package for {name} built on {now_str}'

setup(
    name='{name}',
    packages=['{package_name}'],
    version='{version}',
    license='MIT',
    url='https://github.com/pyansys/',
    author='Ansys Inc.',
    author_email='support@ansys.com',
    description=desc,
    long_description=long_description,
    long_description_content_type='text/markdown',
    python_requires='>=3.5.*',
    install_requires=install_requires,
)
"""


def version_file_text(package_name, version):
    return f"""\"""{package_name} python protocol version\"""
__version__ = '{version[0]}.{version[1]}.{version[2]}'  # major.minor.patch
"""


def construct_package_name(path):
    """Convert a directory containing the protos files to a valid
    python package name.

    Parameters
    ----------
    path : str
        This is the path containing protofiles.  For example
        ``'proto-samples/ansys/api/sample/v1'``.  May be relative or
        absolute.

    Returns
    -------
    str
        Name of the package separated by dashes.  This name will be used on pypi.
    str
        Name of the package separated by dots.  This name will be used
        by python on import and in the ``setup.py``
    list
        Directory structure of the package.

    Examples
    --------
    >>> name, package_name, path = construct_package_name('proto-samples/ansys/api/sample/v1')
    >>> name
    ansys-api-sample-v1
    >>> package_name
    ansys.api.sample.v1
    >>> path
    ['ansys', 'api', 'sample', 'v1']

    """
    # Split path into directories and reverse
    split_path = os.path.normpath(path).split(os.sep)[::-1]

    # verify that "ansys" exists as this is required by our standards.
    if 'ansys' not in split_path:
        raise ValueError(f'Protos path "{path}" is missing the required "ansys" directory')
    package_items = split_path[:split_path.index('ansys') + 1]
    if not (''.join(package_items)).lower():
        raise ValueError('Make file and directory names lowercase.  See:\n'
                         'https://developers.google.com/style/filenames')

    if len(package_items) != 4:
        sub_proto_path = '/'.join(package_items[::-1])
        raise ValueError('Expected a directory structure containing 4 items like:\n'
                         '\tansys/api/<service>/v1\n\n'
                         f'Got:\n\t{sub_proto_path}')

    # at this point, we should be ['v1', '<service>', 'api', 'ansys']
    # validate top level contains a "vX"
    ver_tag = package_items[0]
    if ver_tag[0] != 'v':
        raise ValueError(f'Top level should be a version tag similar to "v1", not "{ver_tag}")')
    ver = ver_tag.split('v')[-1]
    if not ver.isnumeric():
        raise ValueError(f'Top level should be a version tag similar to "v1", not "{ver_tag}")')

    name = '-'.join(package_items[::-1])
    package = '.'.join(package_items[::-1])
    return name, package, package_items[::-1]


def build_python_grpc(protos_path):
    """Builds *.py source interface files given a path containing *.protos files"""
    os.environ['PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION'] = 'cpp'
    rndpath = random_tmp_path()

    # check for protos at the protos path
    proto_glob = os.path.join(protos_path, '*.proto')
    files = glob.glob(proto_glob, recursive=True)
    if not files:
        raise FileNotFoundError(f'Unable locate any *.proto files at {protos_path}')

    # verify proto tools are installed
    try:
        import grpc_tools
    except ImportError:
        raise ImportError('Missing ``grpcio-tools`` package.\n'
                          'Install with `pip install grpcio-tools`')

    cmd = f'python -m grpc_tools.protoc -I{protos_path} '
    cmd += f'--python_out={rndpath} --grpc_python_out={rndpath} {proto_glob}'

    if os.system(cmd):
        raise RuntimeError(f'Failed to run:\n\n{cmd}')

    # verify something was built
    files = glob.glob(os.path.join(rndpath, '*.py'), recursive=True)
    if not files:
        raise RuntimeError(f'No python source generated at {rndpath}')

    return rndpath


def package_protos(protos_path, dist_dir, wheel=False):
    """Package auto-generated grpc python client protocols.

    Parameters
    ----------
    protos_path : str
        This is the path containing protofiles.  For example
        ``'proto-samples/ansys/api/sample/v1'``

    dist_dir : str, optional
        Destination directory of the package.  If ``None``, defaults
        to a new 'dist' directory in the current working directory.

    wheel : bool, optional
        Output a wheel instead of a source distribution.

    Examples
    --------
    >>> package_grpc('proto-samples/ansys/api/sample/v1')
    """
    # validate path exists
    if not os.path.isdir(protos_path):
        raise FileNotFoundError(f'Unable to find path {protos_path}')

    version_file = os.path.join(protos_path, 'VERSION')
    if not os.path.isfile(version_file):
        raise FileNotFoundError(f'VERSION file not found at {version_file}')

    with open(version_file) as f:
        version_str = f.read().strip()
        version = parse_version(version_str)

    # compile the protocols
    grpc_source_path = build_python_grpc(protos_path)

    # check valid package name
    name, package_name, package_paths = construct_package_name(protos_path)

    # grab all module names and source
    py_glob = os.path.join(grpc_source_path, '*.py')
    grpc_source_files = glob.glob(py_glob, recursive=True)
    py_source = {}
    for filename in grpc_source_files:
        relative_path = filename.replace(grpc_source_path, '')
        module_name = '.'.join(re.split(r'\\|/', relative_path))
        module_name = module_name.replace('.py', '')
        module_name = module_name.strip('.')
        py_source[module_name] = open(filename).read()

    # Replace all imports for each module with an absolute import with
    # the new full module name

    # For example
    # import base_pb2 as base__pb2
    # becomes...
    # import ansys.grpc.dpf.base_pb2 as base__pb2
    packages = set()
    for relative_module_name in py_source:

        # NOTE: This is commented out as we do not support nested services (yet)
        # module may be a nested package
        # if '.' in relative_module_name:
        #     package_paths = relative_module_name.split('.')
        #     module_name = package_paths[-1]
        #     parsed_package = package_name.replace('-', '.')
        #     this_package_name = '%s.%s' % (parsed_package, *package_paths[:-1])
        # else:

        # module on the root level
        module_name = relative_module_name
        find_str = 'import %s' % module_name
        repl_str = 'import %s.%s' % (package_name, module_name)
        packages.add(package_name)

        # search through all modules
        for mod_name, mod_source in py_source.items():
            py_source[mod_name] = mod_source.replace(find_str, repl_str)

    # create a temp directory for the python module
    package_path = random_tmp_path()

    # write setup file
    setup_file_path = os.path.join(package_path, 'setup.py')
    with open(setup_file_path, 'w') as f:
        packages_str = ', '.join(["'%s'" % package for package in packages])
        f.write(setup_text(name, package_name, version_str))

    # write readme file
    readme_file_path = os.path.join(package_path, 'README.rst')
    with open(readme_file_path, 'w') as f:
        f.write(readme_text(package_name, version_str))

    # create package source dir
    package_source_path = os.path.join(package_path, *package_paths)
    os.makedirs(package_source_path)

    # write init file
    init_file_path = os.path.join(package_source_path, '__init__.py')
    with open(init_file_path, 'w') as f:
        f.write(f'from ._version import __version__')

    # write version file
    ver_file_path = os.path.join(package_source_path, '_version.py')
    with open(ver_file_path, 'w') as f:
        f.write(version_file_text(package_name, version))

    # write python source
    for module_name, module_source in py_source.items():
        relative_module_path = module_name.split('.')
        relative_module_path[-1] = '%s.py' % relative_module_path[-1]
        filename = os.path.join(package_source_path, *relative_module_path)

        # path may not exist
        module_dir = os.path.dirname(filename)
        if not os.path.isdir(module_dir):
            os.makedirs(module_dir)
            # must create an init file in new directory
            Path(os.path.join(module_dir, '__init__.py')).touch()

        with open(filename, 'w') as f:
            f.write(module_source)

    if wheel:
        btype = 'bdist_wheel'
    else:
        btype = 'sdist'

    p = subprocess.Popen(f"{sys.executable} setup.py {btype}",
                         stdout=subprocess.PIPE,
                         shell=True,
                         cwd=package_path)
    print(p.stdout.read().decode())

    if dist_dir is None:
        dist_dir = os.path.join(os.getcwd(), 'dist')
    if not os.path.isdir(dist_dir):
        os.makedirs(dist_dir)

    if wheel:
        whl_glob = os.path.join(package_path, 'dist', '*.whl')
        whl_files = glob.glob(whl_glob)
        if not whl_files:
            raise FileNotFoundError(f'Wheel not created at {package_path}')
        if len(whl_files) != 1:
            raise RuntimeError('Multiple wheel files generated')
        dist_file = whl_files[0]
    else:
        dist_glob = os.path.join(package_path, 'dist', '*.tar.gz')
        dist_files = glob.glob(dist_glob)
        if not dist_files:
            raise FileNotFoundError(f'Source distribution not created at {package_path}.  Setup failed.')
        if len(dist_files) != 1:
            raise RuntimeError('Source distribution files generated')
        dist_file = dist_files[0]

    # have twine validate
    p = subprocess.Popen(f"{sys.executable} -m twine check {dist_file}",
                     stdout=subprocess.PIPE,
                     shell=True,
                     cwd=package_path)
    output = p.stdout.read().decode()
    if not 'PASSED' in output:
        raise RuntimeError('Failed twine validation check')
    if 'warnings' in output:
        warnings.warn(output)

    tgt_pth = os.path.join(dist_dir, os.path.basename(dist_file))
    final_pth = shutil.move(dist_file, tgt_pth)
    print(f'Built python package for {package_name} at:\n{final_pth}')

    return final_pth
