# coding: utf-8
from __future__ import unicode_literals, division, absolute_import, print_function

import cgi
import codecs
import coverage
import imp
import json
import os
import unittest
import re
import sys
import tempfile
import time
import platform as _plat
import subprocess
from fnmatch import fnmatch

from . import package_name, package_root, other_packages

if sys.version_info < (3,):
    str_cls = unicode  # noqa
    from urllib2 import URLError
    from urllib import urlencode
    from io import open
else:
    str_cls = str
    from urllib.error import URLError
    from urllib.parse import urlencode

if sys.version_info < (3, 7):
    Pattern = re._pattern_type
else:
    Pattern = re.Pattern


def run(ci=False):
    """
    Runs the tests while measuring coverage

    :param ci:
        If coverage is being run in a CI environment - this triggers trying to
        run the tests for the rest of modularcrypto and uploading coverage data

    :return:
        A bool - if the tests ran successfully
    """

    xml_report_path = os.path.join(package_root, 'coverage.xml')
    if os.path.exists(xml_report_path):
        os.unlink(xml_report_path)

    cov = coverage.Coverage(include='%s/*.py' % package_name)
    cov.start()

    from .tests import run as run_tests
    result = run_tests(ci=ci)
    print()

    if ci:
        suite = unittest.TestSuite()
        loader = unittest.TestLoader()
        for other_package in other_packages:
            for test_class in _load_package_tests(other_package):
                suite.addTest(loader.loadTestsFromTestCase(test_class))

        if suite.countTestCases() > 0:
            print('Running tests from other modularcrypto packages')
            sys.stdout.flush()
            runner_result = unittest.TextTestRunner(stream=sys.stdout, verbosity=1).run(suite)
            result = runner_result.wasSuccessful() and result
            print()
            sys.stdout.flush()

    cov.stop()
    cov.save()

    cov.report(show_missing=False)
    print()
    sys.stdout.flush()
    if ci:
        cov.xml_report()

    if ci and result and os.path.exists(xml_report_path):
        _codecov_submit()
        print()

    return result


def _load_package_tests(name):
    """
    Load the test classes from another modularcrypto package

    :param name:
        A unicode string of the other package name

    :return:
        A list of unittest.TestCase classes of the tests for the package
    """

    package_dir = os.path.join('..', name)
    if not os.path.exists(package_dir):
        return []

    tests_module_info = imp.find_module('tests', [package_dir])
    tests_module = imp.load_module('%s.tests' % name, *tests_module_info)
    return tests_module.test_classes()


def _env_info():
    """
    :return:
        A two-element tuple of unicode strings. The first is the name of the
        environment, the second the root of the repo. The environment name
        will be one of: "ci-travis", "ci-circle", "ci-appveyor",
        "ci-github-actions", "local"
    """

    if os.getenv('CI') == 'true' and os.getenv('TRAVIS') == 'true':
        return ('ci-travis', os.getenv('TRAVIS_BUILD_DIR'))

    if os.getenv('CI') == 'True' and os.getenv('APPVEYOR') == 'True':
        return ('ci-appveyor', os.getenv('APPVEYOR_BUILD_FOLDER'))

    if os.getenv('CI') == 'true' and os.getenv('CIRCLECI') == 'true':
        return ('ci-circle', os.getcwdu() if sys.version_info < (3,) else os.getcwd())

    if os.getenv('GITHUB_ACTIONS') == 'true':
        return ('ci-github-actions', os.getenv('GITHUB_WORKSPACE'))

    return ('local', package_root)


def _codecov_submit():
    env_name, root = _env_info()

    try:
        with open(os.path.join(root, 'dev/codecov.json'), 'rb') as f:
            json_data = json.loads(f.read().decode('utf-8'))
    except (OSError, ValueError, UnicodeDecodeError, KeyError):
        print('error reading codecov.json')
        return

    if json_data.get('disabled'):
        return

    if env_name == 'ci-travis':
        # http://docs.travis-ci.com/user/environment-variables/#Default-Environment-Variables
        build_url = 'https://travis-ci.org/%s/jobs/%s' % (os.getenv('TRAVIS_REPO_SLUG'), os.getenv('TRAVIS_JOB_ID'))
        query = {
            'service': 'travis',
            'branch': os.getenv('TRAVIS_BRANCH'),
            'build': os.getenv('TRAVIS_JOB_NUMBER'),
            'pr': os.getenv('TRAVIS_PULL_REQUEST'),
            'job': os.getenv('TRAVIS_JOB_ID'),
            'tag': os.getenv('TRAVIS_TAG'),
            'slug': os.getenv('TRAVIS_REPO_SLUG'),
            'commit': os.getenv('TRAVIS_COMMIT'),
            'build_url': build_url,
        }

    elif env_name == 'ci-appveyor':
        # http://www.appveyor.com/docs/environment-variables
        build_url = 'https://ci.appveyor.com/project/%s/build/%s' % (
            os.getenv('APPVEYOR_REPO_NAME'),
            os.getenv('APPVEYOR_BUILD_VERSION')
        )
        query = {
            'service': "appveyor",
            'branch': os.getenv('APPVEYOR_REPO_BRANCH'),
            'build': os.getenv('APPVEYOR_JOB_ID'),
            'pr': os.getenv('APPVEYOR_PULL_REQUEST_NUMBER'),
            'job': '/'.join((
                os.getenv('APPVEYOR_ACCOUNT_NAME'),
                os.getenv('APPVEYOR_PROJECT_SLUG'),
                os.getenv('APPVEYOR_BUILD_VERSION')
            )),
            'tag': os.getenv('APPVEYOR_REPO_TAG_NAME'),
            'slug': os.getenv('APPVEYOR_REPO_NAME'),
            'commit': os.getenv('APPVEYOR_REPO_COMMIT'),
            'build_url': build_url,
        }

    elif env_name == 'ci-circle':
        # https://circleci.com/docs/environment-variables
        query = {
            'service': 'circleci',
            'branch': os.getenv('CIRCLE_BRANCH'),
            'build': os.getenv('CIRCLE_BUILD_NUM'),
            'pr': os.getenv('CIRCLE_PR_NUMBER'),
            'job': os.getenv('CIRCLE_BUILD_NUM') + "." + os.getenv('CIRCLE_NODE_INDEX'),
            'tag': os.getenv('CIRCLE_TAG'),
            'slug': os.getenv('CIRCLE_PROJECT_USERNAME') + "/" + os.getenv('CIRCLE_PROJECT_REPONAME'),
            'commit': os.getenv('CIRCLE_SHA1'),
            'build_url': os.getenv('CIRCLE_BUILD_URL'),
        }

    elif env_name == 'ci-github-actions':
        branch = ''
        tag = ''
        ref = os.getenv('GITHUB_REF', '')
        if ref.startswith('refs/tags/'):
            tag = ref[10:]
        elif ref.startswith('refs/heads/'):
            branch = ref[11:]

        impl = _plat.python_implementation()
        major, minor = _plat.python_version_tuple()[0:2]
        build_name = '%s %s %s.%s' % (_platform_name(), impl, major, minor)

        query = {
            'service': 'custom',
            'token': json_data['token'],
            'branch': branch,
            'tag': tag,
            'slug': os.getenv('GITHUB_REPOSITORY'),
            'commit': os.getenv('GITHUB_SHA'),
            'build_url': 'https://github.com/wbond/oscrypto/commit/%s/checks' % os.getenv('GITHUB_SHA'),
            'name': 'GitHub Actions %s on %s' % (build_name, os.getenv('RUNNER_OS'))
        }

    else:
        if not os.path.exists(os.path.join(root, '.git')):
            print('git repository not found, not submitting coverage data')
            return
        git_status = _git_command(['status', '--porcelain'], root)
        if git_status != '':
            print('git repository has uncommitted changes, not submitting coverage data')
            return

        branch = _git_command(['rev-parse', '--abbrev-ref', 'HEAD'], root)
        commit = _git_command(['rev-parse', '--verify', 'HEAD'], root)
        tag = _git_command(['name-rev', '--tags', '--name-only', commit], root)
        impl = _plat.python_implementation()
        major, minor = _plat.python_version_tuple()[0:2]
        build_name = '%s %s %s.%s' % (_platform_name(), impl, major, minor)
        query = {
            'branch': branch,
            'commit': commit,
            'slug': json_data['slug'],
            'token': json_data['token'],
            'build': build_name,
        }
        if tag != 'undefined':
            query['tag'] = tag

    payload = 'PLATFORM=%s\n' % _platform_name()
    payload += 'PYTHON_VERSION=%s %s\n' % (_plat.python_version(), _plat.python_implementation())
    if 'oscrypto' in sys.modules:
        payload += 'OSCRYPTO_BACKEND=%s\n' % sys.modules['oscrypto'].backend()
    payload += '<<<<<< ENV\n'

    for path in _list_files(root):
        payload += path + '\n'
    payload += '<<<<<< network\n'

    payload += '# path=coverage.xml\n'
    with open(os.path.join(root, 'coverage.xml'), 'r', encoding='utf-8') as f:
        payload += f.read() + '\n'
    payload += '<<<<<< EOF\n'

    url = 'https://codecov.io/upload/v4'
    headers = {
        'Accept': 'text/plain'
    }
    filtered_query = {}
    for key in query:
        value = query[key]
        if value == '' or value is None:
            continue
        filtered_query[key] = value

    print('Submitting coverage info to codecov.io')
    info = _do_request(
        'POST',
        url,
        headers,
        query_params=filtered_query
    )

    encoding = info[1] or 'utf-8'
    text = info[2].decode(encoding).strip()
    parts = text.split()
    upload_url = parts[1]

    headers = {
        'Content-Type': 'text/plain',
        'x-amz-acl': 'public-read',
        'x-amz-storage-class': 'REDUCED_REDUNDANCY'
    }

    print('Uploading coverage data to codecov.io S3 bucket')
    _do_request(
        'PUT',
        upload_url,
        headers,
        data=payload.encode('utf-8')
    )


def _git_command(params, cwd):
    """
    Executes a git command, returning the output

    :param params:
        A list of the parameters to pass to git

    :param cwd:
        The working directory to execute git in

    :return:
        A 2-element tuple of (stdout, stderr)
    """

    proc = subprocess.Popen(
        ['git'] + params,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        cwd=cwd
    )
    stdout, stderr = proc.communicate()
    code = proc.wait()
    if code != 0:
        e = OSError('git exit code was non-zero')
        e.stdout = stdout
        raise e
    return stdout.decode('utf-8').strip()


def _parse_env_var_file(data):
    """
    Parses a basic VAR="value data" file contents into a dict

    :param data:
        A unicode string of the file data

    :return:
        A dict of parsed name/value data
    """

    output = {}
    for line in data.splitlines():
        line = line.strip()
        if not line or '=' not in line:
            continue
        parts = line.split('=')
        if len(parts) != 2:
            continue
        name = parts[0]
        value = parts[1]
        if len(value) > 1:
            if value[0] == '"' and value[-1] == '"':
                value = value[1:-1]
        output[name] = value
    return output


def _platform_name():
    """
    Returns information about the current operating system and version

    :return:
        A unicode string containing the OS name and version
    """

    if sys.platform == 'darwin':
        version = _plat.mac_ver()[0]
        _plat_ver_info = tuple(map(int, version.split('.')))
        if _plat_ver_info < (10, 12):
            name = 'OS X'
        else:
            name = 'macOS'
        return '%s %s' % (name, version)

    elif sys.platform == 'win32':
        _win_ver = sys.getwindowsversion()
        _plat_ver_info = (_win_ver[0], _win_ver[1])
        return 'Windows %s' % _plat.win32_ver()[0]

    elif sys.platform in ['linux', 'linux2']:
        if os.path.exists('/etc/os-release'):
            with open('/etc/os-release', 'r', encoding='utf-8') as f:
                pairs = _parse_env_var_file(f.read())
                if 'NAME' in pairs and 'VERSION_ID' in pairs:
                    return '%s %s' % (pairs['NAME'], pairs['VERSION_ID'])
                    version = pairs['VERSION_ID']
                elif 'PRETTY_NAME' in pairs:
                    return pairs['PRETTY_NAME']
                elif 'NAME' in pairs:
                    return pairs['NAME']
                else:
                    raise ValueError('No suitable version info found in /etc/os-release')
        elif os.path.exists('/etc/lsb-release'):
            with open('/etc/lsb-release', 'r', encoding='utf-8') as f:
                pairs = _parse_env_var_file(f.read())
                if 'DISTRIB_DESCRIPTION' in pairs:
                    return pairs['DISTRIB_DESCRIPTION']
                else:
                    raise ValueError('No suitable version info found in /etc/lsb-release')
        else:
            return 'Linux'

    else:
        return '%s %s' % (_plat.system(), _plat.release())


def _list_files(root):
    """
    Lists all of the files in a directory, taking into account any .gitignore
    file that is present

    :param root:
        A unicode filesystem path

    :return:
        A list of unicode strings, containing paths of all files not ignored
        by .gitignore with root, using relative paths
    """

    dir_patterns, file_patterns = _gitignore(root)
    paths = []
    prefix = os.path.abspath(root) + os.sep
    for base, dirs, files in os.walk(root):
        for d in dirs:
            for dir_pattern in dir_patterns:
                if fnmatch(d, dir_pattern):
                    dirs.remove(d)
                    break
        for f in files:
            skip = False
            for file_pattern in file_patterns:
                if fnmatch(f, file_pattern):
                    skip = True
                    break
            if skip:
                continue
            full_path = os.path.join(base, f)
            if full_path[:len(prefix)] == prefix:
                full_path = full_path[len(prefix):]
            paths.append(full_path)
    return sorted(paths)


def _gitignore(root):
    """
    Parses a .gitignore file and returns patterns to match dirs and files.
    Only basic gitignore patterns are supported. Pattern negation, ** wildcards
    and anchored patterns are not currently implemented.

    :param root:
        A unicode string of the path to the git repository

    :return:
        A 2-element tuple:
         - 0: a list of unicode strings to match against dirs
         - 1: a list of unicode strings to match against dirs and files
    """

    gitignore_path = os.path.join(root, '.gitignore')

    dir_patterns = ['.git']
    file_patterns = []

    if not os.path.exists(gitignore_path):
        return (dir_patterns, file_patterns)

    with open(gitignore_path, 'r', encoding='utf-8') as f:
        for line in f.readlines():
            line = line.strip()
            if not line:
                continue
            if line.startswith('#'):
                continue
            if '**' in line:
                raise NotImplementedError('gitignore ** wildcards are not implemented')
            if line.startswith('!'):
                raise NotImplementedError('gitignore pattern negation is not implemented')
            if line.startswith('/'):
                raise NotImplementedError('gitignore anchored patterns are not implemented')
            if line.startswith('\\#'):
                line = '#' + line[2:]
            if line.startswith('\\!'):
                line = '!' + line[2:]
            if line.endswith('/'):
                dir_patterns.append(line[:-1])
            else:
                file_patterns.append(line)

    return (dir_patterns, file_patterns)


def _do_request(method, url, headers, data=None, query_params=None, timeout=20):
    """
    Performs an HTTP request

    :param method:
        A unicode string of 'POST' or 'PUT'

    :param url;
        A unicode string of the URL to request

    :param headers:
        A dict of unicode strings, where keys are header names and values are
        the header values.

    :param data:
        A dict of unicode strings (to be encoded as
        application/x-www-form-urlencoded), or a byte string of data.

    :param query_params:
        A dict of unicode keys and values to pass as query params

    :param timeout:
        An integer number of seconds to use as the timeout

    :return:
        A 3-element tuple:
         - 0: A unicode string of the response content-type
         - 1: A unicode string of the response encoding, or None
         - 2: A byte string of the response body
    """

    if query_params:
        url += '?' + urlencode(query_params).replace('+', '%20')

    if isinstance(data, dict):
        data_bytes = {}
        for key in data:
            data_bytes[key.encode('utf-8')] = data[key].encode('utf-8')
        data = urlencode(data_bytes)
        headers['Content-Type'] = 'application/x-www-form-urlencoded'
    if isinstance(data, str_cls):
        raise TypeError('data must be a byte string')

    try:
        tempfd, tempf_path = tempfile.mkstemp('-coverage')
        os.write(tempfd, data or b'')
        os.close(tempfd)

        if sys.platform == 'win32':
            powershell_exe = os.path.join('system32\\WindowsPowerShell\\v1.0\\powershell.exe')
            code = "[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12;"
            code += "$wc = New-Object Net.WebClient;"
            for key in headers:
                code += "$wc.Headers.add('%s','%s');" % (key, headers[key])
            code += "$out = $wc.UploadFile('%s', '%s', '%s');" % (url, method, tempf_path)
            code += "[System.Text.Encoding]::GetEncoding('ISO-8859-1').GetString($wc.ResponseHeaders.ToByteArray())"

            # To properly obtain bytes, we use BitConverter to get hex dash
            # encoding (e.g. AE-09-3F) and they decode in python
            code += " + [System.BitConverter]::ToString($out);"
            stdout, stderr = _execute(
                [powershell_exe, '-Command', code],
                os.getcwd(),
                re.compile(r'Unable to connect to|TLS|Internal Server Error'),
                6
            )
            if stdout[-2:] == b'\r\n' and b'\r\n\r\n' in stdout:
                # An extra trailing crlf is added at the end by powershell
                stdout = stdout[0:-2]
                parts = stdout.split(b'\r\n\r\n', 1)
                if len(parts) == 2:
                    stdout = parts[0] + b'\r\n\r\n' + codecs.decode(parts[1].replace(b'-', b''), 'hex_codec')

        else:
            args = [
                'curl',
                '--http1.1',
                '--connect-timeout', '5',
                '--request',
                method,
                '--location',
                '--silent',
                '--show-error',
                '--include',
                # Prevent curl from asking for an HTTP "100 Continue" response
                '--header', 'Expect:'
            ]
            for key in headers:
                args.append('--header')
                args.append("%s: %s" % (key, headers[key]))
            args.append('--data-binary')
            args.append('@%s' % tempf_path)
            args.append(url)
            stdout, stderr = _execute(
                args,
                os.getcwd(),
                re.compile(r'Failed to connect to|TLS|SSLRead|outstanding|cleanly|timed out'),
                6
            )
    finally:
        if tempf_path and os.path.exists(tempf_path):
            os.remove(tempf_path)

    if len(stderr) > 0:
        raise URLError("Error %sing %s:\n%s" % (method, url, stderr))

    parts = stdout.split(b'\r\n\r\n', 1)
    if len(parts) != 2:
        raise URLError("Error %sing %s, response data malformed:\n%s" % (method, url, stdout))
    header_block, body = parts

    content_type_header = None
    content_len_header = None
    for hline in header_block.decode('iso-8859-1').splitlines():
        hline_parts = hline.split(':', 1)
        if len(hline_parts) != 2:
            continue
        name, val = hline_parts
        name = name.strip().lower()
        val = val.strip()
        if name == 'content-type':
            content_type_header = val
        if name == 'content-length':
            content_len_header = val

    if content_type_header is None and content_len_header != '0':
        raise URLError("Error %sing %s, no content-type header:\n%s" % (method, url, stdout))

    if content_type_header is None:
        content_type = 'text/plain'
        encoding = 'utf-8'
    else:
        content_type, params = cgi.parse_header(content_type_header)
        encoding = params.get('charset')

    return (content_type, encoding, body)


def _execute(params, cwd, retry=None, retries=0, backoff=2):
    """
    Executes a subprocess

    :param params:
        A list of the executable and arguments to pass to it

    :param cwd:
        The working directory to execute the command in

    :param retry:
        If this string is present in stderr, or regex pattern matches stderr, retry the operation

    :param retries:
        An integer number of times to retry

    :return:
        A 2-element tuple of (stdout, stderr)
    """

    proc = subprocess.Popen(
        params,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        cwd=cwd
    )
    stdout, stderr = proc.communicate()
    code = proc.wait()
    if code != 0:
        if retry and retries > 0:
            stderr_str = stderr.decode('utf-8')
            if isinstance(retry, Pattern):
                if retry.search(stderr_str) is not None:
                    time.sleep(backoff)
                    return _execute(params, cwd, retry, retries - 1, backoff * 2)
            elif retry in stderr_str:
                time.sleep(backoff)
                return _execute(params, cwd, retry, retries - 1, backoff * 2)
        e = OSError('subprocess exit code for "%s" was %d: %s' % (' '.join(params), code, stderr))
        e.stdout = stdout
        e.stderr = stderr
        raise e
    return (stdout, stderr)


if __name__ == '__main__':
    _codecov_submit()
