# Copyright 2013: Mirantis Inc.
# All Rights Reserved.
#
#    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.

import collections
import itertools
import time
import traceback

import jsonschema

from rally.common import logging
from rally import consts
from rally import exceptions


LOG = logging.getLogger(__name__)


def get_status(resource, status_attr="status"):
    """Get the status of a given resource object.

    The status is returned in upper case. The status is checked for the
    standard field names with special cases for Heat and Ceilometer.

    :param resource: The resource object or dict.
    :param status_attr: Allows to specify non-standard status fields.
    :return: The status or "NONE" if it is not available.
    """

    for s_attr in ["stack_status", "state", status_attr]:
        status = getattr(resource, s_attr, None)
        if isinstance(status, str):
            return status.upper()

    # Dict case
    if (isinstance(resource, dict)
            and status_attr in resource.keys()
            and isinstance(resource[status_attr], str)):
        return resource[status_attr].upper()

    return "NONE"


class resource_is(object):
    def __init__(self, desired_status, status_getter=None):
        self.desired_status = desired_status
        self.status_getter = status_getter or get_status

    def __call__(self, resource):
        return self.status_getter(resource) == self.desired_status.upper()

    def __str__(self):
        return str(self.desired_status)


def get_from_manager(error_statuses=None):
    error_statuses = error_statuses or ["ERROR"]
    error_statuses = map(lambda str: str.upper(), error_statuses)

    def _get_from_manager(resource, id_attr="id"):
        # catch client side errors
        try:
            res = resource.manager.get(getattr(resource, id_attr))
        except Exception as e:
            if getattr(e, "code", getattr(e, "http_status", 400)) == 404:
                raise exceptions.GetResourceNotFound(resource=resource)
            raise exceptions.GetResourceFailure(resource=resource, err=e)

        # catch abnormal status, such as "no valid host" for servers
        status = get_status(res)

        if status in ("DELETED", "DELETE_COMPLETE"):
            raise exceptions.GetResourceNotFound(resource=res)
        if status in error_statuses:
            raise exceptions.GetResourceErrorStatus(
                resource=res, status=status,
                fault=getattr(res, "fault", "n/a"))

        return res

    return _get_from_manager


def manager_list_size(sizes):
    def _list(mgr):
        return len(mgr.list()) in sizes
    return _list


@logging.log_deprecated("Use wait_for_status instead.", "0.1.2", once=True)
def wait_for(resource, is_ready=None, ready_statuses=None,
             failure_statuses=None, status_attr="status", update_resource=None,
             timeout=60, check_interval=1, id_attr="id"):
    """Waits for the given resource to come into the one of the given statuses.

    The method can be used to check resource for status with a `is_ready`
    function or with a list of expected statuses and the status attribute

    In case when the is_ready checker is not provided the resource should have
    status_attr. It may be an object attribute or a dictionary key. The value
    of the attribute is checked against ready statuses list and failure
    statuses. In case of a failure the wait exits with an exception. The
    resource is updated between iterations with an update_resource call.

    :param is_ready: A predicate that should take the resource object and
                     return True iff it is ready to be returned
    :param ready_statuses: List of statuses which mean that the resource is
                         ready
    :param failure_statuses: List of statuses which mean that an error has
                           occurred while waiting for the resource
    :param status_attr: The name of the status attribute of the resource
    :param update_resource: Function that should take the resource object
                          and return an 'updated' resource. If set to
                          None, no result updating is performed
    :param timeout: Timeout in seconds after which a TimeoutException will be
                    raised
    :param check_interval: Interval in seconds between the two consecutive
                           readiness checks

    :returns: The "ready" resource object
    """

    if is_ready is not None:
        return wait_is_ready(resource=resource, is_ready=is_ready,
                             update_resource=update_resource, timeout=timeout,
                             check_interval=check_interval)
    else:
        return wait_for_status(resource=resource,
                               ready_statuses=ready_statuses,
                               failure_statuses=failure_statuses,
                               status_attr=status_attr,
                               update_resource=update_resource,
                               timeout=timeout,
                               check_interval=check_interval,
                               id_attr=id_attr)


@logging.log_deprecated("Use wait_for_status instead.", "0.1.2", once=True)
def wait_is_ready(resource, is_ready, update_resource=None,
                  timeout=60, check_interval=1):

    resource_repr = getattr(resource, "name", repr(resource))
    start = time.time()

    while True:
        if update_resource is not None:
            resource = update_resource(resource)

        if is_ready(resource):
            return resource

        time.sleep(check_interval)
        if time.time() - start > timeout:
            raise exceptions.TimeoutException(
                desired_status=str(is_ready),
                resource_name=resource_repr,
                resource_type=resource.__class__.__name__,
                resource_id=getattr(resource, "id", "<no id>"),
                resource_status=get_status(resource),
                timeout=timeout)


def wait_for_status(resource, ready_statuses, failure_statuses=["error"],
                    status_attr="status", update_resource=None,
                    timeout=60, check_interval=1, check_deletion=False,
                    id_attr="id"):

    resource_repr = getattr(resource, "name", repr(resource))
    if not isinstance(ready_statuses, (set, list, tuple)):
        raise ValueError("Ready statuses should be supplied as set, list or "
                         "tuple")
    if failure_statuses and not isinstance(failure_statuses,
                                           (set, list, tuple)):
        raise ValueError("Failure statuses should be supplied as set, list or "
                         "tuple")

    # make all statuses upper case
    ready_statuses = set(s.upper() for s in ready_statuses or [])
    failure_statuses = set(s.upper() for s in failure_statuses or [])

    if (ready_statuses & failure_statuses):
        raise ValueError(
            "Can't wait for resource's %s status. Ready and Failure "
            "statuses conflict." % resource_repr)
    if not ready_statuses:
        raise ValueError(
            "Can't wait for resource's %s status. No ready "
            "statuses provided" % resource_repr)
    if not update_resource:
        raise ValueError(
            "Can't wait for resource's %s status. No update method."
            % resource_repr)

    start = time.time()

    latest_status = get_status(resource, status_attr)
    latest_status_update = start

    while True:
        try:
            if id_attr == "id":
                resource = update_resource(resource)
            else:
                resource = update_resource(resource, id_attr=id_attr)
        except exceptions.GetResourceNotFound:
            if check_deletion:
                return
            else:
                raise
        status = get_status(resource, status_attr)

        if status != latest_status:
            current_time = time.time()
            delta = current_time - latest_status_update
            LOG.debug(
                "Waiting for resource %(resource)s. Status changed: "
                "%(latest)s => %(current)s in %(delta)s"
                % {"resource": resource_repr, "latest": latest_status,
                   "current": status, "delta": delta})

            latest_status = status
            latest_status_update = current_time

        if status in ready_statuses:
            return resource
        if status in failure_statuses:
            raise exceptions.GetResourceErrorStatus(
                resource=resource,
                status=status,
                fault="Status in failure list %s" % str(failure_statuses))

        time.sleep(check_interval)
        if time.time() - start > timeout:
            raise exceptions.TimeoutException(
                desired_status="('%s')" % "', '".join(ready_statuses),
                resource_name=resource_repr,
                resource_type=resource.__class__.__name__,
                resource_id=getattr(resource, id_attr, "<no id>"),
                resource_status=get_status(resource, status_attr),
                timeout=timeout)


@logging.log_deprecated("Use wait_for_status instead.", "0.1.2", once=True)
def wait_for_delete(resource, update_resource=None, timeout=60,
                    check_interval=1):
    """Wait for the full deletion of resource.

    :param update_resource: Function that should take the resource object
                            and return an 'updated' resource, or raise
                            exception rally.exceptions.GetResourceNotFound
                            that means that resource is deleted.

    :param timeout: Timeout in seconds after which a TimeoutException will be
                    raised
    :param check_interval: Interval in seconds between the two consecutive
                           readiness checks
    """
    start = time.time()
    while True:
        try:
            resource = update_resource(resource)
        except exceptions.GetResourceNotFound:
            break
        time.sleep(check_interval)
        if time.time() - start > timeout:
            raise exceptions.TimeoutException(
                desired_status="deleted",
                resource_name=getattr(resource, "name", repr(resource)),
                resource_type=resource.__class__.__name__,
                resource_id=getattr(resource, "id", "<no id>"),
                resource_status=get_status(resource),
                timeout=timeout)


def format_exc(exc):
    return [exc.__class__.__name__, str(exc), traceback.format_exc()]


def infinite_run_args_generator(args_func):
    for i in itertools.count():
        try:
            yield args_func(i)
        except StopIteration:
            return


class ActionBuilder(object):
    """Builder class for mapping and creating action objects.

    An action list is an array of single key/value dicts which takes
    the form:

    [{"action": times}, {"action": times}...]

    Here 'action' is a string which indicates an action to perform and
    'times' is a non-zero positive integer which specifies how many
    times to run the action in sequence.

    This utility builder class will build and return methods which
    wrapper the action call the given amount of times.
    """

    SCHEMA_TEMPLATE = {
        "type": "array",
        "$schema": consts.JSON_SCHEMA7,
        "items": {
            "type": "object",
            "properties": {},
            "additionalProperties": False,
            "minItems": 0
        }
    }

    ITEM_TEMPLATE = {
        "type": "integer",
        "minimum": 0,
        "exclusiveMinimum": 0.0,
        "optional": True
    }

    def __init__(self, action_keywords):
        """Create a new instance of the builder for the given action keywords.

        :param action_keywords: A list of strings which are the keywords this
        instance of the builder supports.
        """
        self._bindings = {}
        self.schema = dict(ActionBuilder.SCHEMA_TEMPLATE)
        for kw in action_keywords:
            self.schema["items"]["properties"][kw] = (
                ActionBuilder.ITEM_TEMPLATE)

    def bind_action(self, action_key, action, *args, **kwargs):
        """Bind an action to an action key.

        Static args/kwargs can be optionally binded.
        :param action_key: The action keyword to bind the action to.
        :param action: A method/function to call for the action.
        :param args: (optional) Static positional args to prepend
        to all invocations of the action.
        :param kwargs: (optional) Static kwargs to prepend to all
        invocations of the action.
        """
        self.validate([{action_key: 1}])
        self._bindings[action_key] = {
            "action": action,
            "args": args or (),
            "kwargs": kwargs or {}
        }

    def validate(self, actions):
        """Validate the list of action objects against the builder schema.

        :param actions: The list of action objects to validate.
        """
        jsonschema.validate(actions, self.schema)

    def _build(self, func, times, *args, **kwargs):
        """Build the wrapper action call."""
        def _f():
            for i in range(times):
                func(*args, **kwargs)
        return _f

    def build_actions(self, actions, *args, **kwargs):
        """Build a list of callable actions.

        A list of callable actions based on the given action object list and
        the actions bound to this builder.

        :param actions: A list of action objects to build callable
        action for.
        :param args: (optional) Positional args to pass into each
        built action. These will be appended to any args set for the
        action via its binding.
        :param kwargs: (optional) Keyword args to pass into each built
        action. These will be appended to any kwargs set for the action
        via its binding.
        """
        self.validate(actions)
        bound_actions = []
        for action in actions:
            action_key = list(action)[0]
            times = action.get(action_key)
            binding = self._bindings.get(action_key)
            dft_kwargs = dict(binding["kwargs"])
            dft_kwargs.update(kwargs or {})
            bound_actions.append(
                self._build(binding["action"], times,
                            *(binding["args"] + args), **dft_kwargs))
        return bound_actions


# TODO(andreykurilin): We need to implement some wrapper for atomic actions,
# we can use these wrapper to simulate new and old format.
class WrapperForAtomicActions(list):

    def __init__(self, atomic_actions, timestamp=0):

        self.timestamp = timestamp

        if isinstance(atomic_actions, list):
            self.__atomic_actions = atomic_actions
            self.__old_atomic_actions = self._convert_new_atomic_actions(
                self.__atomic_actions)
        else:
            self.__atomic_actions = self._convert_old_atomic_actions(
                atomic_actions)
            self.__old_atomic_actions = atomic_actions

        super(WrapperForAtomicActions, self).__init__(self.__atomic_actions)

    def _convert_old_atomic_actions(self, old_atomic_actions):
        """Convert atomic actions to new format. """
        atomic_actions = []
        started_at = self.timestamp
        for name, duration in old_atomic_actions.items():
            finished_at = started_at + duration
            atomic_actions.append({"name": name,
                                   "started_at": started_at,
                                   "finished_at": finished_at,
                                   "children": []})
            started_at = finished_at
        return atomic_actions

    def _convert_new_atomic_actions(self, atomic_actions):
        """Convert atomic actions to old format. """
        old_style = collections.OrderedDict()
        for action in atomic_actions:
            duration = action["finished_at"] - action["started_at"]
            if action["name"] in old_style:
                name_template = action["name"] + " (%i)"
                i = 2
                while name_template % i in old_style:
                    i += 1
                old_style[name_template % i] = duration
            else:
                old_style[action["name"]] = duration
        return old_style

    def items(self):
        return self.__old_atomic_actions.items()

    def get(self, name, default=None):
        return self.__old_atomic_actions.get(name, default)

    def __iter__(self):
        return iter(self.__atomic_actions)

    def __len__(self):
        return len(self.__atomic_actions)

    def __getitem__(self, item):
        if isinstance(item, int):
            # it is a call to list:
            return self.__atomic_actions[item]
        else:
            return self.__old_atomic_actions[item]
