# -*- coding: utf-8 -*-
"""Containerize builds

The docker image used for the build is always tagged like this:
    odoo:runbot_tests
This file contains helpers to containerize builds with Docker.
When testing this file:
    the first parameter should be a directory containing Odoo.
    The second parameter is the exposed port
"""
from .docker_manager import DockerManager, docker
import configparser
import getpass
import io
import logging
import os
import re
import subprocess
import time

from odoo.tools import file_path

USERNAME = getpass.getuser()

_logger = logging.getLogger(__name__)
docker_stop_failures = {}


class Command():

    def __init__(self, pres, cmd, posts, finals=None, config_tuples=None, cmd_checker=None):
        """ Command object that represent commands to run in Docker container
        :param pres: list of pre-commands
        :param cmd: list of main command only run if the pres commands succeed (&&)
        :param posts: list of post commands posts only run if the cmd command succedd (&&)
        :param finals: list of finals commands always executed
        :param config_tuples: list of key,value tuples to write in config file
        :param cmd_checker: a checker object that must have a `_cmd_check` method that will be called at build
        returns a string of the full command line to run
        """
        self.pres = pres or []
        self.cmd = cmd
        self.posts = posts or []
        self.finals = finals or []
        self.config_tuples = config_tuples or []
        self.cmd_checker = cmd_checker

    def set_pres(self, pres):
        self.pres = pres

    def set_cmd(self, cmd):
        self.cmd = cmd

    def set_post(self, posts):
        self.posts = posts

    def __getattr__(self, name):
        return getattr(self.cmd, name)

    def __getitem__(self, key):
        return self.cmd[key]

    def __add__(self, l):
        return Command(self.pres, self.cmd + l, self.posts, self.finals, self.config_tuples, self.cmd_checker)

    def __str__(self):
        return self.shell_join(self.cmd)

    def __repr__(self):
        return self.build().replace('&& ', '&&\n').replace('|| ', '||\n\t').replace(';', ';\n')
        
    def shell_join(self, elems):
        return ' '.join([self.escape(elem) for elem in elems])

    def escape(self, elem):
        if not ' ' in elem or '"' in elem:
            return elem
        return f'"{elem}"'

    def build(self):
        if self.cmd_checker:
            self.cmd_checker._cmd_check(self)
        cmd_chain = []
        cmd_chain += [self.shell_join(pre) for pre in self.pres if pre]
        cmd_chain += [self.shell_join(self.cmd)]
        cmd_chain += [self.shell_join(post) for post in self.posts if post]
        cmd_chain = [' && '.join(cmd_chain)]
        cmd_chain += [self.shell_join(final) for final in self.finals if final]
        return ' ; '.join(cmd_chain)

    def add_config_tuple(self, option, value):
        assert '-' not in option
        self.config_tuples.append((option, value))

    def get_config(self, starting_config=''):
        """ returns a config file content based on config tuples and
            and eventually update the starting config
        """
        config = configparser.ConfigParser()
        config.read_string(starting_config)
        if self.config_tuples and not config.has_section('options'):
            config.add_section('options')
        for option, value in self.config_tuples:
            config.set('options', option, value)
        res = io.StringIO()
        config.write(res)
        res.seek(0)
        return res.read()


def docker_build(build_dir, image_tag):
    return _docker_build(build_dir, image_tag)


def _docker_build(build_dir, image_tag):
    """Build the docker image
    :param build_dir: the build directory that contains Dockerfile.
    :param image_tag: name used to tag the resulting docker image
    :return: tuple(success, msg) where success is a boolean and msg is the error message or None
    """

    with DockerManager(image_tag) as dm:
        last_step = None
        dm.result['success'] = False  # waiting for an image_id
        for chunk in dm.consume(dm.docker_client.api.build(path=build_dir, tag=image_tag, rm=True)):
            if 'stream' in chunk:
                stream = chunk['stream']
                if stream.startswith('Step '):
                    last_step = stream[:120] + '...'
                    if dm.log_progress:
                        _logger.info(last_step)
                if not dm.log_progress and ('running in' in stream or dm.duration > 1):
                    dm.log_progress = True
                    _logger.info('Building dockerfile %s', image_tag)
                    if last_step:
                        _logger.info(last_step)
                match = re.search(
                    r'(^Successfully built |sha256:)([0-9a-f]+)$',
                    chunk['stream'],
                )
                if match:
                    dm.result['image_id'] = match.group(2)
                    dm.result['success'] = True
                    break
        return dm.result


def docker_push(image_tag, push_url='127.0.0.1:5001'):
    return _docker_push(image_tag, push_url)


def _docker_push(image_tag, push_url):
    """Push a Docker image to the localy hosted docker registry
    :param image_tag: the image tag (or id) to push
    :return: tuple(success, msg) where success is a boolean and msg is the error message or None
    """

    with DockerManager(image_tag) as dm:
        image = dm.docker_client.images.get(image_tag)
        push_tag = f'{push_url}/{image_tag}'
        image.tag(push_tag)
        for chunk in dm.consume(dm.docker_client.api.push(push_tag, stream=True)):
            if not dm.log_progress and 'Pushing' in chunk.get('status', ''):
                dm.log_progress = True
                _logger.info('Pushing %s', image_tag)
        return dm.result


def docker_pull(image_tag):
    return _docker_pull(image_tag)


def _docker_pull(image_tag):
    """Pull a docker image from a registry.
    :param image_tag: the full image tag, including the registry host
    e.g.: `dockerhub.runbot102.odoo.com/odoo:PureNobleTest`
    :return: DockerMager.result dict
    """
    with DockerManager(image_tag) as dm:
        for chunk in dm.consume(dm.docker_client.api.pull(image_tag, stream=True)):
            if not dm.log_progress and 'Downloading' in chunk['status']:
                dm.log_progress = True
                _logger.info('Pulling %s', image_tag)
        return dm.result


def docker_remove(image_tag):
    return _docker_remove(image_tag)


def _docker_remove(image_tag):
    docker_client = docker.from_env()
    try:
        docker_client.images.remove(image_tag, force=1)
    except docker.errors.APIError:
        message = f"Docker remove failed for {image_tag}"
        _logger.exception(message)
        return False
    return True


def docker_prune():
    return _docker_prune()


def _docker_prune():
    docker_client = docker.from_env()
    try:
        return docker_client.images.prune()
    except docker.errors.APIError:
        _logger.exception('Docker prune failed')
        return {'ImagesDeleted': None, 'SpaceReclaimed': 0}

def docker_run(*args, **kwargs):
    return _docker_run(*args, **kwargs)


def _docker_run(cmd=False, log_path=False, build_dir=False, container_name=False, image_tag=False, exposed_ports=None, cpu_limit=None, cpu_period=100000, cpus=0, memory=None, preexec_fn=None, ro_volumes=None, env_variables=None):
    """Run tests in a docker container
    :param run_cmd: command string to run in container
    :param log_path: path to the logfile that will contain odoo stdout and stderr
    :param build_dir: the build directory that contains the Odoo sources to build.
                      This directory is shared as a volume with the container
    :param container_name: used to give a name to the container for later reference
    :param image_tag: Docker image tag name to select which docker image to use
    :param exposed_ports: if not None, starting at 8069, ports will be exposed as exposed_ports numbers
    :param cpu_period: Specify the CPU CFS scheduler period, which is used alongside cpu_quota
    :param cpus: used to compute cpu_quota = cpu_period * cpus (equivalent of --cpus in docker CLI)
    :param memory: memory limit in bytes for the container
    :params ro_volumes: dict of dest:source volumes to mount readonly in builddir
    :params env_variables: list of environment variables
    """
    assert cmd and log_path and build_dir and container_name
    image_tag = image_tag or 'odoo:DockerDefault'
    container_name = sanitize_container_name(container_name)
    cmd_str = str(cmd)
    if isinstance(cmd, Command):
        run_cmd = cmd.build()
    else:
        run_cmd = cmd
    run_cmd = f'cd /data/build;touch start-{container_name};{run_cmd};cd /data/build;touch end-{container_name}'
    _logger.info('Docker run command: %s', run_cmd)
    docker_clear_state(container_name, build_dir)  # ensure that no state are remaining
    build_dir = file_path(build_dir)

    file_path(os.path.dirname(log_path))
    open(os.path.join(build_dir, 'exist-%s' % container_name), 'w+').close()
    logs = open(log_path, 'w')
    logs.write("Docker command:\n%s\n=================================================\n" % cmd_str)
    # create start script
    volumes = {
        '/var/run/postgresql': {'bind': '/var/run/postgresql', 'mode': 'rw'},
        f'{build_dir}': {'bind': '/data/build', 'mode': 'rw'},
        f'{log_path}': {'bind': '/data/buildlogs.txt', 'mode': 'rw'}
    }

    if ro_volumes:
        for dest, source in ro_volumes.items():
            logs.write("Adding readonly volume '%s' pointing to %s \n" % (dest, source))
            volumes[source] = {'bind': dest, 'mode': 'ro'}
    logs.close()

    ports = {}
    if exposed_ports:
        for dp, hp in enumerate(exposed_ports, start=8069):
            ports[f'{dp}/tcp'] = ('127.0.0.1', hp)

    ulimits = [docker.types.Ulimit(name='core', soft=0, hard=0)]  # avoid core dump in containers
    if cpu_limit:
        ulimits.append(docker.types.Ulimit(name='cpu', soft=cpu_limit, hard=cpu_limit))

    docker_client = docker.from_env()
    container = docker_client.containers.run(
        image_tag,
        name=container_name,
        volumes=volumes,
        shm_size='128m',
        mem_limit=memory,
        ports=ports,
        ulimits=ulimits,
        cpu_period=cpu_period,
        cpu_quota=int(cpus * cpu_period ) if cpus else None,
        environment=env_variables,
        init=True,
        command=['/bin/bash', '-c',
                 f'exec &>> /data/buildlogs.txt ;{run_cmd}'],
        auto_remove=True,
        detach=True,
        user=USERNAME,
    )
    if container.status not in ('running', 'created') :
        _logger.error('Container %s started but status is not running or created:  %s', container_name, container.status)
    else:
        _logger.info('Started Docker container %s (%s)', container_name, container.short_id)
    return


def docker_stop(container_name, build_dir=None):
    return _docker_stop(container_name, build_dir)


def _docker_stop(container_name, build_dir):
    """Stops the container named container_name"""
    container_name = sanitize_container_name(container_name)
    _logger.info('Stopping container %s', container_name)
    if container_name in docker_stop_failures:
        if docker_stop_failures[container_name] + 60 * 60 < time.time():
            _logger.warning('Removing %s from docker_stop_failures', container_name)
            del docker_stop_failures[container_name]
        else:
            _logger.warning('Skipping %s, is in failure', container_name)
            return
    docker_client = docker.from_env()
    if build_dir:
        end_file = os.path.join(build_dir, 'end-%s' % container_name)
        subprocess.run(['touch', end_file])
    else:
        _logger.info('Stopping docker without defined build_dir')
    try:
        container = docker_client.containers.get(container_name)
        container.stop(timeout=1)
        return
    except docker.errors.NotFound:
        _logger.error('Cannnot stop container %s. Container not found', container_name)
    except docker.errors.APIError as e:
        _logger.error('Cannnot stop container %s. API Error "%s"', container_name, e)
    docker_stop_failures[container_name] = time.time()


def docker_state(container_name, build_dir):
    container_name = sanitize_container_name(container_name)
    exist = os.path.exists(os.path.join(build_dir, 'exist-%s' % container_name))
    started = os.path.exists(os.path.join(build_dir, 'start-%s' % container_name))

    if not exist:
        return 'VOID'

    if os.path.exists(os.path.join(build_dir, f'end-{container_name}')):
        return 'END'

    state = 'UNKNOWN'
    if started:
        docker_client = docker.from_env()
        try:
            container = docker_client.containers.get(container_name)
            # possible statuses: created, restarting, running, removing, paused, exited, or dead
            state = 'RUNNING' if container.status in ('created', 'running', 'paused') else 'GHOST'
        except docker.errors.NotFound:
            state = 'GHOST'
        # check if the end- file has been written in between time
        if state == 'GHOST' and os.path.exists(os.path.join(build_dir, f'end-{container_name}')):
            state = 'END'
    return state


def docker_clear_state(container_name, build_dir):
    """Return True if container is still running"""
    container_name = sanitize_container_name(container_name)
    if os.path.exists(os.path.join(build_dir, 'start-%s' % container_name)):
        os.remove(os.path.join(build_dir, 'start-%s' % container_name))
    if os.path.exists(os.path.join(build_dir, 'end-%s' % container_name)):
        os.remove(os.path.join(build_dir, 'end-%s' % container_name))
    if os.path.exists(os.path.join(build_dir, 'exist-%s' % container_name)):
        os.remove(os.path.join(build_dir, 'exist-%s' % container_name))


def docker_get_gateway_ip():
    """Return the host ip of the docker default bridge gateway"""
    docker_client = docker.from_env()
    try:
        bridge_net = docker_client.networks.get([n.id for n in docker_client.networks.list('bridge')][0])
        return bridge_net.attrs['IPAM']['Config'][0]['Gateway']
    except (KeyError, IndexError):
        return None


def docker_ps():
    return _docker_ps()


def _docker_ps():
    """Return a list of running containers names"""
    docker_client = docker.client.from_env()
    return [c.name for c in docker_client.containers.list()]


def docker_images():
    return _docker_images()


def _docker_images():
    """Return a list of running existing images"""
    docker_client = docker.client.from_env()
    return [c for c in docker_client.images.list()]


def sanitize_container_name(name):
    """Returns a container name with unallowed characters removed"""
    name = re.sub('^[^a-zA-Z0-9]+', '', name)
    return re.sub('[^a-zA-Z0-9_.-]', '', name)
