import json
import os
from os.path import dirname as _dir
import random
import string
import time
import logging
import subprocess

import docker
from docker.errors import DockerException
import pytest


def get_logger(name):
    return logging.getLogger('conftest.%s' % name)


def pytest_sessionstart(session):
    BASE_FORMAT = "[%(name)s][%(levelname)-6s] %(message)s"
    FILE_FORMAT = "[%(asctime)s]" + BASE_FORMAT

    root_logger = logging.getLogger('conftest')
    dir_path = os.path.dirname(os.path.realpath(__file__))
    top_level = _dir(_dir(dir_path))
    log_file = os.path.join(top_level, 'pytest-functional-tests.log')

    root_logger.setLevel(logging.DEBUG)

    # File Logger
    fh = logging.FileHandler(log_file)
    fh.setLevel(logging.DEBUG)
    fh.setFormatter(logging.Formatter(FILE_FORMAT, "%Y-%m-%d %H:%M:%S"))

    root_logger.addHandler(fh)

    # Setup the environment variable for the container
    image = os.environ.get('PYTEST_CONTAINER', 'anchore/inline-scan:latest')
    os.environ['PYTEST_CONTAINER'] = image


def use_environ():
    """
    In certain test environments, the necessary docker env vars are available
    and those should be used. This function checks for those and returns
    a boolean so that the docker client can be instantiated properly
    """
    for var in ['DOCKER_CERT_PATH', 'DOCKER_HOST', 'DOCKER_MACHINE_NAME', 'DOCKER_TLS_VERIFY']:
        if os.environ.get(var) is None:
            return False
    return True


def create_client():
    logger = get_logger('create_client')
    try:
        if use_environ():
            logger.info('using environment to create docker client')
            c = docker.from_env()
        else:
            c = docker.DockerClient(base_url='unix://var/run/docker.sock', version="auto")
        # XXX ?
        c.run = run(c)
        return c
    except DockerException as e:
        logger.exception('Unable to connect to a docker socket')
        raise pytest.UsageError("Could not connect to a running docker socket: %s" % str(e))


@pytest.fixture(scope='session')
def client():
    return create_client()


def pytest_assertrepr_compare(op, left, right):
    if isinstance(left, ExitCode) and isinstance(right, ExitCode):
        return [
            "failure ExitCode(%s) %s ExitCode(%s)" % (left, op, right),
            "Exit status assertion failure, stdout and stderr context:",
            ] + [
            '   stdout: %s' % line for line in left.stdout.split('\n')
            ] + [
            '   stderr: %s' % line for line in left.stderr.split('\n')
        ]


def pytest_addoption(parser):
    """
    Do not Keep the container around and remove it after a test run. Useful
    only for the CI. When running locally a developer will probably want to
    keep the container around for easier/faster testing.
    """
    parser.addoption(
        "--nokeepalive", action="store_true",
        default=False, help="Do not keep docker container alive"
    )


def pytest_report_header(config):
    logger = get_logger('report_header')
    msg = []
    try:
        client = create_client()
        metadata = client.api.inspect_container('pytest_inline_scan')
    except docker.errors.NotFound:
        logger.info("No running container was found, can't add info to report header")
        metadata = {'Config': {'Labels': {}}}
        msg = ['Docker: Anchore inline_scan container not running yet']
    except DockerException as e:
        logger.exception('Unable to connect to a docker socket')
        msg = ['Anchore Version: Unable to connect to a docker socket']
        msg.append('Error: %s' % str(e))
        return msg

    labels = metadata['Config']['Labels']
    version = labels.get('version', 'unknown')
    commit = labels.get('anchore_commit', 'unknown')

    msg.extend([
       'Anchore Version: %s' % version,
       'Anchore Commit: %s' % commit
    ])
    return msg


def pytest_runtest_logreport(report):
    if report.failed:
        client = create_client()

        test_containers = client.containers.list(
            all=True,
            filters={"name": "pytest_inline_scan"})
        for container in test_containers:
            # XXX magical number! get the last 10 log lines
            log_lines = [
                ("Container ID: {!r}:".format(container.attrs['Id'])),
                ] + container.logs().decode('utf-8').split('\n')[-10:]
            try:
                report.longrepr.addsection('docker logs', os.linesep.join(log_lines))
            except AttributeError:
                pass


@pytest.fixture(scope='session', autouse=True)
def inline_scan(client, request):
    # If the container is already running, this will return the running
    # container identified with `pytest_inline_scan`
    image = os.environ.get('PYTEST_CONTAINER', 'anchore/inline-scan:latest')
    os.environ['PYTEST_CONTAINER'] = image
    container = start_container(
        client,
        image=image,
        name='pytest_inline_scan',
        environment={},
        detach=True,
        ports={'8228/tcp': 8228}
    )

    no_keep_alive = request.config.getoption("--nokeepalive", False)
    if no_keep_alive:
        # Do not leave the container running and tear it down at the end of the session
        request.addfinalizer(lambda: teardown_container(client, container=container))

    return container


def teardown_container(client, container=None, name=None):
    logger = get_logger('teardown_container')
    container_name = name or container.name
    if name:
        container_name = name
    else:
        container_name = container.name
    logger.debug('Tearing down container: %s', container_name)
    containers = client.containers.list(all=True, filters={'name': container_name})
    # TODO: check if stop/remove can take a force=True param
    for available_container in containers:
        available_container.stop()
        available_container.remove()


def start_container(client, image, name, environment, ports, detach=True):
    """
    Start a container, wait for (successful) completion of entrypoint
    and raise an exception with container logs otherwise
    """
    logger = get_logger('start_container')
    logger.info('will try to start container image %s', image)
    try:
        container = client.containers.get(name)
        if container.status != 'running':
            logger.info('%s container found but not running, will start it', name)
            container.start()
    except docker.errors.NotFound:
        logger.info('%s container not found, will start it', name)
        container = client.containers.run(
            image=image,
            name=name,
            environment={},
            detach=True,
            ports=ports,
        )

    start = time.time()
    while time.time() - start < 70:
        out, err, code = call(
            ['anchore-cli', '--u', 'admin', '--p', 'foobar', 'system', 'status'],
        )
        if code == 0:
            # This path needs to be hit when the container is ready to be
            # used, if this is not reached, then an error needs to bubble
            # up
            return container
        time.sleep(2)

    logger.error('Aborting tests: unable to verify a healthy status from container')
    # If 70 seconds passed and anchore-cli wasn't able to determine an OK
    # status from anchore-engine then failure needs to be raised with as much
    # logging as possible. Can't assume the container is healthy even if the
    # exit code is 0
    print("[ERROR][setup] failed to setup container")
    for line in out.split('\n'):
        print("[ERROR][setup][stdout] {}".format(line))
    for line in err.split('\n'):
        print("[ERROR][setup][stderr] {}".format(line))
    raise RuntimeError()


def remove_container(client, container_name):
    # remove any existing test container
    for test_container in client.containers.list(all=True):
        if test_container.name == container_name:
            test_container.stop()
            test_container.remove()


def run(client):
    def run_command(container_id, command):
        created_command = client.exec_create(container_id, cmd=command)
        result = client.exec_start(created_command)
        exit_code = client.exec_inspect(created_command)['ExitCode']
        if exit_code != 0:
            msg = 'Non-zero exit code (%d) for command: %s' % (exit_code, command)
            raise(AssertionError(result), msg)
        return result
    return run_command


class ExitCode(int):
    """
    For rich comparison in Pytest, the objects being compared can be
    introspected to provide more context to a failure. The idea here is that
    when the exit code is not expected, a custom Pytest hook can provide the
    `stderr` and `stdout` aside from just the exit code. The normal `int`
    behavior is preserved.
    """
    def __init__(self, code):
        self.code = code
        self.stderr = ''
        self.stdout = ''


def call(command, **kw):
    """
    Similar to ``subprocess.Popen`` with the following changes:

    * returns stdout, stderr, and exit code (vs. just the exit code)
    * logs the full contents of stderr and stdout (separately) to the file log

    By default, no terminal output is given, not even the command that is going
    to run.

    Useful when system calls are needed to act on output, and that same output
    shouldn't get displayed on the terminal.

    :param terminal_verbose: Log command output to terminal, defaults to False, and
                             it is forcefully set to True if a return code is non-zero
    :param split: Instead of returning output as a long string, split on newlines, and then also
                  split on whitespace. Useful when output keeps changing when tabbing on custom
                  lengths
    """
    logger = get_logger('call')
    stdout = get_logger('call.stdout')
    stderr = get_logger('call.stderr')
    log_verbose = kw.pop('log_verbose', False)
    command_msg = "Running command: %s" % ' '.join(command)
    logger.info(command_msg)
    env = kw.pop('env', None)
    split = kw.pop('split', False)
    existing_env = os.environ.copy()
    if env:
        for key, value in env.items():
            existing_env[key] = value

    process = subprocess.Popen(
        command,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        stdin=subprocess.PIPE,
        close_fds=True,
        env=existing_env,
        **kw
    )
    stdout_stream = process.stdout.read()
    stderr_stream = process.stderr.read()
    returncode = process.wait()
    if not isinstance(stdout_stream, str):
        stdout_stream = stdout_stream.decode('utf-8')
    if not isinstance(stderr_stream, str):
        stderr_stream = stderr_stream.decode('utf-8')

    if returncode != 0:
        # set to true so that we can log the stderr/stdout that callers would
        # do anyway
        log_verbose = True

    # the following can get a messed up order in the log if the system call
    # returns output with both stderr and stdout intermingled. This separates
    # that.
    if log_verbose:
        for line in stdout_stream.splitlines():
            stdout.info(line)
        for line in stderr_stream.splitlines():
            stderr.info(line)

    returncode = ExitCode(returncode)
    returncode.stderr = stderr_stream
    returncode.stdout = stdout_stream

    if split:
        stdout_stream = [line.split() for line in stdout_stream.split('\n')]
        stderr_stream = [line.split() for line in stderr_stream.split('\n')]

    return stdout_stream, stderr_stream, returncode


def _call(command, **kw):
    if command[0] != 'anchore-cli':
        command.insert(0, 'anchore-cli')
    return call(
        command,
        env={'ANCHORE_CLI_USER': 'admin', 'ANCHORE_CLI_PASS': 'foobar'},
        **kw
    )


@pytest.fixture(scope='session')
def session_admin_call():
    """
    Same as a plain admin call, but scoped for the session (runs once per test
    session)
    """
    return _call


@pytest.fixture(scope='class')
def class_admin_call():
    """
    Same as a plain admin call, but scoped for a class (runs once for a whole
    class)
    """
    return _call


@pytest.fixture
def admin_call():
    return _call


def get_account(account_name, account_list):
    for account in account_list:
        if account['name'] == account_name:
            return account


def random_name():
    return ''.join(random.choice(string.ascii_lowercase) for i in range(8))


@pytest.fixture
def add_account(request, admin_call):
    def apply(account_name=None):
        if account_name is not None:
            # makes sure that for specific accounts, that those are not
            # present. This is problematic if that account was previously
            # created and it is currently in `deleting` state.
            for i in range(15):
                out, _, _ = admin_call(['--json', 'account', 'list'])
                account_list = json.loads(out)
                account = get_account(account_name, account_list)
                if account:
                    # created, possibly on deleting status
                    if account['state'] == 'deleting':
                        time.sleep(2)
                        continue
                    elif account['state'] == 'disabled':
                        # delete it because we need a clean slate
                        admin_call(['account', 'del', '--dontask', account_name])

                else:
                    break
        else:
            account_name = random_name()
        admin_call(['account', 'add', account_name])
        def finalizer(): # noqa
            admin_call(['account', 'disable', account_name])
            admin_call(['account', 'del', '--dontask', account_name])
        request.addfinalizer(finalizer)
        return account_name
    return apply