import contextlib
import json
import os
import re
import sys
import threading

import pytest
from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer

from dcos import constants

from .helpers.common import (assert_command, assert_lines, exec_command,
                             popen_tty, update_config)
from .helpers.marathon import (app, list_apps, list_deployments, show_app,
                               start_app, watch_all_deployments,
                               watch_deployment)


_ZERO_INSTANCE_APP_ID = 'zero-instance-app'
_ZERO_INSTANCE_APP_INSTANCES = 100


def test_help():
    with open('dcoscli/data/help/marathon.txt') as content:
        assert_command(['dcos', 'marathon', '--help'],
                       stdout=content.read().encode('utf-8'))


def test_version():
    assert_command(['dcos', 'marathon', '--version'],
                   stdout=b'dcos-marathon version SNAPSHOT\n')


def test_info():
    assert_command(['dcos', 'marathon', '--info'],
                   stdout=b'Deploy and manage applications to DC/OS\n')


def test_about():
    returncode, stdout, stderr = exec_command(['dcos', 'marathon', 'about'])

    assert returncode == 0
    assert stderr == b''

    result = json.loads(stdout.decode('utf-8'))
    assert result['name'] == "marathon"


@pytest.fixture
def env():
    r = os.environ.copy()
    r.update({constants.PATH_ENV: os.environ[constants.PATH_ENV]})

    return r


def test_empty_list():
    list_apps()


def test_add_app_through_http():
    with _zero_instance_app_through_http():
        list_apps('zero-instance-app')


def test_add_app_bad_resource():
    stderr = (b'Can\'t read from resource: bad_resource.\n'
              b'Please check that it exists.\n')
    assert_command(['dcos', 'marathon', 'app', 'add', 'bad_resource'],
                   returncode=1,
                   stderr=stderr)


def test_remove_app():
    with _zero_instance_app():
        pass
    list_apps()


def test_add_bad_json_app():
    with open('tests/data/marathon/apps/bad.json') as fd:
        returncode, stdout, stderr = exec_command(
            ['dcos', 'marathon', 'app', 'add'],
            stdin=fd)

        assert returncode == 1
        assert stdout == b''
        assert stderr.decode('utf-8').startswith('Error loading JSON: ')


def test_add_existing_app():
    with _zero_instance_app():
        app_path = 'tests/data/marathon/apps/zero_instance_sleep_v2.json'
        with open(app_path) as fd:
            stderr = b"Application '/zero-instance-app' already exists\n"
            assert_command(['dcos', 'marathon', 'app', 'add'],
                           returncode=1,
                           stderr=stderr,
                           stdin=fd)


def test_show_absolute_app_version():
    with _zero_instance_app():
        _update_app(
            'zero-instance-app',
            'tests/data/marathon/apps/update_zero_instance_sleep.json')

        result = show_app('zero-instance-app')
        show_app('zero-instance-app', result['version'])


def test_show_relative_app_version():
    with _zero_instance_app():
        _update_app(
            'zero-instance-app',
            'tests/data/marathon/apps/update_zero_instance_sleep.json')
        show_app('zero-instance-app', "-1")


def test_show_missing_relative_app_version():
    app_id = _ZERO_INSTANCE_APP_ID

    with _zero_instance_app():
        _update_app(
            app_id,
            'tests/data/marathon/apps/update_zero_instance_sleep.json')

        # Marathon persists app versions indefinitely by ID, so pick a large
        # index here in case the history is long
        cmd = ['dcos', 'marathon', 'app', 'show', '--app-version=-200', app_id]
        returncode, stdout, stderr = exec_command(cmd)

        assert returncode == 1
        assert stdout == b''

        pattern = ("Application 'zero-instance-app' only has [1-9][0-9]* "
                   "version\\(s\\)\\.\n")
        assert re.fullmatch(pattern, stderr.decode('utf-8'), flags=re.DOTALL)


def test_show_missing_absolute_app_version():
    with _zero_instance_app():
        _update_app(
            'zero-instance-app',
            'tests/data/marathon/apps/update_zero_instance_sleep.json')

        returncode, stdout, stderr = exec_command(
            ['dcos', 'marathon', 'app', 'show',
             '--app-version=2000-02-11T20:39:32.972Z', 'zero-instance-app'])

        assert returncode == 1
        assert stdout == b''
        assert stderr.decode('utf-8').startswith(
            "Error: App '/zero-instance-app' does not exist")


def test_show_bad_app_version():
    with _zero_instance_app():
        _update_app(
            'zero-instance-app',
            'tests/data/marathon/apps/update_zero_instance_sleep.json')

        returncode, stdout, stderr = exec_command(
            ['dcos', 'marathon', 'app', 'show', '--app-version=20:39:32.972Z',
             'zero-instance-app'])
        assert returncode == 1
        assert stdout == b''
        assert stderr.startswith(b'Error while fetching')
        pattern = (b"""{"message":"Invalid format: """
                   b"""\\"20:39:32.972Z\\" is malformed"""
                   b""" at \\":39:32.972Z\\""}\n""")
        assert stderr.endswith(pattern)


def test_show_bad_relative_app_version():
    with _zero_instance_app():
        _update_app(
            'zero-instance-app',
            'tests/data/marathon/apps/update_zero_instance_sleep.json')

        assert_command(
            ['dcos', 'marathon', 'app', 'show',
             '--app-version=2', 'zero-instance-app'],
            returncode=1,
            stderr=b"Relative versions must be negative: 2\n")


def test_start_missing_app():
    assert_command(
        ['dcos', 'marathon', 'app', 'start', 'missing-id'],
        returncode=1,
        stderr=b"Error: App '/missing-id' does not exist\n")


def test_start_already_started_app():
    with _zero_instance_app():
        start_app('zero-instance-app')

        stdout = (b"Application 'zero-instance-app' already "
                  b"started: 1 instances.\n")
        assert_command(
            ['dcos', 'marathon', 'app', 'start', 'zero-instance-app'],
            returncode=1,
            stdout=stdout)


def test_stop_missing_app():
    assert_command(['dcos', 'marathon', 'app', 'stop', 'missing-id'],
                   returncode=1,
                   stderr=b"Error: App '/missing-id' does not exist\n")


def test_stop_app():
    with _zero_instance_app():
        start_app('zero-instance-app', 3)
        watch_all_deployments()

        returncode, stdout, stderr = exec_command(
            ['dcos', 'marathon', 'app', 'stop', 'zero-instance-app'])

        assert returncode == 0
        assert stdout.decode().startswith('Created deployment ')
        assert stderr == b''


def test_stop_already_stopped_app():
    with _zero_instance_app():
        stdout = (b"Application 'zero-instance-app' already "
                  b"stopped: 0 instances.\n")
        assert_command(
            ['dcos', 'marathon', 'app', 'stop', 'zero-instance-app'],
            returncode=1,
            stdout=stdout)


def test_update_missing_app():
    assert_command(['dcos', 'marathon', 'app', 'update', 'missing-id'],
                   stderr=b"Error: App '/missing-id' does not exist\n",
                   returncode=1)


def test_update_bad_type():
    with _zero_instance_app():
        returncode, stdout, stderr = exec_command(
            ['dcos', 'marathon', 'app', 'update',
             'zero-instance-app', 'cpus="a string"'])

        stderr_end = b"""{"message":"Invalid JSON","details":[{"path":"/cpus","errors":["error.expected.jsnumber"]}]}"""  # noqa: E501

        assert returncode == 1
        assert stderr_end in stderr
        assert stdout == b''


def test_update_invalid_request():
    returncode, stdout, stderr = exec_command(
        ['dcos', 'marathon', 'app', 'update', '{', 'instances'])
    assert returncode == 1
    assert stdout == b''
    stderr = stderr.decode()
    # TODO (tamar): this becomes 'Error: App '/{' does not exist\n"'
    # in Marathon 0.11.0
    assert stderr.startswith('Error on request')
    assert stderr.endswith('HTTP 400: Bad Request\n')


def test_app_add_invalid_request():
    path = os.path.join(
        'tests', 'data', 'marathon', 'apps', 'app_add_400.json')

    returncode, stdout, stderr = exec_command(
        ['dcos', 'marathon', 'app', 'add', path])

    stderr_end = b"""{"message":"Invalid JSON","details":[{"path":"/container/docker/network","errors":["error.unknown.enum.literal"]}]}"""  # noqa: E501

    assert returncode == 1
    assert stderr_end in stderr
    assert stdout == b''


def test_update_app():
    with _zero_instance_app():
        returncode, stdout, stderr = exec_command(
            ['dcos', 'marathon', 'app', 'update', 'zero-instance-app',
             'cpus=1', 'mem=20', "cmd='sleep 100'"])

        assert returncode == 0
        assert stdout.decode().startswith('Created deployment ')
        assert stderr == b''


def test_update_app_from_stdin():
    with _zero_instance_app():
        _update_app(
            'zero-instance-app',
            'tests/data/marathon/apps/update_zero_instance_sleep.json')


def test_restarting_stopped_app():
    with _zero_instance_app():
        stdout = (b"Unable to perform rolling restart of application '"
                  b"/zero-instance-app' because it has no running tasks\n")
        assert_command(
            ['dcos', 'marathon', 'app', 'restart', 'zero-instance-app'],
            returncode=1,
            stdout=stdout)


def test_restarting_missing_app():
    assert_command(['dcos', 'marathon', 'app', 'restart', 'missing-id'],
                   returncode=1,
                   stderr=b"Error: App '/missing-id' does not exist\n")


def test_killing_app():
    with _zero_instance_app():
        start_app('zero-instance-app', 3)
        watch_all_deployments()
        task_set_1 = set([task['id']
                          for task in _list_tasks(3, 'zero-instance-app')])
        returncode, stdout, stderr = exec_command(
            ['dcos', 'marathon', 'app', 'kill', 'zero-instance-app'])
        assert returncode == 0
        assert stdout.decode().startswith('Killed tasks: ')
        assert stderr == b''
        watch_all_deployments()
        task_set_2 = set([task['id']
                          for task in _list_tasks(app_id='zero-instance-app')])
        assert len(task_set_1.intersection(task_set_2)) == 0


def test_killing_scaling_app():
    with _zero_instance_app():
        start_app('zero-instance-app', 3)
        watch_all_deployments()
        _list_tasks(3)
        command = ['dcos', 'marathon', 'app', 'kill', '--scale',
                   'zero-instance-app']
        returncode, stdout, stderr = exec_command(command)
        assert returncode == 0
        assert stdout.decode().startswith('Started deployment: ')
        assert stdout.decode().find('version') > -1
        assert stdout.decode().find('deploymentId') > -1
        assert stderr == b''
        watch_all_deployments()
        _list_tasks(0)


def test_killing_with_host_app():
    with _zero_instance_app():
        start_app('zero-instance-app', 3)
        watch_all_deployments()
        existing_tasks = _list_tasks(3, 'zero-instance-app')
        task_hosts = set([task['host'] for task in existing_tasks])
        if len(task_hosts) <= 1:
            pytest.skip('test needs 2 or more agents to succeed, '
                        'only {} agents available'.format(len(task_hosts)))
        assert len(task_hosts) > 1
        kill_host = list(task_hosts)[0]
        expected_to_be_killed = set([task['id']
                                     for task in existing_tasks
                                     if task['host'] == kill_host])
        not_to_be_killed = set([task['id']
                                for task in existing_tasks
                                if task['host'] != kill_host])
        assert len(not_to_be_killed) > 0
        assert len(expected_to_be_killed) > 0
        command = ['dcos', 'marathon', 'app', 'kill', '--host', kill_host,
                   'zero-instance-app']
        returncode, stdout, stderr = exec_command(command)
        assert stdout.decode().startswith('Killed tasks: ')
        assert stderr == b''
        new_tasks = set([task['id'] for task in _list_tasks()])
        assert not_to_be_killed.intersection(new_tasks) == not_to_be_killed
        assert len(expected_to_be_killed.intersection(new_tasks)) == 0


@pytest.mark.skipif(
    True, reason='https://github.com/mesosphere/marathon/issues/3251')
def test_kill_stopped_app():
    with _zero_instance_app():
        returncode, stdout, stderr = exec_command(
            ['dcos', 'marathon', 'app', 'kill', 'zero-instance-app'])
        assert returncode == 1
        assert stdout.decode().startswith('Killed tasks: []')


def test_kill_missing_app():
    returncode, stdout, stderr = exec_command(
        ['dcos', 'marathon', 'app', 'kill', 'app'])
    assert returncode == 1
    assert stdout.decode() == ''
    stderr_expected = "Error: App '/app' does not exist"
    assert stderr.decode().strip() == stderr_expected


def test_list_version_missing_app():
    assert_command(
        ['dcos', 'marathon', 'app', 'version', 'list', 'missing-id'],
        returncode=1,
        stderr=b"Error: App '/missing-id' does not exist\n")


def test_list_version_negative_max_count():
    assert_command(['dcos', 'marathon', 'app', 'version', 'list',
                    'missing-id', '--max-count=-1'],
                   returncode=1,
                   stderr=b'Maximum count must be a positive number: -1\n')


def test_list_version_app():
    app_id = _ZERO_INSTANCE_APP_ID

    with _zero_instance_app():
        _list_versions(app_id, 1)

        _update_app(
            app_id,
            'tests/data/marathon/apps/update_zero_instance_sleep.json')
        _list_versions(app_id, 2)


def test_list_version_max_count():
    app_id = _ZERO_INSTANCE_APP_ID

    with _zero_instance_app():
        _update_app(
            app_id,
            'tests/data/marathon/apps/update_zero_instance_sleep.json')

        _list_versions(app_id, 1, 1)
        _list_versions(app_id, 2, 2)
        _list_versions(app_id, 2, 3)


def test_list_empty_deployment():
    list_deployments(0)


def test_list_deployment():
    with _zero_instance_app():
        start_app('zero-instance-app', _ZERO_INSTANCE_APP_INSTANCES)
        list_deployments(1)


def test_list_deployment_table():
    """Simple sanity check for listing deployments with a table output.
    The more specific testing is done in unit tests.

    """

    with _zero_instance_app():
        start_app('zero-instance-app', _ZERO_INSTANCE_APP_INSTANCES)
        assert_lines(['dcos', 'marathon', 'deployment', 'list'], 2)


def test_list_deployment_missing_app():
    with _zero_instance_app():
        start_app('zero-instance-app')
        list_deployments(0, 'missing-id')


def test_list_deployment_app():
    with _zero_instance_app():
        start_app('zero-instance-app', _ZERO_INSTANCE_APP_INSTANCES)
        list_deployments(1, 'zero-instance-app')


def test_rollback_missing_deployment():
    assert_command(
        ['dcos', 'marathon', 'deployment', 'rollback', 'missing-deployment'],
        returncode=1,
        stderr=b'Error: DeploymentPlan missing-deployment does not exist\n')


def test_rollback_deployment():
    with _zero_instance_app():
        start_app('zero-instance-app', _ZERO_INSTANCE_APP_INSTANCES)
        result = list_deployments(1, 'zero-instance-app')

        returncode, stdout, stderr = exec_command(
            ['dcos', 'marathon', 'deployment', 'rollback', result[0]['id']])

        result = json.loads(stdout.decode('utf-8'))

        assert returncode == 0
        assert 'deploymentId' in result
        assert 'version' in result
        assert stderr == b''

        watch_all_deployments()
        list_deployments(0)


def test_stop_deployment():
    with _zero_instance_app():
        start_app('zero-instance-app', _ZERO_INSTANCE_APP_INSTANCES)
        result = list_deployments(1, 'zero-instance-app')

        assert_command(
            ['dcos', 'marathon', 'deployment', 'stop', result[0]['id']])

        list_deployments(0)


def test_watching_missing_deployment():
    watch_deployment('missing-deployment', 1)


def test_watching_deployment():
    with _zero_instance_app():
        start_app('zero-instance-app', _ZERO_INSTANCE_APP_INSTANCES)
        result = list_deployments(1, 'zero-instance-app')
        watch_deployment(result[0]['id'], 60)
        assert_command(
            ['dcos', 'marathon', 'deployment', 'stop', result[0]['id']])
        list_deployments(0, 'zero-instance-app')


def test_list_empty_task():
    _list_tasks(0)


def test_list_empty_task_not_running_app():
    with _zero_instance_app():
        _list_tasks(0)


def test_list_tasks():
    with _zero_instance_app():
        start_app('zero-instance-app', 3)
        watch_all_deployments()
        _list_tasks(3)


def test_list_tasks_table():
    with _zero_instance_app():
        start_app('zero-instance-app', 3)
        watch_all_deployments()
        assert_lines(['dcos', 'marathon', 'task', 'list'], 4)


def test_list_app_tasks():
    with _zero_instance_app():
        start_app('zero-instance-app', 3)
        watch_all_deployments()
        _list_tasks(3, 'zero-instance-app')


def test_list_missing_app_tasks():
    with _zero_instance_app():
        start_app('zero-instance-app', 3)
        watch_all_deployments()
        _list_tasks(0, 'missing-id')


def test_show_missing_task():
    returncode, stdout, stderr = exec_command(
        ['dcos', 'marathon', 'task', 'show', 'missing-id'])

    stderr = stderr.decode('utf-8')

    assert returncode == 1
    assert stdout == b''
    assert stderr.startswith("Task '")
    assert stderr.endswith("' does not exist\n")


def test_show_task():
    with _zero_instance_app():
        start_app('zero-instance-app', 3)
        watch_all_deployments()
        result = _list_tasks(3, 'zero-instance-app')

        returncode, stdout, stderr = exec_command(
            ['dcos', 'marathon', 'task', 'show', result[0]['id']])

        result = json.loads(stdout.decode('utf-8'))

        assert returncode == 0
        assert result['appId'] == '/zero-instance-app'
        assert stderr == b''


def test_stop_task():
    with _zero_instance_app():
        start_app('zero-instance-app', 1)
        watch_all_deployments()
        task_list = _list_tasks(1, 'zero-instance-app')
        task_id = task_list[0]['id']

        _stop_task(task_id)


def test_stop_task_wipe():
    with _zero_instance_app():
        start_app('zero-instance-app', 1)
        watch_all_deployments()
        task_list = _list_tasks(1, 'zero-instance-app')
        task_id = task_list[0]['id']

        _stop_task(task_id, '--wipe')


def test_kill_one_task():
    with _zero_instance_app():
        start_app('zero-instance-app', 1)
        watch_all_deployments()
        task_list = _list_tasks(1, 'zero-instance-app')
        task_id = [task_list[0]['id']]

        _kill_task(task_id)


def test_kill_two_tasks():
    with _zero_instance_app():
        start_app('zero-instance-app', 2)
        watch_all_deployments()
        task_list = _list_tasks(2, 'zero-instance-app')
        task_ids = [task['id'] for task in task_list]

        _kill_task(task_ids)


def test_kill_and_scale_task():
    with _zero_instance_app():
        start_app('zero-instance-app', 2)
        watch_all_deployments()
        task_list = _list_tasks(2, 'zero-instance-app')
        task_id = [task_list[0]['id']]

        _kill_task(task_id, scale=True)

        task_list = _list_tasks(1, 'zero-instance-app')


def test_kill_unknown_task():
    with _zero_instance_app():
        start_app('zero-instance-app')
        watch_all_deployments()
        task_id = ['unknown-task-id']

        _kill_task(task_id, expect_success=False)


def test_kill_task_wipe():
    with _zero_instance_app():
        start_app('zero-instance-app', 1)
        watch_all_deployments()
        task_list = _list_tasks(1, 'zero-instance-app')
        task_id = [task_list[0]['id']]

        _kill_task(task_id, wipe=True)


def test_stop_unknown_task():
    with _zero_instance_app():
        start_app('zero-instance-app')
        watch_all_deployments()
        task_id = 'unknown-task-id'

        _stop_task(task_id, expect_success=False)


def test_stop_unknown_task_wipe():
    with _zero_instance_app():
        start_app('zero-instance-app')
        watch_all_deployments()
        task_id = 'unknown-task-id'

        _stop_task(task_id, '--wipe', expect_success=False)


def test_bad_configuration(env):
    with update_config('marathon.url', 'http://localhost:88888', env):
        returncode, stdout, stderr = exec_command(
            ['dcos', 'marathon', 'about'], env=env)

        assert returncode == 1
        assert stdout == b''
        assert stderr.startswith(
            b"URL [http://localhost:88888/v2/info] is unreachable")


def test_app_locked_error():
    with app('tests/data/marathon/apps/sleep_many_instances.json',
             '/sleep-many-instances',
             wait=False):
        stderr = b'Changes blocked: deployment already in progress for app.\n'
        assert_command(
            ['dcos', 'marathon', 'app', 'stop', 'sleep-many-instances'],
            returncode=1,
            stderr=stderr)


@pytest.mark.skipif(sys.platform == 'win32',
                    reason="No pseudo terminal on windows")
def test_app_add_no_tty():
    proc, master = popen_tty('dcos marathon app add')

    stdout, stderr = proc.communicate()
    os.close(master)

    print(stdout)
    print(stderr)

    assert proc.wait() == 1
    assert stdout == b''
    assert stderr == (b"We currently don't support reading from the TTY. "
                      b"Please specify an application JSON.\n"
                      b"E.g.: dcos marathon app add < app_resource.json\n")


def _update_app(app_id, file_path):
    with open(file_path) as fd:
        returncode, stdout, stderr = exec_command(
            ['dcos', 'marathon', 'app', 'update', app_id],
            stdin=fd)

        assert returncode == 0
        assert stdout.decode().startswith('Created deployment ')
        assert stderr == b''


def _list_versions(app_id, expected_min_count, max_count=None):
    cmd = ['dcos', 'marathon', 'app', 'version', 'list', app_id]
    if max_count is not None:
        cmd.append('--max-count={}'.format(max_count))

    returncode, stdout, stderr = exec_command(cmd)

    result = json.loads(stdout.decode('utf-8'))

    assert returncode == 0
    assert isinstance(result, list)
    assert stderr == b''

    # Marathon persists app versions indefinitely by ID, so there may be extras
    assert len(result) >= expected_min_count

    if max_count is not None:
        assert len(result) <= max_count


def _list_tasks(expected_count=None, app_id=None):
    cmd = ['dcos', 'marathon', 'task', 'list', '--json']
    if app_id is not None:
        cmd.append(app_id)

    returncode, stdout, stderr = exec_command(cmd)

    result = json.loads(stdout.decode('utf-8'))

    assert returncode == 0
    if expected_count:
        assert len(result) == expected_count
    assert stderr == b''

    return result


def _stop_task(task_id, wipe=None, expect_success=True):
    cmd = ['dcos', 'marathon', 'task', 'stop', task_id]
    if wipe is not None:
        cmd.append('--wipe')

    returncode, stdout, stderr = exec_command(cmd)

    if expect_success:
        assert returncode == 0
        assert stderr == b''
        result = json.loads(stdout.decode('utf-8'))
        assert result['id'] == task_id
    else:
        assert returncode == 1


def _kill_task(task_ids, scale=None, wipe=None, expect_success=True):
    cmd = ['dcos', 'marathon', 'task', 'kill', '--json'] + task_ids
    if scale:
        cmd.append('--scale')
    if wipe:
        cmd.append('--wipe')
    returncode, stdout, stderr = exec_command(cmd)

    if expect_success:
        assert returncode == 0
        assert stderr == b''
        result = json.loads(stdout.decode('utf-8'))
        if scale:
            assert 'deploymentId' in result
        else:
            assert sorted(
                [task['id'] for task in result['tasks']]) == sorted(task_ids)

    else:
        assert returncode == 1


@contextlib.contextmanager
def _zero_instance_app():
    with app('tests/data/marathon/apps/zero_instance_sleep.json',
             'zero-instance-app'):
        yield


@contextlib.contextmanager
def _zero_instance_app_through_http():
    class JSONRequestHandler (BaseHTTPRequestHandler):

        def do_GET(self):  # noqa: N802
            self.send_response(200)
            self.send_header("Content-type", "application/json")
            self.end_headers()
            self.wfile.write(open(
                'tests/data/marathon/apps/zero_instance_sleep.json',
                'rb').read())

    host = 'localhost'
    port = 12345
    server = HTTPServer((host, port), JSONRequestHandler)
    thread = threading.Thread(target=server.serve_forever)
    thread.setDaemon(True)
    thread.start()

    with app('http://{}:{}'.format(host, port), 'zero-instance-app'):
        try:
            yield
        finally:
            server.shutdown()
