"""
Core
====

This library implements the business logic of Mekansm. It receives
calls from the Handlers, gets the data that it needs from the adapters,
and returns Serializable objects.

Note that this library is and should remain isolated from the rest of
Mekansm. It only imports exceptions from mekansm.errors. Dependency
Injection is implemented by passing the application context as an
argument to methods that need it. This makes this module easy to test,
and easy to reason about, since the flow of control follows one direction.
"""

from collections import defaultdict
import datetime
import json
from abc import ABCMeta, abstractmethod, abstractproperty
import itertools

from mekansm.errors import (
    NodeNotFound, DeploymentNotFound, InvalidNodeId, InvalidDeploymentId,
    InvalidNodeFields, InvalidDeploymentFields,
    InvalidNodeStatusFields, NodeStatusNotFound)


class Serializable(object):
    """Base class for dict-like objects that will be serialized.

    The implementer classes need to define the known dict keys (fields)
    they handle, and a custom exceptions for incorrect fields.
    """

    __metaclass__ = ABCMeta

    def __init__(self, context, **kwargs):
        self._context = context
        got = set(kwargs.keys())
        missing_fields = self.mandatory_fields - got
        unknown_fields = got - self.mandatory_fields - self.optional_fields
        if missing_fields or unknown_fields:
            raise self.raise_invalid_fields_exc(missing_fields, unknown_fields)
        self.data = kwargs.copy()

    @abstractproperty
    def mandatory_fields(self):  # pragma: no cover
        """
        :return: Known mandatory fields
        :rtype: set
        """
        return NotImplemented

    @property
    def optional_fields(self):
        """
        :return: Known optional fields
        :rtype: set
        """
        return set()

    @property
    def identifier_fields(self):
        """
        :return: Fields that identify the object (default={id})
        :rtype: set
        """
        return {"id"}

    @abstractproperty
    def data_type(self):  # pragma: no cover
        """
        :return: The data type implemented by the class
        :rtype: str
        """
        return NotImplemented

    @abstractmethod
    def raise_invalid_fields_exc(
            self, missing_fields, unknown_fields):  # pragma: no cover
        """Raises an exception when invalid fields are used.

        :param set missing_fields: Necessary fields that were not passed
        :param set unknown_fields: Fields passed that are unhandled.
        :return: None
        :raise MekansmInvalidFields: with invalid fields
        """
        return NotImplemented

    @staticmethod
    def is_valid_id(id_):
        """Check that an id is valid for the type.

        :param id_: The ID
        :return: True or False
        :rtype: bool
        """
        return isinstance(id_, str) and id_ != ""

    def as_dict(self):
        """
        :return: A dictionary of data with all the handled fields.
        :rtype: dict
        """
        data = self.data.copy()
        idfields = self.identifier_fields.copy()
        res = {
            "type": self.data_type,
            "attributes": data
        }
        if len(idfields) == 1:
            res["id"] = data.pop(idfields.pop())
        else:
            res["id"] = {name: data.pop(name) for name in idfields}
        return res

    def __getitem__(self, item):
        return self.data[item]

    def __setitem__(self, key, value):
        self.data[key] = value


class Deployment(Serializable):
    """Deployment class"""

    nodes = []

    @property
    def mandatory_fields(self):
        """
        :return: Handled data fields in a Deployment
        :rtype: set
        """
        return {"repository", "triggered_by", "environment",
                "sha", "owner", "account", "application",
                "bundletype", "config", "s3location", "group"}

    @property
    def optional_fields(self):
        return {"created_at", "id", "status"}

    @property
    def data_type(self):
        return "deployments"

    def raise_invalid_fields_exc(self, missing_fields, unknown_fields):
        """Raises an exception when invalid fields are used.

        :param set missing_fields: Necessary fields that were not passed
        :param set unknown_fields: Fields passed that are unhandled.
        :return: None
        :raise InvalidDeploymentFields: with invalid fields
        """
        raise InvalidDeploymentFields(missing_fields, unknown_fields)

    @classmethod
    def get(cls, context, deployment_id):
        """Given an ID, return a deployment from the running context.

        :param mekansm.adapters.Context context: Application context
        :param str deployment_id: The ID of the deployment to get
        :return: A deployment object
        :rtype: Deployment
        :raise InvalidDeploymentId: if deployment_id is invalid
        :raise DeploymentNotFound:
            if the deployment couldn't be found in the running context
        """
        if not cls.is_valid_id(deployment_id):
            raise InvalidDeploymentId(deployment_id)
        deployment = context.get_deployment(deployment_id)
        if not deployment:
            raise DeploymentNotFound(deployment_id)
        return cls(context, **deployment)

    @classmethod
    def find_by(cls, context, filter_obj):
        """Given a filter object, return all deployments that match.

        :param mekansm.adapters.Context context: Application context
        :param dict filter_obj: The filter to use
        :return: A list of matching Deployment objects
        :rtype: List[Deployment]
        """
        return [
            cls(context, **dep) for dep in context.get_deployments(filter_obj)]

    @classmethod
    def create(cls, context, **kwargs):
        """Creates a persisted deployment object

        :param mekansm.adapters.Context context: Application context
        :param dict kwargs: The data for the Deployment, including a nodes
                            member that has a list of kwargs passed to
                            mekansm.core.Node creation.
        :return: The deployment object that was created.
        :rtype: mekansm.core.Deployment
        """
        nodes = kwargs.pop("nodes")
        deployment = cls(context, **kwargs)
        deployment.nodes = [Node(context, **node) for node in nodes]
        deployment.save()
        return deployment

    def save(self):
        """Persist the deployment object.

        :return:
        """
        return self._context.create_deployment(
            self.data, [node.data for node in self.nodes]
        )

    def set_status(self, status):
        """Set the status of the deployment

        :param str status: The new status
        :return: The number of nodes that the status changed.
        :rtype: int
        """
        res = self._context.set_deployment_status(self["id"], status)
        self["status"] = status
        return res

    @property
    def instances_statuses(self):
        """
        :return: A list of node status objects for the deployment
        :rtype: list of NodeStatus
        """
        return [
            NodeStatus(
                self._context,
                **{
                    "deployment": self["id"],
                    "node": status["node"],
                    "status": status["status"],
                    "update_time": status["lastUpdatedAt"],
                    "events": status["lifecycleEvents"]
                })
            for status in
            self._context.get_deployment_nodes_status(self["id"])
        ]


class Node(Serializable):
    """Node class"""

    @property
    def mandatory_fields(self):
        """
        :return: Handled data fields in a Node
        :rtype: set
        """
        return {"id", "datacenter"}

    @property
    def data_type(self):
        return "nodes"

    def raise_invalid_fields_exc(self, missing_fields, unknown_fields):
        """Raises an exception when invalid fields are used.

        :param set missing_fields: Necessary fields that were not passed
        :param set unknown_fields: Fields passed that are unhandled.
        :return: None
        :raise InvalidNodeFields: with invalid fields
        """
        raise InvalidNodeFields(missing_fields, unknown_fields)

    @classmethod
    def get(cls, context, node_id):
        """Given an ID, return a node from the running context.

        :param mekansm.adapters.Context context: Application context
        :param int node_id: The ID of the node to get
        :return: A Node object
        :rtype: Node
        :raise InvalidNodeId: if node_id is invalid
        :raise NodeNotFound:
            if the node couldn't be found in the running context
        """
        if not cls.is_valid_id(node_id):
            raise InvalidNodeId(node_id)
        node = context.get_node(node_id)
        if not node:
            raise NodeNotFound(node_id)
        return cls(context, **node)

    @classmethod
    def find_by(cls, context, filter_obj):
        """Given a filter object, return all nodes that match.

        :param mekansm.adapters.Context context: Application context
        :param dict filter_obj: The filter to use
        :return: A list of matching raw node objects
        :rtype: list
        """

        return list(
            cls(context, **node_data)
            for node_data in context.get_nodes(filter_obj)
        )

    def deployments(self, filter_obj):
        """Given a filter object, return all matching deployments for the node.

        :param dict filter_obj: The filter to use
        :return: A list of matching deployment objects
        :rtype: List[Deployment]
        """
        filter_obj["node"] = self["id"]
        return Deployment.find_by(self._context, filter_obj)

    def deployments_and_statuses(self):
        res = {"instance": self.data, "deployments": []}

        def gkey(deployment):
            return deployment["application"], deployment["environment"]

        def sortkey(deployment):
            return deployment["created_at"]

        for key, deployments in itertools.groupby(self.deployments({}), gkey):
            last_success = None
            last_inprogress = None
            l = []
            for deployment in sorted(deployments, key=sortkey, reverse=True):
                s = []
                for status in deployment.instances_statuses:
                    if status["node"] == self["id"]:
                        dstatus = status["status"]
                        if not last_success and dstatus == "Succeeded":
                            last_success = status
                        elif not last_inprogress and dstatus in (
                                "Created", "Queued", "InProgress"
                            ) and (
                                last_success is None or
                                last_success["attributes"]["update_time"] <
                                    status["attributes"]["update_time"]
                        ):
                            last_inprogress = status
                        s.append(status)
                assert len(s) == 1
                l.append(s[0])

            res["deployments"].append({
                "application": key[0],
                "environment": key[1],
                "last_success": last_success,
                "last_inprogress": last_inprogress
            })
        return res


class NodeStatus(Serializable):
    """Deployment status for a Node"""

    @property
    def mandatory_fields(self):
        return {"deployment", "node", "status"}

    @property
    def optional_fields(self):
        return {"update_time", "events"}

    @property
    def identifier_fields(self):
        return {"deployment", "node"}

    @property
    def data_type(self):
        return "node_status"

    def raise_invalid_fields_exc(self, missing_fields, unknown_fields):
        """Raises an exception when invalid fields are used.

        :param set missing_fields: Necessary fields that were not passed
        :param set unknown_fields: Fields passed that are unhandled.
        :return: None
        :raise InvalidDeploymentFields: with invalid fields
        """
        raise InvalidNodeStatusFields(missing_fields, unknown_fields)

    @classmethod
    def get(cls, context, deployment_id, node_id):
        """Return a NodeStatus object given the context and ids

        :param mekansm.adapters.Context context: The running context
        :param str deployment_id: The Deployment ID
        :param str node_id: The Node ID
        :return: The NodeStatus object if found
        :rtype: NodeStatus
        :raise NodeStatusNotFound: when it doesn't exist
        """
        res = context.get_deployment_instance_status(deployment_id, node_id)
        if res is None:
            raise NodeStatusNotFound(
                {"deployment": deployment_id, "node": node_id})
        return cls(context, **res)


class MekansmJSONEncoder(json.JSONEncoder):
    """A custom JSON encoder to make Serializable objects, well, serializable.
    """
    def default(self, obj):
        """Serialize the given object.

        It currently handles datetime objects, and the Serializable interface.

        :param Any obj: the object to serialize
        :return: A serializable representation of the given object
        """
        if isinstance(obj, bytes):
            return obj.encode("UTF-8")
        if isinstance(obj, datetime.date):
            return obj.isoformat()
        if isinstance(obj, Serializable):
            return obj.as_dict()
        return json.JSONEncoder.default(self, obj)

json_encoder = MekansmJSONEncoder()
"""A handy json encoder singleton"""
