# Copyright 2015 Sam Yaple
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# FIXME(yoctozepto): restart_policy is *not* checked in the container

import docker
import json
import os

from ansible.module_utils.kolla_container_worker import COMPARE_CONFIG_CMD
from ansible.module_utils.kolla_container_worker import ContainerWorker

from distutils.version import StrictVersion


def get_docker_client():
    return docker.APIClient


class DockerWorker(ContainerWorker):

    def __init__(self, module):
        super().__init__(module)

        options = {
            'version': self.params.get('api_version'),
            'timeout': self.params.get('client_timeout'),
        }

        self.dc = get_docker_client()(**options)

        self._cgroupns_mode_supported = (
            StrictVersion(self.dc._version) >= StrictVersion('1.41'))
        self._dimensions_kernel_memory_removed = (
            StrictVersion(self.dc._version) >= StrictVersion('1.42'))

        if self._dimensions_kernel_memory_removed:
            self.dimension_map.pop('kernel_memory', None)

    def generate_tls(self):
        tls = {'verify': self.params.get('tls_verify')}
        tls_cert = self.params.get('tls_cert'),
        tls_key = self.params.get('tls_key'),
        tls_cacert = self.params.get('tls_cacert')

        if tls['verify']:
            if tls_cert:
                self.check_file(tls_cert)
                self.check_file(tls_key)
                tls['client_cert'] = (tls_cert, tls_key)
            if tls_cacert:
                self.check_file(tls_cacert)
                tls['verify'] = tls_cacert

        return docker.tls.TLSConfig(**tls)

    def check_file(self, path):
        if not os.path.isfile(path):
            self.module.fail_json(
                failed=True,
                msg='There is no file at "{}"'.format(path)
            )
        if not os.access(path, os.R_OK):
            self.module.fail_json(
                failed=True,
                msg='Permission denied for file at "{}"'.format(path)
            )

    def check_image(self):
        find_image = ':'.join(self.parse_image())
        for image in self.dc.images():
            repo_tags = image.get('RepoTags')
            if not repo_tags:
                continue
            for image_name in repo_tags:
                if image_name == find_image:
                    return image

    def check_volume(self):
        for vol in self.dc.volumes()['Volumes'] or list():
            if vol['Name'] == self.params.get('name'):
                return vol

    def check_container(self):
        find_name = '/{}'.format(self.params.get('name'))
        for cont in self.dc.containers(all=True):
            if find_name in cont['Names']:
                return cont

    def get_container_info(self):
        container = self.check_container()
        if not container:
            return None
        return self.dc.inspect_container(self.params.get('name'))

    def compare_pid_mode(self, container_info):
        new_pid_mode = self.params.get('pid_mode')
        current_pid_mode = container_info['HostConfig'].get('PidMode')
        if not current_pid_mode:
            current_pid_mode = None

        if new_pid_mode != current_pid_mode:
            return True

    def compare_image(self, container_info=None):
        container_info = container_info or self.get_container_info()
        parse_repository_tag = docker.utils.parse_repository_tag
        if not container_info:
            return True
        new_image = self.check_image()
        current_image = container_info['Image']
        if not new_image:
            return True
        if new_image['Id'] != current_image:
            return True
        # NOTE(Jeffrey4l) when new image and the current image have
        # the same id, but the tag name different.
        elif (parse_repository_tag(container_info['Config']['Image']) !=
              parse_repository_tag(self.params.get('image'))):
            return True

    def compare_volumes(self, container_info):
        volumes, binds = self.generate_volumes()
        current_vols = container_info['Config'].get('Volumes')
        current_binds = container_info['HostConfig'].get('Binds')
        if not volumes:
            volumes = list()
        if not current_vols:
            current_vols = list()
        if not current_binds:
            current_binds = list()

        if set(volumes).symmetric_difference(set(current_vols)):
            return True

        new_binds = list()
        if binds:
            for k, v in binds.items():
                new_binds.append("{}:{}:{}".format(k, v['bind'], v['mode']))

        if set(new_binds).symmetric_difference(set(current_binds)):
            return True

    def compare_config(self):
        try:
            job = self.dc.exec_create(
                self.params['name'],
                COMPARE_CONFIG_CMD,
                user='root',
            )
            output = self.dc.exec_start(job)
            exec_inspect = self.dc.exec_inspect(job)
        except docker.errors.APIError as e:
            # NOTE(yoctozepto): If we have a client error, then the container
            # cannot be used for config check (e.g., is restarting, or stopped
            # in the mean time) - assume config is stale = return True.
            # Else, propagate the server error back.
            if e.is_client_error():
                return True
            else:
                raise
        # Exit codes:
        # 0: not changed
        # 1: changed
        # 137: abrupt exit -> changed
        # else: error
        if exec_inspect['ExitCode'] == 0:
            return False
        elif exec_inspect['ExitCode'] == 1:
            return True
        elif exec_inspect['ExitCode'] == 137:
            # NOTE(yoctozepto): This is Docker's command exit due to container
            # exit. It means the container is unstable so we are better off
            # marking it as requiring a restart due to config update.
            return True
        else:
            raise Exception('Failed to compare container configuration: '
                            'ExitCode: %s Message: %s' %
                            (exec_inspect['ExitCode'], output))

    def get_image_id(self):
        full_image = self.params.get('image')

        image = self.dc.images(name=full_image, quiet=True)
        return image[0] if len(image) == 1 else None

    def pull_image(self):
        if self.params.get('auth_username'):
            self.dc.login(
                username=self.params.get('auth_username'),
                password=self.params.get('auth_password'),
                registry=self.params.get('auth_registry'),
                email=self.params.get('auth_email')
            )

        image, tag = self.parse_image()
        old_image_id = self.get_image_id()

        statuses = [
            json.loads(line.strip().decode('utf-8')) for line in self.dc.pull(
                repository=image, tag=tag, stream=True
            )
        ]

        for status in reversed(statuses):
            if 'error' in status:
                if status['error'].endswith('not found'):
                    self.module.fail_json(
                        msg="The requested image does not exist: {}:{}".format(
                            image, tag),
                        failed=True
                    )
                else:
                    self.module.fail_json(
                        msg="Unknown error message: {}".format(
                            status['error']),
                        failed=True
                    )

        new_image_id = self.get_image_id()
        self.changed = old_image_id != new_image_id

    def remove_container(self):
        self.changed |= self.systemd.remove_unit_file()
        if self.check_container():
            self.changed = True
            # NOTE(jeffrey4l): in some case, docker failed to remove container
            # filesystem and raise error.  But the container info is
            # disappeared already. If this happens, assume the container is
            # removed.
            try:
                self.dc.remove_container(
                    container=self.params.get('name'),
                    force=True
                )
                self.systemd.remove_unit_file()
            except docker.errors.APIError:
                if self.check_container():
                    raise

    def parse_dimensions(self, dimensions):
        # When the data object contains types such as
        # docker.types.Ulimit, Ansible will fail when these are
        # returned via exit_json or fail_json. HostConfig is derived from dict,
        # but its constructor requires additional arguments.
        # to avoid that, here do copy the dimensions and return a new one.
        dimensions = dimensions.copy()

        supported = {'cpu_period', 'cpu_quota', 'cpu_shares',
                     'cpuset_cpus', 'cpuset_mems', 'mem_limit',
                     'mem_reservation', 'memswap_limit',
                     'kernel_memory', 'blkio_weight', 'ulimits'}
        unsupported = set(dimensions) - supported
        if unsupported:
            self.module.exit_json(failed=True,
                                  msg=repr("Unsupported dimensions"),
                                  unsupported_dimensions=unsupported)

        ulimits = dimensions.get('ulimits')
        if ulimits:
            dimensions['ulimits'] = self.build_ulimits(ulimits)

        return dimensions

    def build_ulimits(self, ulimits):
        ulimits_opt = []
        for key, value in ulimits.items():
            soft = value.get('soft')
            hard = value.get('hard')
            ulimits_opt.append(docker.types.Ulimit(name=key,
                                                   soft=soft,
                                                   hard=hard))
        return ulimits_opt

    def build_host_config(self, binds):
        options = {
            'network_mode': 'host',
            'ipc_mode': self.params.get('ipc_mode'),
            'cap_add': self.params.get('cap_add'),
            'security_opt': self.params.get('security_opt'),
            'pid_mode': self.params.get('pid_mode'),
            'privileged': self.params.get('privileged'),
            'tmpfs': self.generate_tmpfs(),
            'volumes_from': self.params.get('volumes_from')
        }

        dimensions = self.params.get('dimensions')

        if dimensions:
            dimensions = self.parse_dimensions(dimensions)
            options.update(dimensions)

        if binds:
            options['binds'] = binds

        host_config = self.dc.create_host_config(**options)

        if self._cgroupns_mode_supported:
            # NOTE(yoctozepto): python-docker does not support CgroupnsMode
            # natively so we stuff it in manually.
            cgroupns_mode = self.params.get('cgroupns_mode')
            if cgroupns_mode is not None:
                host_config['CgroupnsMode'] = cgroupns_mode

        # detached containers should only log to journald
        if self.params.get('detach'):
            options['log_config'] = docker.types.LogConfig(
                type=docker.types.LogConfig.types.NONE)

        return host_config

    def _inject_env_var(self, environment_info):
        newenv = {
            'KOLLA_SERVICE_NAME': self.params.get('name').replace('_', '-')
        }
        environment_info.update(newenv)
        return environment_info

    def _format_env_vars(self):
        env = self._inject_env_var(self.params.get('environment'))
        return {k: "" if env[k] is None else env[k] for k in env}

    def build_container_options(self):
        volumes, binds = self.generate_volumes()

        options = {
            'command': self.params.get('command'),
            'detach': self.params.get('detach'),
            'environment': self._format_env_vars(),
            'host_config': self.build_host_config(binds),
            'labels': self.params.get('labels'),
            'image': self.params.get('image'),
            'name': self.params.get('name'),
            'volumes': volumes,
            'tty': self.params.get('tty'),
        }

        healthcheck = self.parse_healthcheck(self.params.get('healthcheck'))
        if healthcheck:
            options.update(healthcheck)

        return options

    def create_container(self):
        self.changed = True
        options = self.build_container_options()
        self.dc.create_container(**options)
        if self.params.get('restart_policy') != 'no':
            self.changed |= self.systemd.create_unit_file()

    def recreate_or_restart_container(self):
        self.changed = True
        container = self.check_container()
        # get config_strategy from env
        environment = self.params.get('environment')
        config_strategy = environment.get('KOLLA_CONFIG_STRATEGY')

        if not container:
            self.start_container()
            return
        # If config_strategy is COPY_ONCE or container's parameters are
        # changed, try to start a new one.
        if config_strategy == 'COPY_ONCE' or self.check_container_differs():
            # NOTE(mgoddard): Pull the image if necessary before stopping the
            # container, otherwise a failure to pull the image will leave the
            # container stopped.
            if not self.check_image():
                self.pull_image()
            self.stop_container()
            self.remove_container()
            self.start_container()
        elif config_strategy == 'COPY_ALWAYS':
            self.restart_container()

    def start_container(self):
        if not self.check_image():
            self.pull_image()

        container = self.check_container()
        if container and self.check_container_differs():
            self.stop_container()
            self.remove_container()
            container = self.check_container()

        if not container:
            self.create_container()
            container = self.check_container()

        if not container['Status'].startswith('Up '):
            self.changed = True
            if self.params.get('restart_policy') == 'no':
                self.dc.start(container=self.params.get('name'))
            else:
                self.systemd.create_unit_file()
                if not self.systemd.start():
                    self.module.fail_json(
                        changed=True,
                        msg="Container timed out",
                        **self.check_container())

        # We do not want to detach so we wait around for container to exit
        if not self.params.get('detach'):
            rc = self.dc.wait(self.params.get('name'))
            # NOTE(jeffrey4l): since python docker package 3.0, wait return a
            # dict all the time.
            if isinstance(rc, dict):
                rc = rc['StatusCode']
            # Include container's return code, standard output and error in the
            # result.
            self.result['rc'] = rc
            self.result['stdout'] = self.dc.logs(self.params.get('name'),
                                                 stdout=True, stderr=False)
            self.result['stderr'] = self.dc.logs(self.params.get('name'),
                                                 stdout=False, stderr=True)
            if self.params.get('remove_on_exit'):
                self.stop_container()
                self.remove_container()
            if rc != 0:
                self.module.fail_json(
                    changed=True,
                    msg="Container exited with non-zero return code %s" % rc,
                    **self.result
                )

    def stop_container(self):
        name = self.params.get('name')
        graceful_timeout = self.params.get('graceful_timeout')
        if not graceful_timeout:
            graceful_timeout = 10
        container = self.check_container()
        if not container:
            ignore_missing = self.params.get('ignore_missing')
            if not ignore_missing:
                self.module.fail_json(
                    msg="No such container: {} to stop".format(name))
        elif not container['Status'].startswith('Exited '):
            self.changed = True
            if not self.systemd.check_unit_file():
                self.dc.stop(name, timeout=graceful_timeout)
            else:
                self.systemd.stop()

    def stop_and_remove_container(self):
        container = self.check_container()
        if container:
            self.stop_container()
            self.remove_container()

    def restart_container(self):
        name = self.params.get('name')
        graceful_timeout = self.params.get('graceful_timeout')
        if not graceful_timeout:
            graceful_timeout = 10
        info = self.get_container_info()
        if not info:
            self.module.fail_json(
                msg="No such container: {}".format(name))
        else:
            self.changed = True
            if self.params.get('restart_policy') != 'no':
                self.systemd.create_unit_file()
                if not self.systemd.restart():
                    self.module.fail_json(
                        changed=True,
                        msg="Container timed out",
                        **self.check_container())
            else:
                self.dc.stop(name, timeout=graceful_timeout)
                self.dc.start(name)

    def create_volume(self):
        if not self.check_volume():
            self.changed = True
            self.dc.create_volume(name=self.params.get('name'), driver='local')

    def remove_volume(self):
        if self.check_volume():
            self.changed = True
            try:
                self.dc.remove_volume(name=self.params.get('name'))
            except docker.errors.APIError as e:
                if e.response.status_code == 409:
                    self.module.fail_json(
                        failed=True,
                        msg="Volume named '{}' is currently in-use".format(
                            self.params.get('name')
                        )
                    )
                raise

    def remove_image(self):
        if self.check_image():
            self.changed = True
            try:
                self.dc.remove_image(image=self.params.get('image'))
            except docker.errors.APIError as e:
                if e.response.status_code == 409:
                    self.module.fail_json(
                        failed=True,
                        msg="Image '{}' is currently in-use".format(
                            self.params.get('image')
                        )
                    )
                elif e.response.status_code == 500:
                    self.module.fail_json(
                        failed=True,
                        msg="Server error"
                    )
                raise

    def ensure_image(self):
        if not self.check_image():
            self.pull_image()
