import functools
import json
import logging
import datetime
import traceback
from operator import attrgetter

from sen.constants import ISO_DATETIME_PARSE_STRING
from sen.exceptions import (
    TerminateApplication, NotifyError, NotAvailableAnymore
)

import docker
import docker.errors

from sen.net import NetData
from sen.util import (
    calculate_cpu_percent, calculate_cpu_percent2, calculate_blkio_bytes,
    calculate_network_bytes, repeater,
    humanize_time,
    graceful_chain_get
)

logger = logging.getLogger(__name__)


DINOSAUR_TIME = datetime.datetime.fromordinal(1)


class ImageNameStruct(object):
    """
    stolen from atomic-reactor; thanks @mmilata!
    """
    def __init__(self, registry=None, namespace=None, repo=None, tag=None):
        self.registry = registry
        self.namespace = namespace
        self.repo = repo
        self.tag = tag

    @classmethod
    def parse(cls, image_name):
        result = cls()

        # registry.org/namespace/repo:tag
        s = image_name.split('/', 2)

        if len(s) == 2:
            if '.' in s[0] or ':' in s[0]:
                result.registry = s[0]
            else:
                result.namespace = s[0]
        elif len(s) == 3:
            result.registry = s[0]
            result.namespace = s[1]
        if result.namespace == 'library':
            # https://github.com/projectatomic/atomic-reactor/issues/45
            logger.debug("namespace 'library' -> ''")
            result.namespace = None
        result.repo = s[-1]

        try:
            result.repo, result.tag = result.repo.rsplit(':', 1)
        except ValueError:
            pass

        return result

    def to_str(self, registry=True, tag=True, explicit_tag=False,
               explicit_namespace=False):
        if self.repo is None:
            raise RuntimeError('No image repository specified')

        result = self.repo if self.repo != "<none>" else ""

        # don't display <none> junk
        if tag and self.tag and self.tag != "<none>":
            result = '{0}:{1}'.format(result, self.tag)
        elif tag and explicit_tag and self.tag != "<none>":
            result = '{0}:{1}'.format(result, 'latest')

        # don't display <none> junk
        if self.namespace:
            result = '{0}/{1}'.format(self.namespace, result)
        elif explicit_namespace:
            result = '{0}/{1}'.format('library', result)

        if registry and self.registry:
            result = '{0}/{1}'.format(self.registry, result)

        return result

    def __str__(self):
        return self.to_str(registry=True, tag=True)

    def __repr__(self):
        return "ImageName(image=%s)" % repr(self.to_str())

    def __eq__(self, other):
        return type(self) == type(other) and self.__dict__ == other.__dict__

    def __ne__(self, other):
        return not self == other

    def __hash__(self):
        return hash(self.to_str())

    def copy(self):
        return ImageNameStruct(
            registry=self.registry,
            namespace=self.namespace,
            repo=self.repo,
            tag=self.tag)


def operation(fmt_str):
    def wrap(func):
        @functools.wraps(func)
        def wrapper(self, *args, **kwargs):
            pretty_message = ""

            before = datetime.datetime.now()
            response = func(self, *args, **kwargs)
            after = datetime.datetime.now()

            # we want milliseconds, not seconds
            command_took = (after - before).total_seconds() * 1000
            # # this line literally break zsh; wat?
            # logger.debug("%s(%s, %s) %s -> [%f ms]", func.__name__, args, kwargs, self,
            #              command_took)
            if fmt_str:
                pretty_message = fmt_str.format(object_type=getattr(self, "pretty_object_type", ""),
                                                object_short_name=getattr(self, "short_name", ""))
            return Operation(response, pretty_message=pretty_message, took=command_took)
        return wrapper
    return wrap


class Operation:
    """
    class for describing performed operation
    """

    def __init__(self, response, pretty_message="", took=None):
        self.response = response
        self.pretty_message = pretty_message
        self.took = took


class DockerObject:
    """
    Common base for images and containers
    """
    def __init__(self, data, docker_backend, object_id=None):
        self._id = object_id
        self._short_id = None
        self.data = data  # `client.containers` or `client.images`
        self.docker_backend = docker_backend
        self._created = None
        self._inspect = None
        self._names = None

    @property
    def d(self):
        """
        shortcut for instance of Docker client
        """
        return self.docker_backend.client

    @property
    def created_int(self):
        return self.data["Created"]

    @property
    def created(self):
        if self._created is None:
            self._created = datetime.datetime.fromtimestamp(self.data["Created"])
        return self._created

    def set_id(self):
        if self._id is None:
            try:
                self._id = self.data["Id"]
            except KeyError:
                raise RuntimeError("initial data not specified")

    @property
    def object_id(self):
        if self._id is None:
            self.set_id()
        return self._id

    @property
    def short_id(self):
        if self._short_id is None:
            self.set_id()
            if ":" in self._id:
                colon_index = self._id.index(":") + 1
                self._short_id = self._id[colon_index:][:12]
            else:
                self._short_id = self._id[:12]
        return self._short_id

    def display_time_created(self):
        return humanize_time(self.created)

    def display_formal_time_created(self):
        # http://tools.ietf.org/html/rfc2822.html#section-3.3
        return self.created.strftime("%d %b %Y, %H:%M:%S")

    def inspect(self, cached=True):
        raise NotImplementedError()

    def display_inspect(self):
        try:
            return json.dumps(self.inspect().response, indent=2)
        except docker.errors.NotFound:
            raise NotAvailableAnymore()

    @property
    def labels(self):
        labels = self.data["Labels"]
        return labels

    @property
    def natural_sort_value(self):
        return self.created

    def metadata_get(self, path, cached=True):
        """
        get metadata from inspect, specified by path

        :param path: list of str
        :param cached: bool, use cached version of inspect if available
        """
        try:
            value = graceful_chain_get(self.inspect(cached=cached).response, *path)
        except docker.errors.NotFound:
            logger.warning("object %s is not available anymore", self)
            raise NotAvailableAnymore()
        return value

    def refresh(self):
        """ refresh metadata """
        self.inspect(cached=False)

    def __eq__(self, other):
        return type(self) == type(other) and self._id == other._id

    def __ne__(self, other):
        return not self == other

    def __hash__(self):
        return hash(self._id)


class DockerImage(DockerObject):
    def __init__(self, data, docker_backend, object_id=None):
        super().__init__(data, docker_backend, object_id=object_id)
        self._unique_size = None
        self._total_size = None
        self._virtual_size = None
        self._shared_size = None

    @property
    def image_id(self):
        return self.object_id

    @property
    def parent_id(self):
        if self.data:
            return self.data.get("ParentId", None)
        else:
            return self.metadata_get(["Parent"])

    @property
    def pretty_object_type(self):
        return "Image"

    @property
    def parent_image(self):
        try:
            parent_id = self.parent_id
        except Exception as ex:
            logger.error("error while getting parent ID of image %s: %r", self, ex)
            logger.info(traceback.format_exc())
            raise
        if parent_id:
            return self.docker_backend.get_image_by_id(parent_id)
        else:
            return self.docker_backend.scratch_image

    @property
    def layers(self):
        """
        similar as parent images, except that it uses /history API endpoint
        :return:
        """
        # sample output:
        # {
        #     "Created": 1457116802,
        #     "Id": "sha256:507cb13a216097710f0d234668bf64a4c92949c573ba15eba13d05aad392fe04",
        #     "Size": 204692029,
        #     "Tags": [
        #         "docker.io/fedora:latest"
        #     ],
        #     "Comment": "",
        #     "CreatedBy": "/bin/sh -c #(nop) ADD file:bcb5e5c... in /"
        # }
        try:
            response = self.d.history(self.image_id)
        except docker.errors.NotFound:
            raise NotAvailableAnymore()

        layers = []
        for l in response:
            layer_id = l["Id"]
            if layer_id == "<missing>":
                layers.append(DockerImage(l, self.docker_backend))
            else:
                layers.append(self.docker_backend.get_image_by_id(layer_id))
        return layers

    @property
    def children(self):
        return self.docker_backend.get_images_for_parent(self)

    def get_next_sibling(self):
        imgs = self.parent_image.children
        if len(imgs) == 1:
            return None
        try:
            return imgs[imgs.index(self) + 1]
        except IndexError:
            return None

    def get_prev_sibling(self):
        imgs = self.parent_image.children
        if len(imgs) == 1:
            return None
        # 0 - 1 turns into -1 which turns into last element which creates cycle
        # which totally messes up whole tree
        prev_index = imgs.index(self) - 1
        if prev_index < 0:
            return None
        else:
            return imgs[prev_index]

    @property
    def command(self):
        cmd = self.metadata_get(["Config", "Cmd"])
        if cmd:
            return " ".join(cmd)
        return ""

    @property
    def container_command(self):
        # history item
        created_by = graceful_chain_get(self.data, "CreatedBy")
        if created_by:
            return created_by
        try:
            cmd = self.metadata_get(["ContainerConfig", "Cmd"])
        except NotAvailableAnymore:
            pass
        else:
            if cmd:
                return " ".join(cmd)
        return ""

    @property
    def comment(self):
        return self.metadata_get(["Comment"])

    @property
    def total_size(self):
        """
        Size of ALL layers in bytes

        :return: int or None
        """
        return self._total_size or self.data.get("Size", 0)

    @property
    def unique_size(self):
        """
        Size of ONLY this particular layer

        :return: int or None
        """
        self._virtual_size = self._virtual_size or \
                             graceful_chain_get(self.data, "VirtualSize", default=0)
        try:
            return self._virtual_size - self._shared_size
        except TypeError:
            return 0

    @property
    def shared_size(self):
        """
        I guess this is size of layers which are shared with some other image

        :return: int or None
        """
        return self._shared_size

    @property
    def names(self):
        if self._names is None:
            self._names = []
            if self.data is None:
                return self._names
            # RepoTags = image, Tags = output from `history` command, Tags can be None
            for t in self.data.get("RepoTags", self.data.get("Tags")) or []:
                image_name = ImageNameStruct.parse(t)
                if image_name.to_str():
                    self._names.append(image_name)
            # RepoDigests can be none
            for t in self.data.get("RepoDigests", []) or []:
                image_name = ImageNameStruct.parse(t)
                if image_name.to_str():
                    self._names.append(image_name)
            # sort by name length
            self._names.sort(key=lambda x: len(x.to_str()))
        return self._names

    @property
    def short_name(self):
        try:
            ins = self.names[0]
        except IndexError:
            return self.short_id
        if ins.repo == "<none>":
            return self.short_id
        return ins.to_str()

    def base_image(self):
        child_image = self
        while True:
            try:
                parent_image = self.docker_backend.get_image_by_id(child_image.parent_id)
            except Exception as ex:
                logger.warning("error while getting image by ID: %r", ex)
                parent_image = None
            if parent_image is None:
                try:
                    child_image = child_image.parent_image
                except Exception as ex:
                    logger.error("error while getting parent image for image %s: %r", self, ex)
                    return None
                if child_image is None:
                    return None
            else:
                return parent_image

    @operation("Inspect image {object_short_name}.")
    def inspect(self, cached=False):
        if self._inspect is None or cached is False:
            try:
                self._inspect = self.d.inspect_image(self.image_id)
            except docker.errors.NotFound:
                self._inspect = self._inspect or {}
        return self._inspect

    @operation("{object_type} {object_short_name} removed!")
    def remove(self, force=False):
        return self.d.remove_image(self.image_id, force=force)

    @operation("Tag of {object_type} {object_short_name} removed!")
    def remove_tag(self, tag):
        assert tag in self.names
        return self.d.remove_image(str(tag))

    def matches_search(self, s):
        return s in self.image_id or any([s in str(x) for x in self.names])

    def __str__(self):
        # it's dangerous to put many stuff here b/c most of the values are loaded dynamically
        # and it's trivial to go into nested exception madness
        return "image {}".format(self.short_id)

    def __repr__(self):
        return self.__str__()

    def containers(self):
        return self.docker_backend.get_containers_for_image(self.image_id)


class RootImage(DockerImage):
    """
    this is essentially "scratch" but you cannot inspect it anymore
    """
    def __init__(self, docker_backend):
        self.image_name = "scratch"
        super().__init__(None, docker_backend, object_id="")

    @property
    def parent_id(self):
        return None

    @property
    def parent_image(self):
        return None

    def get_next_sibling(self):
        return None

    def get_prev_sibling(self):
        return None

    @property
    def names(self):
        return [ImageNameStruct.parse(self.image_name)]

    def __str__(self):
        return self.image_name


class DockerContainer(DockerObject):
    """
    Container related logic
    """

    def __init__(self, data, docker_backend, object_id=None):
        super(DockerContainer, self).__init__(data, docker_backend, object_id)
        self.size_root_fs = None
        self.size_rw_fs = None

    def __str__(self):
        return "{} ({})".format(self.container_id, self.short_name)

    # properties

    @property
    def container_id(self):
        return self.object_id

    @property
    def names(self):
        if self._names is None:
            self._names = []
            for t in self.data.get("Names", []):
                self._names.append(t)
            # sort by name length
            self._names.sort(key=lambda x: len(x))
        return self._names

    @property
    def command(self):
        return self.data["Command"]

    @property
    def nice_status(self):
        return self.data["Status"]

    @property
    def simple_status(self):
        return self.metadata_get(["State", "Status"])

    @property
    def simple_status_cap(self):
        return self.simple_status.capitalize()

    @property
    def running(self):
        return self.metadata_get(["State", "Running"])

    @property
    def status_created(self):
        return self.simple_status == "created"

    @property
    def exit_code(self):
        return self.metadata_get(["State", "ExitCode"])

    @property
    def exited_well(self):
        return self.exit_code == 0

    @property
    def short_name(self):
        try:
            return self.names[0]
        except IndexError:
            return self.short_id

    @property
    def pretty_object_type(self):
        return "Container"

    @property
    def image_id(self):
        """ this container is created from image with id..."""
        try:
            # docker >= 1.9
            image_id = self.data["ImageID"]
        except KeyError:
            # docker <= 1.8
            image_id = self.metadata_get(["Image"])
        return image_id

    @property
    def image(self):
        return self.docker_backend.get_image_by_id(self.image_id)

    @property
    def ip_address(self):
        # docker == 1.10
        ip_address = self.metadata_get(["NetworkSettings", "IPAddress"])
        return ip_address

    @property
    def net(self):
        """
        get ACTIVE port mappings of a container

        :return: dict:
        {
            "host_port": "container_port"
        }
        """
        try:
            return NetData(self.inspect(cached=True).response)
        except docker.errors.NotFound:
            raise NotAvailableAnymore()

    @property
    def started_at(self):
        s = self.metadata_get(["State", "StartedAt"])
        if s:
            # python expects 6 digits in milliseconds, docker returns 9
            s = s[:26]
            if s == "0001-01-01T00:00:00Z":
                return DINOSAUR_TIME
            s = s.replace("Z", "0")
            try:
                started_at = datetime.datetime.strptime(s, ISO_DATETIME_PARSE_STRING)
            except ValueError as ex:
                logger.error("unable to parse datetime %s: %s", s, ex)
                return DINOSAUR_TIME
            return started_at

    @property
    def finished_at(self):
        f = self.metadata_get(["State", "FinishedAt"])
        if f:
            f = f[:26]
            if f == "0001-01-01T00:00:00Z":
                return DINOSAUR_TIME
            f = f.replace("Z", "0")
            try:
                finished_at = datetime.datetime.strptime(f, ISO_DATETIME_PARSE_STRING)
            except ValueError as ex:
                logger.error("unable to parse datetime %s: %s", f, ex)
                return DINOSAUR_TIME
            return finished_at

    @property
    def natural_sort_value(self):
        # docker acts weird: 'created' is provided as ordinal and is local time
        # 'started' and 'finished' are UTC as timestamp; it would be awesome to unite those b/c
        # atm these inconsistencies mess ordering
        try:
            # Nones are unsortable
            return max([x for x in
                        [self.started_at, self.finished_at, super().natural_sort_value]
                        if x])
        except NotAvailableAnymore:
            return super().natural_sort_value

    # methods

    def image_name(self):
        if self.image is not None:
            return self.image.short_name
        else:
            return self.image_id[:12]

    def matches_search(self, s):
        return s in self.container_id or \
               s in self.short_name
    # api calls

    @operation("Get resources statistics.")
    def stats(self):
        cpu_total = 0.0
        cpu_system = 0.0
        cpu_percent = 0.0
        for x in self.d.stats(self.container_id, decode=True, stream=True):
            blk_read, blk_write = calculate_blkio_bytes(x)
            net_r, net_w = calculate_network_bytes(x)
            mem_current = x["memory_stats"]["usage"]
            mem_total = x["memory_stats"]["limit"]

            try:
                cpu_percent, cpu_system, cpu_total = calculate_cpu_percent2(x, cpu_total, cpu_system)
            except KeyError as e:
                logger.error("error while getting new CPU stats: %r, falling back")
                cpu_percent = calculate_cpu_percent(x)

            r = {
                "cpu_percent": cpu_percent,
                "mem_current": mem_current,
                "mem_total": x["memory_stats"]["limit"],
                "mem_percent": (mem_current / mem_total) * 100.0,
                "blk_read": blk_read,
                "blk_write": blk_write,
                "net_rx": net_r,
                "net_tx": net_w,
            }
            yield r

    @operation("List processes in running container.")
    def top(self):
        """
        list of processes in a running container

        :return: None or list of dicts
        """
        # let's get resources from .stats()
        ps_args = "-eo pid,ppid,wchan,args"
        # returns {"Processes": [values], "Titles": [values]}
        # it's easier to play with list of dicts: [{"pid": 1, "ppid": 0}]
        try:
            response = self.d.top(self.container_id, ps_args=ps_args)
        except docker.errors.APIError as ex:
            logger.warning("error getting processes: %r", ex)
            return []
        # TODO: sort?
        logger.debug(json.dumps(response, indent=2))
        return [dict(zip(response["Titles"], process))
                for process in response["Processes"] or []]

    @operation("Inspect container {object_short_name}.")
    def inspect(self, cached=False):
        if cached is False or self._inspect is None:
            try:
                self._inspect = self.d.inspect_container(self.container_id)
            except docker.errors.NotFound:
                self._inspect = self._inspect or {}
        return self._inspect

    @operation("Logs of container {object_short_name} received.")
    def logs(self, follow=False, lines="all"):
        # when tail is set to all, it takes ages to populate widget
        # docker-py does `inspect` in the background
        try:
            logs_data = self.d.logs(self.container_id, stream=follow, tail=lines)
        except docker.errors.NotFound:
            return None
        else:
            return logs_data

    @operation("{object_type} {object_short_name} removed!")
    def remove(self, force=False):
        self.d.remove_container(self.container_id, force=force)

    @operation("{object_type} {object_short_name} started.")
    def start(self):
        self.d.start(self.container_id)

    @operation("{object_type} {object_short_name} stopped.")
    def stop(self):
        self.d.stop(self.container_id)

    @operation("{object_type} {object_short_name} restarted.")
    def restart(self):
        self.d.restart(self.container_id)

    @operation("{object_type} {object_short_name} killed.")
    def kill(self):
        self.d.kill(self.container_id)

    @operation("{object_type} {object_short_name} paused.")
    def pause(self):
        self.d.pause(self.container_id)

    @operation("{object_type} {object_short_name} unpaused.")
    def unpause(self):
        self.d.unpause(self.container_id)


class DockerBackend:
    """
    backend for docker
    """

    def __init__(self):
        self._containers = None
        self._images = None  # displayed images
        self._all_images = None  # docker images -a
        self._df = None

        kwargs = {"version": "auto"}
        kwargs.update(docker.utils.kwargs_from_env(assert_hostname=False))

        try:
            APIClientClass = docker.Client  # 1.x
        except AttributeError:
            APIClientClass = docker.APIClient  # 2.x

        try:
            self.client = APIClientClass(**kwargs)
        except docker.errors.DockerException as ex:
            raise TerminateApplication("can't establish connection to docker daemon: {0}".format(str(ex)))

        self.scratch_image = RootImage(self)

    # backend queries

    @operation("Get list of images.")
    def get_images(self, cached=True):
        if cached is False or self._images is None:
            logger.debug("doing images() query")
            self._images = {}
            images_response = repeater(self.client.images) or []
            for i in images_response:
                img = DockerImage(i, self)
                self._images[img.image_id] = img
            self._all_images = {}
            # FIXME: performance: do just all=True
            all_images_response = repeater(self.client.images, kwargs={"all": True}) or []
            for i in all_images_response:
                img = DockerImage(i, self)
                self._all_images[img.image_id] = img
        return list(self._images.values())

    @operation("Get list of containers.")
    def get_containers(self, cached=True, stopped=True):
        if cached is False or self._containers is None:
            logger.debug("doing containers() query")
            self._containers = {}
            containers_reponse = repeater(self.client.containers, kwargs={"all": stopped}) or []
            for c in containers_reponse:
                container = DockerContainer(c, self)
                self._containers[container.container_id] = container
        if not stopped:
            return [x for x in list(self._containers.values()) if x.running]
        return list(self._containers.values())

    @operation("Get disk usage.")
    def df(self, cached=True):
        if cached is False or self._df is None:
            logger.debug("getting disk-usage")
            # TODO: wrap in try/execpt
            self._df = self.client.df()
            # TODO: attach these to real containers and images
            # # since DOCKER API-1.25 (v.1.13.0)
            # df = self.client.df()
            # if 'Containers' in df:
            #     df_containers = df['Containers']
            containers_data = graceful_chain_get(self._df, "Containers")
            for c_data in containers_data:
                c = graceful_chain_get(self._containers, graceful_chain_get(c_data, "Id"))
                c.size_root_fs = graceful_chain_get(c_data, "SizeRootFs")
                c.size_rw_fs = graceful_chain_get(c_data, "SizeRw")
            images_data = graceful_chain_get(self._df, "Images")
            for i_data in images_data:
                i = graceful_chain_get(self._images, graceful_chain_get(i_data, "Id"))
                i._total_size = graceful_chain_get(i_data, "Size")
                i._shared_size = graceful_chain_get(i_data, "SharedSize")
                i._virtual_size = graceful_chain_get(i_data, "VirtualSize")
        return self._df

    def realtime_updates(self):
        event = it = None
        while True:
            if not it or not event:
                it = repeater(self.client.events, kwargs={"decode": True}, retries=5)
                if not it:
                    raise NotifyError("Unable to fetch realtime updates from docker engine.")

            event = repeater(next, args=(it, ), retries=2)  # likely an engine restart
            if not event:
                continue

            logger.debug("RT event: %s", event)

            yield event

            # try:
            #     # 1.10+
            #     is_container = event["Type"] == "container"
            # except KeyError:
            #     # event["from'] means it's a container
            #     is_container = "from" in event
            # if is_container:
            #     # inspect doesn't contain info about status and you can't query just one
            #     # container with containers()
            #     # let's do full-blown containers() query; it's not that expensive
            #     self.get_containers(cached=False)
            # else:
            #     # similar as ^
            #     # images() doesn't support query by ID
            #     # inspect doesn't contain info about repositories
            #     self.get_images(cached=False)
            # content, _, _ = self.filter(containers=True, images=True, stopped=True,
            #                             cached=True, sort_by_created=True)
            # yield content

    # service methods

    def get_image_by_id(self, image_id):
        return self._all_images.get(image_id)

    def get_images_for_parent(self, image):
        if not image:
            return []
        l = sorted([x for x in self._all_images.values() if x.parent_image == image], key=lambda x: x.created_int)
        return l

    def get_container_by_id(self, container_id):
        return self._containers.get(container_id)

    def get_containers_for_image(self, image_id):
        return [container for container in self._containers.values() if container.image_id == image_id]

    def filter(self, containers=True, images=True, stopped=True, cached=False, sort_by_created=True):
        """
        since django is so awesome, let's use their ORM API

        :return:
        """
        content = []
        containers_o = None
        images_o = None
        # return containers when containers=False and running=True
        if containers or not stopped:
            containers_o = self.get_containers(cached=cached, stopped=stopped)
            content += containers_o.response
        if images:
            images_o = self.get_images(cached=cached)
            content += images_o.response
        if sort_by_created:
            content.sort(key=attrgetter("natural_sort_value"), reverse=True)
        return content, containers_o, images_o