from abc import abstractmethod, ABCMeta
from catcher.steps.external_step import ExternalStep
from catcher.steps.step import update_variables
from catcher.utils.logger import debug


class DockerCmd(metaclass=ABCMeta):
    @abstractmethod
    def action(self, variables):
        pass


class NetworkBasedCmd(metaclass=ABCMeta):
    def __init__(self, network=None, **kwargs) -> None:
        super().__init__()
        self._network = network

    def network(self, variables):
        return self._ensure_network(self._network or variables['TEST_NAME'])

    @staticmethod
    def _ensure_network(name: str):
        import docker
        client = docker.from_env()
        filtered = client.networks.list(names=[name])
        if not filtered:
            return client.networks.create(name, driver="bridge").id
        return filtered[0].id


class DeviceBasedCmd(metaclass=ABCMeta):
    def __init__(self, device_requests=None, **kwargs) -> None:
        super().__init__()
        self._device_requests = device_requests

    def get_device_requests(self):
        if self._device_requests is None: return []
        reqs = []
        for req in self._device_requests:
            reqs.append(self._get_device_request(driver=req.get('driver'),
                                                count=req.get('count'),
                                                device_ids=req.get('device_ids'),
                                                capabilities=req.get('capabilities'),
                                                options=req.get('options')))
        return reqs

    @staticmethod
    def _get_device_request(driver=None, count=None, device_ids=None, capabilities=None, options=None):
        import docker
        return docker.types.DeviceRequest(driver=driver,
                                        count=count,
                                        device_ids=device_ids,
                                        capabilities=capabilities,
                                        options=options)


class IdBasedCmd:
    def __init__(self, **kwargs: dict) -> None:
        self._id = kwargs.get('name', kwargs.get('hash'))
        if self._id is None:
            raise ValueError('no id for container')

    def get_container(self):
        import docker
        client = docker.from_env()
        return client.containers.get(self._id)


class StopCmd(IdBasedCmd, DockerCmd):
    def __init__(self, delete=False, **kwargs: dict) -> None:
        IdBasedCmd.__init__(self, **kwargs)
        self.delete = delete

    def action(self, variables):
        container = self.get_container()
        res = container.stop()
        if self.delete:
            debug('Removing {}'.format(container.name))
            container.remove()
        return res


class StatusCmd(IdBasedCmd, DockerCmd):
    def action(self, variables):
        return self.get_container().status


class InspectCmd(IdBasedCmd, DockerCmd):
    def action(self, variables):
        return self.get_container().attrs


class DisconnectCmd(IdBasedCmd, NetworkBasedCmd, DockerCmd):
    def __init__(self, **kwargs: dict) -> None:
        IdBasedCmd.__init__(self, **kwargs)
        NetworkBasedCmd.__init__(self, **kwargs)

    def action(self, variables):
        import docker
        client = docker.from_env()
        container = self.get_container()
        if not container or container.status == 'exited':
            raise ValueError('Container exited; Can\'t disconnect.')
        return client.networks.get(self.network(variables)).disconnect(container)


class ConnectCmd(IdBasedCmd, NetworkBasedCmd,  DockerCmd):
    def __init__(self, **kwargs: dict) -> None:
        IdBasedCmd.__init__(self, **kwargs)
        NetworkBasedCmd.__init__(self, **kwargs)

    def action(self, variables: str):
        import docker
        client = docker.from_env()
        container = self.get_container()
        if not container or container.status == 'exited':
            raise ValueError('Container exited; can\'t connect.')
        return client.networks.get(self.network(variables)).connect(container)


class LogsCmd(IdBasedCmd, DockerCmd):
    def action(self, variables):
        return self.get_container().logs().decode()


class StartCmd(NetworkBasedCmd, DeviceBasedCmd, DockerCmd):
    def __init__(self, image: str, **kwargs: dict) -> None:
        super().__init__(**kwargs)
        self._image = image
        self._name = kwargs.get('name')
        self._cmd = kwargs.get('cmd')
        self._detached = kwargs.get('detached', True)
        self._ports = kwargs.get('ports')
        self._env = kwargs.get('environment')
        self._volumes = kwargs.get('volumes', {})
        self._extra_hosts = kwargs.get('extra_hosts', {})
        self._device_requests = kwargs.get('device_requests', [])

    def action(self, variables):
        import docker
        client = docker.from_env()
        volumes = dict([(k, {'bind': v, 'mode': 'rw'}) for k, v in self._volumes.items()])
        output = client.containers.run(self._image,
                                       self._cmd,
                                       name=self._name,
                                       detach=self._detached,
                                       network=self.network(variables),
                                       ports=self._ports,
                                       environment=self._env,
                                       volumes=volumes,
                                       extra_hosts=self._extra_hosts,
                                       device_requests=self.get_device_requests())
        if not self._detached:
            return output.decode()
        else:
            return output.id


class ExecCmd(IdBasedCmd, DockerCmd):
    def __init__(self, cmd: str, **kwargs: dict) -> None:
        super().__init__(**kwargs)
        self._cmd = cmd
        self._dir = kwargs.get('dir')
        self._user = kwargs.get('user', 'root')
        self._env = kwargs.get('environment', None)

    def action(self, variables):
        res = self.get_container().exec_run(cmd=self._cmd,
                                            workdir=self._dir,
                                            user=self._user,
                                            environment=self._env)
        if res.exit_code != 0:
            raise Exception(res.output.decode())
        return res.output.decode()


class CmdFactory:
    @staticmethod
    def get_cmd(command: dict) -> DockerCmd:
        if 'start' in command:
            return StartCmd(**command['start'])
        if 'exec' in command:
            return ExecCmd(**command['exec'])
        if 'stop' in command:
            return StopCmd(**command['stop'])
        if 'status' in command:
            return StatusCmd(**command['status'])
        if 'connect' in command:
            return ConnectCmd(**command['connect'])
        if 'disconnect' in command:
            return DisconnectCmd(**command['disconnect'])
        if 'logs' in command:
            return LogsCmd(**command['logs'])
        if 'inspect' in command:
            return InspectCmd(**command['inspect'])
        raise ValueError('Unknown command: ' + str(command))


class Docker(ExternalStep):
    """
    Allows you to start/stop/disconnect/connect/exec commands, get logs and statuses of
    `Docker <https://www.docker.com/>`_ containers.
    Is very useful when you need to run something like `Mockserver <https://www.mock-server.com/>`_
    and/or simulate network disconnects.

    :Input:

    :start: run container. Return hash.

    - image: container's image.
    - name: container's name. *Optional*
    - cmd: command to run in the container. *Optional*
    - detached: should it be run detached? *Optional* (default is True)
    - ports: dictionary of ports to bind. Keys - container ports, values - host ports.
    - environment: a dictionary of environment variables
    - volumes: a dictionary of volumes
    - network: network name. *Optional* (default is current test's name)
    - extra_hosts: a dictionary of extra hosts (see Docker docs)
    - device_requests: a list of dictionaries. Keys and values like in docker.types.DeviceRequest args

    :stop: stop a container.

    - name: container's name. *Optional*
    - hash: container's hash. *Optional* Either name or hash should present
    - delete: delete a container. *Optional* (default is false)

    :status: get the container status.

    - name: container's name. *Optional*
    - hash: container's hash. *Optional* Either name or hash should present

    :disconnect: disconnect a container from a network (network failure simulation)

    - name: container's name. *Optional*
    - hash: container's hash. *Optional* Either name or hash should present
    - network: network name. *Optional* (default is current test's name)

    :connect: connect a container to a network. All containers share the same network per test.

    - name: container's name. *Optional*
    - hash: container's hash. *Optional* Either name or hash should present
    - network: network name. *Optional* (default is current test's name)

    :exec: execute a command inside a running container.

    - name: container's name. *Optional*
    - hash: container's hash. *Optional* Either name or hash should present
    - cmd: command to execute.
    - dir: directory, where this command will be executed. *Optional*
    - user: user to execute this command. *Optional* (default is root)
    - environment: a dictionary of environment variables

    :logs: get container's logs.

    - name: container's name. *Optional*
    - hash: container's hash. *Optional* Either name or hash should present

    :inspect: get container's inspect information

    - name: container's name. *Optional*
    - hash: container's hash. *Optional* Either name or hash should present

    :Useful hack:
    if you are going to run docker step from a docker image - you'd need to mount your host's docker
    ``/var/run/docker.sock`` directory to the catcher image. docker installation is not included in the catcher's docker
    image to avoid docker-in-docker problem.


    :Examples:

    Run blocking command in a new container and check the output.
    ::

        steps:
            - docker:
                start:
                    image: 'alpine'
                    cmd: 'echo hello world'
                    detached: false
                register: {echo: '{{ OUTPUT.strip() }}'}
            - check:
                equals: {the: '{{ echo }}', is: 'hello world'}

    Start named container detached with volumes, environment, extra host and GPUs.
    ::

        - docker:
            start:
                image: 'my-backend-service'
                name: 'mock server'
                ports:
                    '1080/tcp': 8000
                environment:
                    POOL_SIZE: 20
                    OTHER_URL: {{ service1.url }}
                volumes:
                    '{{ CURRENT_DIR }}/data': '/data'
                    '/tmp/logs': '/var/log/service'
                extra_hosts: { host1: '{{ HOST1_IP }}' }
                device_requests: [ { count: -1, capabilities: '[["gpu"]]' } ]

    Exec command on running container.
    ::

        - docker:
            start:
                image: 'postgres:alpine'
                environment:
                    POSTGRES_PASSWORD: test
                    POSTGRES_USER: user
                    POSTGRES_DB: test
            register: {hash: '{{ OUTPUT }}'}
        ...
        - docker:
            exec:
                hash: '{{ hash }}'
                cmd: >
                    psql -U user -d test -c \
                    "CREATE TABLE test(rno integer, name character varying)"
            register: {create_result: '{{ OUTPUT.strip() }}'}

    Get container's logs.
    ::

        - docker:
            start:
                image: 'alpine'
                cmd: 'echo hello world'
            register: {id: '{{ OUTPUT }}'}
        - docker:
            logs:
                hash: '{{ id }}'
            register: {out: '{{ OUTPUT.strip() }}'}
        - check:
            equals: {the: '{{ out }}', is: 'hello world'}

    Disconnect a container from a network.
    ::

        - docker:
            disconnect:
                hash: '{{ hash }}'
        - http:
            get:
                url: 'http://localhost:8000/some/path'
                should_fail: true
        - docker:
            connect:
                hash: '{{ hash }}'

    """

    @update_variables
    def action(self, includes: dict, variables: dict) -> dict or tuple:
        cmd = CmdFactory.get_cmd(self.simple_input(variables))
        out = cmd.action(variables)
        return variables, out
