"""Module to handle a task."""

# Copyright 2017 Qarnot computing
#
# 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.


from os import makedirs, path
import time
import warnings
import sys
from typing import Dict, Optional, Union, List, Any, Callable

from . import get_url, raise_on_error, _util
from .status import Status
from .hardware_constraint import HardwareConstraint
from .bucket import Bucket
from .pool import Pool
from .error import Error
from .exceptions import MissingTaskException, MaxTaskException, NotEnoughCreditsException, \
    MissingBucketException, BucketStorageUnavailableException

try:
    from progressbar import AnimatedMarker, Bar, Percentage, AdaptiveETA, ProgressBar
except ImportError:
    pass

RUNNING_DOWNLOADING_STATES = ['Submitted', 'PartiallyDispatched',
                              'FullyDispatched', 'PartiallyExecuting',
                              'FullyExecuting', 'DownloadingResults', 'UploadingResults']

JobType = Any
ConnectionType = Any
TaskType = Any
PoolType = Pool
BucketType = Optional[Bucket]
StatusType = Status
Uuid = str


class Task(object):
    """Represents a Qarnot task.

    .. note::
       A :class:`Task` must be created with
       :meth:`qarnot.connection.Connection.create_task`
       or retrieved with :meth:`qarnot.connection.Connection.tasks` or :meth:`qarnot.connection.Connection.retrieve_task`.
    """
    def __init__(self, connection: ConnectionType, name: str, profile_or_pool: Union[str, PoolType] = None, instancecount_or_range: Union[int, str] = 1, shortname: str = None, job: JobType = None):
        """Create a new :class:`Task`.

        :param connection: the cluster on which to send the task
        :type connection: :class:`qarnot.connection.Connection`
        :param name: given name of the task
        :type name: :class:`str`
        :param profile_or_pool: which profile to use with this task, or which Pool to run task,
        :type profile_or_pool: str or :class:`~qarnot.pool.Pool` or None

        :param instancecount_or_range: number of instances or ranges on which to run task
        :type instancecount_or_range: int or str
        :param shortname: userfriendly task name
        :type shortname: :class:`str`
        :param job: which job to attach the task to
        :type job: :class:`~qarnot.job.Job`
        """
        self._name = name
        self._shortname = shortname
        self._profile = None
        self._pool_uuid = None
        self._job_uuid = None
        if profile_or_pool is not None:
            if isinstance(profile_or_pool, Pool):
                self._pool_uuid = profile_or_pool.uuid
            else:
                self._profile = profile_or_pool

        if job is not None:
            if isinstance(profile_or_pool, Pool):
                raise Exception("Cannot attach a same task to pool and a job simultaneously")
            self._job_uuid = job.uuid

        if isinstance(instancecount_or_range, int):
            self._instancecount: int = instancecount_or_range
            self._advanced_range = None
        else:
            self._advanced_range = instancecount_or_range
            self._instancecount = 0

        self._running_core_count = 0
        self._running_instance_count = 0

        self._resource_objects: List[BucketType] = []

        self._result_object: BucketType = None
        self._connection = connection
        self._constants: Dict[str, Any] = {}
        """
         :type: dict(str,str)

         Constants of the task.
         Can be set until :meth:`run` or :meth:`submit` is called

        .. note:: See available constants for a specific profile
              with :meth:`qarnot.connection.Connection.retrieve_profile`.
        """

        self._dependentOn: List[Uuid] = []

        self._auto_update = True
        self._last_auto_update_state = self._auto_update
        self._update_cache_time = 5

        self._last_cache = time.time()
        self._constraints: Dict[str, str] = {}
        self._labels: Dict[str, str] = {}
        self._state = 'UnSubmitted'  # RO property same for below
        self._uuid = None
        self._snapshots: Union[int, bool] = False
        self._dirty = False
        self._rescount = -1
        self._snapshot_whitelist: str = None
        self._snapshot_blacklist: str = None
        self._results_whitelist: str = None
        self._results_blacklist: str = None
        self._status = None
        self._completed_instances: List[CompletedInstance] = []
        self._tags: List[str] = []
        self._creation_date = None
        self._errors: Optional[List[Error]] = None
        self._resource_object_ids: List[str] = []
        self._resource_object_advanced: List[str] = []
        self._result_object_id = None
        self._is_summary = False
        self._completion_time_to_live = "00:00:00"
        self._auto_delete = False
        self._wait_for_pool_resources_synchronization: Optional[bool] = None

        self._previous_state = None
        self._state_transition_time = None
        self._previous_state_transition_time = None
        self._last_modified = None
        self._snapshot_interval = None
        self._progress = None
        self._execution_time = None
        self._wall_time = None
        self._end_date = None
        self._upload_results_on_cancellation: Optional[bool] = None
        self._hardware_constraints: List[HardwareConstraint] = []

    @classmethod
    def _retrieve(cls, connection: ConnectionType, uuid: str) -> TaskType:
        """Retrieve a submitted task given its uuid.

        :param qarnot.connection.Connection connection:
          the cluster to retrieve the task from
        :param str uuid: the uuid of the task to retrieve

        :rtype: Task
        :returns: The retrieved task.

        :raises qarnot.exceptions.QarnotGenericException: API general error, see message for details
        :raises qarnot.exceptions.UnauthorizedException: invalid credentials
        :raises qarnot.exceptions.MissingTaskException: no such task
        """
        resp = connection._get(get_url('task update', uuid=uuid))
        if resp.status_code == 404:
            raise MissingTaskException(resp.json()['message'])
        raise_on_error(resp)
        return Task.from_json(connection, resp.json())

    def run(self, output_dir: str = None, job_timeout: float = None, live_progress: bool = False, results_progress: bool = None) -> None:
        """Submit a task, wait for the results and download them if required.

        :param str output_dir: (optional) path to a directory that will contain the results
        :param float job_timeout: (optional) Number of seconds before the task :meth:`abort` if it is not
          already finished
        :param bool live_progress: (optional) display a live progress
        :param results_progress: (optional) can be a callback (read,total,filename) or True to display a progress bar
        :type results_progress: bool or function(float, float, str)
        :raises qarnot.exceptions.QarnotGenericException: API general error, see message for details
        :raises qarnot.exceptions.MaxTaskException: Task quota reached
        :raises qarnot.exceptions.NotEnoughCreditsException: Not enough credits
        :raises qarnot.exceptions.UnauthorizedException: invalid credentials

        .. note:: Will ensure all added file are on the resource bucket
           regardless of their uploading mode.
        .. note:: If this function is interrupted (script killed for example),
           but the task is submitted, the task will still be executed remotely
           (results will not be downloaded)
        .. warning:: Will override *output_dir* content.
        """
        self.submit()
        self.wait(timeout=job_timeout, live_progress=live_progress)
        if job_timeout is not None:
            self.abort()
        if output_dir is not None:
            self.download_results(output_dir, progress=results_progress)

    def resume(self, output_dir: str, job_timeout: float = None, live_progress: bool = False, results_progress: bool = None) -> None:
        """Resume waiting for this task if it is still in submitted mode.
        Equivalent to :meth:`wait` + :meth:`download_results`.

        :param str output_dir: path to a directory that will contain the results
        :param float job_timeout: Number of seconds before the task :meth:`abort` if it is not
          already finished
        :param bool live_progress: display a live progress
        :param results_progress: can be a callback (read,total,filename) or True to display a progress bar
        :type results_progress: bool or function(float, float, str)
        :raises qarnot.exceptions.QarnotGenericException: API general error, see message for details
        :raises qarnot.exceptions.UnauthorizedException: invalid credentials
        :raises qarnot.exceptions.MissingTaskException: task does not exist

        .. note:: Do nothing if the task has not been submitted.
        .. warning:: Will override *output_dir* content.
        """
        if self._uuid is not None:
            self.wait(timeout=job_timeout, live_progress=live_progress)
            self.download_results(output_dir, progress=results_progress)

    def submit(self) -> None:
        """Submit task to the cluster if it is not already submitted.

        :raises qarnot.exceptions.QarnotGenericException: API general error, see message for details
        :raises qarnot.exceptions.MaxTaskException: Task quota reached
        :raises qarnot.exceptions.NotEnoughCreditsException: Not enough credits
        :raises qarnot.exceptions.UnauthorizedException: invalid credentials

        .. note:: Will ensure all added files are on the resource bucket
           regardless of their uploading mode.

        .. note:: To get the results, call :meth:`download_results` once the job is done.
        """
        self._pre_submit()

        payload = self._to_json()
        resp = self._connection._post(get_url('tasks'), json=payload)

        if resp.status_code == 404:
            raise MissingBucketException(resp.json()['message'])
        elif resp.status_code == 403:
            raise MaxTaskException(resp.json()['message'])
        elif resp.status_code == 402:
            raise NotEnoughCreditsException(resp.json()['message'])
        raise_on_error(resp)
        self._uuid = resp.json()['uuid']

        self._post_submit()

    def _pre_submit(self) -> None:
        """Pre submit action on the task & its resources"""
        if self._uuid is None:
            for resource_buckets in self.resources:
                resource_buckets.flush()

    def _post_submit(self) -> None:
        """Post submit action on the task after submission"""
        if not isinstance(self._snapshots, bool):
            self.snapshot(self._snapshots)

        self.update(True)

    def abort(self) -> None:
        """Abort this task if running.

        :raises qarnot.exceptions.QarnotGenericException: API general error, see message for details
        :raises qarnot.exceptions.UnauthorizedException: invalid credentials
        :raises qarnot.exceptions.MissingTaskException: task does not exist
        """
        self.update(True)

        resp = self._connection._post(
            get_url('task abort', uuid=self._uuid))

        if resp.status_code == 404:
            raise MissingTaskException(resp.json()['message'])
        raise_on_error(resp)

        self.update(True)

    def update_resources(self) -> None:
        """ Update resources for a running task.

        The typical workflow is as follows:
           1. Upload new files on your resource bucket,
           2. Call this method,
           3. The new files will appear on all the compute nodes in the same resources folder as original resources

        Note: There is no way to know when the files are effectively transfered. This information is available on the compute node only.
        Note: The update is additive only: files deleted from the bucket will NOT be deleted from the task's resources directory.

        :raises qarnot.exceptions.QarnotGenericException: API general error, see message for details
        :raises qarnot.exceptions.UnauthorizedException: invalid credentials
        :raises qarnot.exceptions.MissingTaskException: task does not exist
        """

        self.update(True)
        resp = self._connection._patch(
            get_url('task update', uuid=self._uuid))

        if resp.status_code == 404:
            raise MissingTaskException(resp.json()['message'])
        raise_on_error(resp)

        self.update(True)

    def delete(self, purge_resources: bool = False, purge_results: bool = False) -> None:
        """Delete this task on the server.

        :param bool purge_resources: parameter value is used to determine if the bucket is also deleted.
                Defaults to False.

        :param bool purge_results: parameter value is used to determine if the bucket is also deleted.
                Defaults to False.

        :raises qarnot.exceptions.QarnotGenericException: API general error, see message for details
        :raises qarnot.exceptions.UnauthorizedException: invalid credentials
        :raises qarnot.exceptions.MissingTaskException: task does not exist
        """
        if purge_resources or purge_results:
            self._update_if_summary()

        if self._auto_update:
            self._auto_update = False

        if self._uuid is None:
            return

        if purge_results and self.results is not None:
            try:
                self.results.update()
            except MissingBucketException:
                purge_results = False

        resp = self._connection._delete(
            get_url('task update', uuid=self._uuid))
        if resp.status_code == 404:
            raise MissingTaskException(resp.json()['message'])
        raise_on_error(resp)

        if purge_resources and len(self.resources) != 0:
            toremove = []
            for r in self.resources:
                try:
                    r.update()
                    r.delete()
                    toremove.append(r)
                except (MissingBucketException, BucketStorageUnavailableException) as exception:
                    warnings.warn(str(exception))
            for tr in toremove:
                self.resources.remove(tr)

        if purge_results and self._result_object is not None:
            try:
                self._result_object.delete()
                self._result_object = None
            except MissingBucketException as exception:
                warnings.warn(str(exception))

        self._state = "Deleted"
        self._uuid = None

    def update(self, flushcache: bool = False) -> None:
        """
        Update the task object from the REST Api.
        The flushcache parameter can be used to force the update, otherwise a cached version of the object
        will be served when accessing properties of the object.
        Some methods will flush the cache, like :meth:`submit`, :meth:`abort`, :meth:`wait` and :meth:`instant`.
        Cache behavior is configurable with :attr:`auto_update` and :attr:`update_cache_time`.

        :raises qarnot.exceptions.QarnotGenericException: API general error, see message for details
        :raises qarnot.exceptions.UnauthorizedException: invalid credentials
        :raises qarnot.exceptions.MissingTaskException: task does not represent a
          valid one
        """
        if self._uuid is None:
            return

        now = time.time()
        if (now - self._last_cache) < self._update_cache_time and not flushcache:
            return

        resp = self._connection._get(
            get_url('task update', uuid=self._uuid))
        if resp.status_code == 404:
            raise MissingTaskException(resp.json()['message'])

        raise_on_error(resp)
        self._update(resp.json())
        self._last_cache = time.time()
        self._is_summary = False

    def _update(self, json_task: Dict) -> None:
        """Update this task from retrieved info."""
        self._name = json_task['name']
        self._shortname = json_task.get('shortname')
        self._profile = json_task['profile']
        self._pool_uuid = json_task.get('poolUuid')
        self._job_uuid = json_task.get('jobUuid')
        self._instancecount = json_task.get('instanceCount')
        self._advanced_range = json_task.get('advancedRanges')
        self._wait_for_pool_resources_synchronization = json_task.get('waitForPoolResourcesSynchronization', None)

        if json_task['runningCoreCount'] is not None:
            self._running_core_count = json_task['runningCoreCount']
        if json_task['runningInstanceCount'] is not None:
            self._running_instance_count = json_task['runningInstanceCount']

        if 'resourceBuckets' in json_task and json_task['resourceBuckets']:
            self._resource_object_ids = json_task['resourceBuckets']

        if 'advancedResourceBuckets' in json_task and json_task['advancedResourceBuckets']:
            self._resource_object_advanced = json_task['advancedResourceBuckets']

        if 'resultBucket' in json_task and json_task['resultBucket']:
            self._result_object_id = json_task['resultBucket']

        if 'status' in json_task:
            self._status = json_task['status']
        self._creation_date = _util.parse_datetime(json_task['creationDate'])
        if 'errors' in json_task:
            self._errors = [Error(d) for d in json_task['errors']]
        else:
            self._errors = []

        if 'constants' in json_task:
            for constant in json_task['constants']:
                self._constants[constant.get('key')] = constant.get('value')

        self._uuid = json_task['uuid']
        self._state = json_task['state']
        self._tags = json_task.get('tags', None)
        self._upload_results_on_cancellation = json_task.get('uploadResultsOnCancellation', None)
        if 'resultsCount' in json_task:
            if self._rescount < json_task['resultsCount']:
                self._dirty = True
            self._rescount = json_task['resultsCount']

        if 'resultsBlacklist' in json_task:
            self._results_blacklist = json_task['resultsBlacklist']
        if 'resultsWhitelist' in json_task:
            self._results_whitelist = json_task['resultsWhitelist']
        if 'snapshotWhitelist' in json_task:
            self._snapshot_whitelist = json_task['snapshotWhitelist']
        if 'snapshotBlacklist' in json_task:
            self._snapshot_blacklist = json_task['snapshotBlacklist']

        if 'completedInstances' in json_task:
            self._completed_instances = [CompletedInstance(x) for x in json_task['completedInstances']]
        else:
            self._completed_instances = []

        if 'autoDeleteOnCompletion' in json_task:
            self._auto_delete = json_task["autoDeleteOnCompletion"]
        if 'completionTimeToLive' in json_task:
            self._completion_time_to_live = json_task["completionTimeToLive"]

        self._previous_state = json_task.get("previousState", None)
        self._state_transition_time = json_task.get("stateTransitionTime", None)
        self._previous_state_transition_time = json_task.get("previousStateTransitionTime", None)
        self._last_modified = json_task.get("lastModified", None)
        self._snapshot_interval = json_task.get("snapshotInterval", None)
        self._progress = json_task.get("progress", None)
        self._execution_time = json_task.get("executionTime", None)
        self._wall_time = json_task.get("wallTime", None)
        self._end_date = json_task.get("endDate", None)
        self._labels = json_task.get("labels", {})
        self._hardware_constraints = json_task.get("hardwareConstraints", [])

    @classmethod
    def from_json(cls, connection: ConnectionType, json_task: Dict, is_summary: bool = False) -> TaskType:
        """Create a Task object from a json task.

        :param qarnot.connection.Connection connection: the cluster connection
        :param dict json_task: Dictionary representing the task
        :returns: The created :class:`~qarnot.task.Task`.
        """
        if 'instanceCount' in json_task:
            instance_count_or_range = json_task['instanceCount']
        else:
            instance_count_or_range = json_task['advancedRanges']
        new_task = cls(connection,
                       json_task['name'],
                       json_task.get('profile') or json_task.get('poolUuid'),
                       instance_count_or_range)
        new_task._update(json_task)
        new_task._is_summary = is_summary
        return new_task

    def commit(self) -> None:
        """Replicate local changes on the current object instance to the REST API

        :raises qarnot.exceptions.QarnotGenericException: API general error, see message for details
        :raises qarnot.exceptions.UnauthorizedException: invalid credentials

        .. note:: When updating buckets' properties, auto update will be disabled until commit is called.
        """
        data = self._to_json()
        resp = self._connection._put(get_url('task update', uuid=self._uuid), json=data)
        self._auto_update = self._last_auto_update_state
        if resp.status_code == 404:
            raise MissingTaskException(resp.json()['message'])

        raise_on_error(resp)

    def wait(self, timeout: float = None, live_progress: bool = False) -> bool:
        """Wait for this task until it is completed.

        :param float timeout: maximum time (in seconds) to wait before returning
           (None => no timeout)
        :param bool live_progress: display a live progress

        :rtype: :class:`bool`
        :returns: Is the task finished

        :raises qarnot.exceptions.QarnotGenericException: API general error, see message for details
        :raises qarnot.exceptions.UnauthorizedException: invalid credentials
        :raises qarnot.exceptions.MissingTaskException: task does not represent a valid
          one
        """

        live_progress = live_progress and sys.stdout.isatty()

        if live_progress:
            try:
                widgets = [
                    Percentage(),
                    ' ', AnimatedMarker(),
                    ' ', Bar(),
                    ' ', AdaptiveETA()
                ]
                progressbar = ProgressBar(widgets=widgets, max_value=100)
            except Exception:  # pylint: disable=W0703
                live_progress = False

        start = time.time()
        if self._uuid is None:
            self.update(True)
            return False

        nap = min(10, timeout) if timeout is not None else 10

        self.update(True)
        while self._state in RUNNING_DOWNLOADING_STATES:
            if live_progress:
                n = 0
                progress = 0
                while True:
                    time.sleep(1)
                    n += 1
                    if n >= nap:
                        break
                    progress = self.status.execution_progress if self.status is not None else 0
                    progress = max(0, min(progress, 100))
                    progressbar.update(progress)
            else:
                time.sleep(nap)

            self.update(True)

            if timeout is not None:
                elapsed = time.time() - start
                if timeout <= elapsed:
                    self.update()
                    return False
                else:
                    nap = min(10, timeout - elapsed)
        self.update(True)
        if live_progress:
            progressbar.finish()
        return True

    def snapshot(self, interval: int) -> None:
        """Start snapshooting results.
        If called, this task's results will be periodically
        updated, instead of only being available at the end.

        Snapshots will be taken every *interval* second from the time
        the task is submitted.

        :param int interval: the interval in seconds at which to take snapshots

        :raises qarnot.exceptions.QarnotGenericException: API general error, see message for details
        :raises qarnot.exceptions.UnauthorizedException: invalid credentials
        :raises qarnot.exceptions.MissingTaskException: task does not represent a
          valid one

        .. note:: To get the temporary results, call :meth:`download_results`.
        """
        if self._uuid is None:
            self._snapshots = interval
            return
        resp = self._connection._post(get_url('task snapshot', uuid=self._uuid),
                                      json={"interval": interval})

        if resp.status_code == 400:
            raise ValueError(interval)
        elif resp.status_code == 404:
            raise MissingTaskException(resp.json()['message'])

        raise_on_error(resp)

        self._snapshots = True

    def instant(self) -> None:
        """Make a snapshot of the current task.

        :raises qarnot.exceptions.QarnotGenericException: API general error, see message for details
        :raises qarnot.exceptions.UnauthorizedException: invalid credentials
        :raises qarnot.exceptions.MissingTaskException: task does not exist

        .. note:: To get the temporary results, call :meth:`download_results`.
        """
        if self._uuid is None:
            return

        resp = self._connection._post(get_url('task instant', uuid=self._uuid),
                                      json=None)

        if resp.status_code == 404:
            raise MissingTaskException(resp.json()['message'])
        raise_on_error(resp)

        self.update(True)

    @property
    def state(self):
        """:type: :class:`str`
        :getter: return this task's state

        State of the task.

        Value is in
           * UnSubmitted
           * Submitted
           * PartiallyDispatched
           * FullyDispatched
           * PartiallyExecuting
           * FullyExecuting
           * UploadingResults
           * DownloadingResults
           * PendingCancel
           * Cancelled
           * Success
           * Failure
           * PendingDelete

        .. warning::
           this is the state of the task when the object was retrieved,
           call :meth:`update` for up to date value.
        """
        if self._auto_update:
            self.update()
        return self._state

    @property
    def resources(self):
        """:type: list(:class:`~qarnot.bucket.Bucket`)
        :getter: Returns this task's resources bucket
        :setter: Sets this task's resources bucket

        Represents resource files.
        """
        self._update_if_summary()
        if self._auto_update:
            self.update()
        if not self._resource_objects:
            for adv in self._resource_object_advanced:
                d = Bucket.from_json(self._connection, adv)
                self._resource_objects.append(d)

            for bid in self._resource_object_ids:
                d = Bucket(self._connection, bid)
                self._resource_objects.append(d)

        return self._resource_objects

    @resources.setter
    def resources(self, value: List[BucketType]):
        """This is a setter."""
        self._resource_objects = value

    @property
    def results(self):
        """:type: :class:`~qarnot.bucket.Bucket`
        :getter: Returns this task's results bucket
        :setter: Sets this task's results bucket

        Represents results files."""
        self._update_if_summary()
        if self._result_object is None and self._result_object_id is not None:
            self._result_object = Bucket(self._connection, self._result_object_id)

        if self._auto_update:
            self.update()

        return self._result_object

    @results.setter
    def results(self, value: Bucket):
        """ This is a setter."""
        self._result_object = value

    def download_results(self, output_dir: str, progress: Union[bool, Callable[[float, float, str], None]] = None) -> None:
        """Download results in given *output_dir*.

        :param str output_dir: local directory for the retrieved files.
        :param progress: can be a callback (read,total,filename)  or True to display a progress bar
        :type progress: bool or function(float, float, str)
        :raises qarnot.exceptions.MissingBucketException: the bucket is not on the server
        :raises qarnot.exceptions.QarnotGenericException: API general error, see message for details
        :raises qarnot.exceptions.UnauthorizedException: invalid credentials

        .. warning:: Will override *output_dir* content.

        """

        if self._uuid is not None:
            self.update()

        if not path.exists(output_dir):
            makedirs(output_dir)

        if self._dirty:
            self.results.get_all_files(output_dir, progress=progress)

    def stdout(self):
        """Get the standard output of the task
        since the submission of the task.

        :rtype: :class:`str`
        :returns: The standard output.

        :raises qarnot.exceptions.QarnotGenericException: API general error, see message for details
        :raises qarnot.exceptions.UnauthorizedException: invalid credentials
        :raises qarnot.exceptions.MissingTaskException: task does not exist

        .. note:: The buffer is circular, if stdout is too big, prefer calling
          :meth:`fresh_stdout` regularly.
        """
        if self._uuid is None:
            return ""
        resp = self._connection._get(
            get_url('task stdout', uuid=self._uuid))

        if resp.status_code == 404:
            raise MissingTaskException(resp.json()['message'])

        raise_on_error(resp)

        return resp.text

    def fresh_stdout(self):
        """Get what has been written on the standard output since last time
        this function was called or since the task has been submitted.

        :rtype: :class:`str`
        :returns: The new output since last call.

        :raises qarnot.exceptions.QarnotGenericException: API general error, see message for details
        :raises qarnot.exceptions.UnauthorizedException: invalid credentials
        :raises qarnot.exceptions.MissingTaskException: task does not exist
        """
        if self._uuid is None:
            return ""
        resp = self._connection._post(
            get_url('task stdout', uuid=self._uuid))

        if resp.status_code == 404:
            raise MissingTaskException(resp.json()['message'])

        raise_on_error(resp)
        return resp.text

    def stderr(self):
        """Get the standard error of the task
        since the submission of the task.

        :rtype: :class:`str`
        :returns: The standard error.

        :raises qarnot.exceptions.QarnotGenericException: API general error, see message for details
        :raises qarnot.exceptions.UnauthorizedException: invalid credentials
        :raises qarnot.exceptions.MissingTaskException: task does not exist

        .. note:: The buffer is circular, if stderr is too big, prefer calling
          :meth:`fresh_stderr` regularly.
        """
        if self._uuid is None:
            return ""
        resp = self._connection._get(
            get_url('task stderr', uuid=self._uuid))

        if resp.status_code == 404:
            raise MissingTaskException(resp.json()['message'])

        raise_on_error(resp)
        return resp.text

    def fresh_stderr(self):
        """Get what has been written on the standard error since last time
        this function was called or since the task has been submitted.

        :rtype: :class:`str`
        :returns: The new error messages since last call.

        :raises qarnot.exceptions.QarnotGenericException: API general error, see message for details
        :raises qarnot.exceptions.UnauthorizedException: invalid credentials
        :raises qarnot.exceptions.MissingTaskException: task does not exist
        """
        if self._uuid is None:
            return ""
        resp = self._connection._post(
            get_url('task stderr', uuid=self._uuid))

        if resp.status_code == 404:
            raise MissingTaskException(resp.json()['message'])

        raise_on_error(resp)
        return resp.text

    @property
    def uuid(self):
        """:type: :class:`str`
        :getter: Returns this task's uuid

        The task's uuid.

        Automatically set when a task is submitted.
        """
        return self._uuid

    @property
    def name(self):
        """:type: :class:`str`
        :getter: Returns this task's name
        :setter: Sets this task's name

        The task's name.

        Can be set until task is submitted.
        """
        return self._name

    @name.setter
    def name(self, value: str):
        """Setter for name."""
        if self.uuid is not None:
            raise AttributeError("can't set attribute on a launched task")
        else:
            self._name = value

    @property
    def shortname(self):
        """:type: :class:`str`
        :getter: Returns this task's shortname
        :setter: Sets this task's shortname

        The task's shortname, must be DNS compliant and unique, if not provided, will default to :attr:`uuid`.

        Can be set until task is submitted.
        """
        return self._shortname

    @shortname.setter
    def shortname(self, value: str):
        """Setter for shortname."""
        if self.uuid is not None:
            raise AttributeError("can't set attribute on a launched task")
        else:
            self._shortname = value

    @property
    def tags(self):
        """:type: :class:list(`str`)
        :getter: Returns this task's tags
        :setter: Sets this task's tags

        Custom tags.
        """
        self._update_if_summary()
        if self._auto_update:
            self.update()

        return self._tags

    @tags.setter
    def tags(self, value: List[str]):
        """setter for tags"""
        self._tags = value
        self._auto_update = False

    @property
    def pool(self):
        """:type: :class:`~qarnot.pool.Pool`
        :getter: Returns this task's pool
        :setter: Sets this task's pool

        The pool to run the task in.

        Can be set until :meth:`run` is called.

        .. warning:: This property is mutually exclusive with :attr:`profile`
        """
        return self._connection.retrieve_pool(self._pool_uuid)

    @pool.setter
    def pool(self, value: PoolType):
        """setter for pool"""
        if self.uuid is not None:
            raise AttributeError("can't set attribute on a launched task")
        if self._profile is not None:
            raise AttributeError("Can't set pool if profile is not None")
        else:
            self._pool_uuid = value.uuid

    @property
    def profile(self):
        """:type: :class:`str`
        :getter: Returns this task's profile
        :setter: Sets this task's profile

        The profile to run the task with.

        Can be set until :meth:`run` or :meth:`submit` is called.

         .. warning:: This property is mutually exclusive with :attr:`pool`
        """
        return self._profile

    @profile.setter
    def profile(self, value: str):
        """setter for profile"""
        if self.uuid is not None:
            raise AttributeError("can't set attribute on a launched task")
        if self._pool_uuid is not None:
            raise AttributeError("Can't set profile if pool is not None")
        else:
            self._profile = value

    @property
    def instancecount(self):
        """:type: :class:`int`
        :getter: Returns this task's instance count
        :setter: Sets this task's instance count

        Number of instances needed for the task.

        Can be set until :meth:`run` or :meth:`submit` is called.

        :raises AttributeError: if :attr:`advanced_range` is not None when setting this property

        .. warning:: This property is mutually exclusive with :attr:`advanced_range`
        """
        return self._instancecount

    @instancecount.setter
    def instancecount(self, value: int):
        """Setter for instancecount."""
        if self.uuid is not None:
            raise AttributeError("can't set attribute on a launched task")

        if self.advanced_range is not None:
            raise AttributeError("Can't set instancecount if advanced_range is not None")
        self._instancecount = value

    @property
    def running_core_count(self):
        """:type: :class:`int`
        :getter: Returns this task's running core count

        Number of core running inside the task.
        """
        return self._running_core_count

    @property
    def running_instance_count(self):
        """:type: :class:`int`
        :getter: Returns this task's running instance count

        Number of instances running inside the task.
        """
        return self._running_instance_count

    @property
    def advanced_range(self):
        """:type: :class:`str`
        :getter: Returns this task's advanced range
        :setter: Sets this task's advanced range

        Advanced instances range selection.

        Allows to select which instances will be computed.
        Should be None or match the following extended regular expression
        """r"""**"([0-9]+|[0-9]+-[0-9]+)(,([0-9]+|[0-9]+-[0-9]+))+"**
        eg: 1,3-8,9,12-19

        Can be set until :meth:`run` is called.

        :raises AttributeError: if :attr:`instancecount` is not 0 when setting this property

        .. warning:: This property is mutually exclusive with :attr:`instancecount`
        """
        return self._advanced_range

    @advanced_range.setter
    def advanced_range(self, value: str):
        """Setter for advanced_range."""
        if self.uuid is not None:
            raise AttributeError("can't set attribute on a launched task")
        if self.instancecount != 0:
            raise AttributeError("Can't set advanced_range if instancecount is not 0")
        self._advanced_range = value

    @property
    def snapshot_whitelist(self):
        """:type: :class:`str`
        :getter: Returns this task's snapshot whitelist
        :setter: Sets this task's snapshot whitelist

        Snapshot white list (regex) for :meth:`snapshot` and :meth:`instant`

        Can be set until task is submitted.
        """
        self._update_if_summary()
        return self._snapshot_whitelist

    @snapshot_whitelist.setter
    def snapshot_whitelist(self, value: str):
        """Setter for snapshot whitelist, this can only be set before tasks submission
        """
        if self.uuid is not None:
            raise AttributeError("can't set attribute on a launched task")
        self._snapshot_whitelist = value

    @property
    def snapshot_blacklist(self):
        """:type: :class:`str`
        :getter: Returns this task's snapshot blacklist
        :setter: Sets this task's snapshot blacklist

        Snapshot black list (regex) for :meth:`snapshot` :meth:`instant`

        Can be set until task is submitted.
        """
        self._update_if_summary()
        return self._snapshot_blacklist

    @snapshot_blacklist.setter
    def snapshot_blacklist(self, value: str):
        """Setter for snapshot blacklist, this can only be set before tasks submission
        """
        if self.uuid is not None:
            raise AttributeError("can't set attribute on a launched task")
        self._snapshot_blacklist = value

    @property
    def results_whitelist(self):
        """:type: :class:`str`
        :getter: Returns this task's results whitelist
        :setter: Sets this task's results whitelist

        Results whitelist (regex)

        Can be set until task is submitted.
        """
        self._update_if_summary()
        return self._results_whitelist

    @results_whitelist.setter
    def results_whitelist(self, value: str):
        """Setter for results whitelist, this can only be set before tasks submission
        """
        if self.uuid is not None:
            raise AttributeError("can't set attribute on a launched task")
        self._results_whitelist = value

    @property
    def results_blacklist(self):
        """:type: :class:`str`
        :getter: Returns this task's results blacklist
        :setter: Sets this task's results blacklist

        Results blacklist (regex)

        Can be set until task is submitted.
        """
        self._update_if_summary()
        if self._auto_update:
            self.update()

        return self._results_blacklist

    @results_blacklist.setter
    def results_blacklist(self, value: str):
        """Setter for results blacklist, this can only be set before tasks submission
        """
        if self.uuid is not None:
            raise AttributeError("can't set attribute on a launched task")
        self._results_blacklist = value

    @property
    def status(self):
        """:type: :class:`qarnot.status.Status`
        :getter: Returns this task's status

        Status of the task
        """
        self._update_if_summary()
        if self._auto_update:
            self.update()

        if self._status:
            return Status(self._status)
        return self._status

    @property
    def completed_instances(self):
        """:type: list(:class:`CompletedInstance`)
        :getter: Return this task's completed instances
        """
        self._update_if_summary()
        if self._auto_update:
            self.update()
        return self._completed_instances

    @property
    def creation_date(self):
        """:type: :class:`datetime.datetime`

        :getter: Returns this task's creation date

        Creation date of the task (UTC Time)
        """
        return self._creation_date

    @property
    def errors(self):
        """:type: list(:class:``Error``)
        :getter: Returns this task's errors if any.

        Error reason if any, empty string if none
        """
        self._update_if_summary()
        if self._auto_update:
            self.update()

        return self._errors

    @property
    def constants(self):
        """:type: dictionary{:class:`str` : :class:`str`}
        :getter: Returns this task's constants dictionary.
        :setter: set the task's constants dictionary.

        Update the constants if needed.
        Constants are used to configure the profiles,
        set them to change your profile's parameters.
        """
        self._update_if_summary()
        if self._auto_update:
            self.update()

        return self._constants

    @constants.setter
    def constants(self, value: Dict[str, str]):
        """Setter for constants
        """
        self._update_if_summary()
        if self._auto_update:
            self.update()

        self._constants = value

    @property
    def constraints(self):
        """:type: dictionary{:class:`str` : :class:`str`}
        :getter: Returns this task's constraints dictionary.
        :setter: set the task's constraints dictionary.

        Update the constraints if needed.
        advance usage
        """
        self._update_if_summary()
        if self._auto_update:
            self.update()

        return self._constraints

    @constraints.setter
    def constraints(self, value: Dict[str, str]):
        """Setter for constraints
        """
        self._update_if_summary()
        if self._auto_update:
            self.update()

        self._constraints = value

    @property
    def labels(self):
        """:type: dictionary{:class:`str` : :class:`str`}
        :getter: Returns this task's labels dictionary.
        :setter: Set the task's labels dictionary.

        Update the labels if needed.
        Labels are used to attach arbitrary key / value pairs
        to a task in order to find them later with greater ease.
        They do not affect the execution of a task.
        """
        self._update_if_summary()
        if self._auto_update:
            self.update()

        return self._labels

    @labels.setter
    def labels(self, value):
        """Setter for labels
        """
        self._update_if_summary()
        if self._auto_update:
            self.update()

        if self.uuid is not None:
            raise AttributeError("can't set attribute on a launched task")

        self._labels = value

    @property
    def wait_for_pool_resources_synchronization(self):
        """:type: :class:`bool` or None
        :getter: Returns this task's wait_for_pool_resources_synchronization.
        :setter: set the task's wait_for_pool_resources_synchronization.

        :raises AttributeError: can't set this attribute on a launched task
        """
        return self._wait_for_pool_resources_synchronization

    @wait_for_pool_resources_synchronization.setter
    def wait_for_pool_resources_synchronization(self, value: Optional[bool]):
        """Setter for wait_for_pool_resources_synchronization
        """
        if self.uuid is not None:
            raise AttributeError("can't set attribute on a launched task")
        self._wait_for_pool_resources_synchronization = value

    @property
    def auto_update(self):
        """:type: :class:`bool`

        :getter: Returns this task's auto update state
        :setter: Sets this task's auto update state

        Auto update state, default to True
        When auto update is disabled properties will always return cached value
        for the object and a call to :meth:`update` will be required to get latest values from the REST Api.
        """
        return self._auto_update

    @auto_update.setter
    def auto_update(self, value: bool):
        """Setter for auto_update feature
        """
        self._auto_update = value
        self._last_auto_update_state = self._auto_update

    @property
    def update_cache_time(self):
        """:type: :class:`int`

        :getter: Returns this task's auto update state
        :setter: Sets this task's auto update state

        Cache expiration time, default to 5s
        """
        return self._update_cache_time

    @update_cache_time.setter
    def update_cache_time(self, value: int):
        """Setter for update_cache_time
        """
        self._update_cache_time = value

    @property
    def upload_results_on_cancellation(self) -> Optional[bool]:
        """:type: :class:`bool`, optional

        :getter: Whether task results will be uploaded upon task cancellation
        :setter: Set to `True` to upload results on task cancellation, `False` to
                 make sure they won't be uploaded, and keep to `None` to get the
                 default behavior.

        :raises AttributeError: trying to set this after the task is submitted
        """
        return self._upload_results_on_cancellation

    @upload_results_on_cancellation.setter
    def upload_results_on_cancellation(self, value: Optional[bool]):
        """Setter for upload_results_on_cancellation
        """
        if self.uuid is not None:
            raise AttributeError("can't set attribute on a launched task")

        self._upload_results_on_cancellation = value

    def set_task_dependencies_from_uuids(self, uuids: Uuid):
        """Setter for the task dependencies using uuid
        """
        self._dependentOn += uuids

    def set_task_dependencies_from_tasks(self, tasks: List[TaskType]):
        """Setter for the task dependencies using tasks
        """
        self._dependentOn += [task._uuid for task in tasks]

    @property
    def hardware_constraints(self):
        """:type: :class:`list`, optional

        :getter: setup the hardware constraints
        :setter: Set up specific hardware constraints.

        :raises AttributeError: trying to set this after the task is submitted
        """
        return self._hardware_constraints

    @hardware_constraints.setter
    def hardware_constraints(self, value):
        """Setter for hardware_constraints
        """
        if self.uuid is not None:
            raise AttributeError("can't set attribute on a launched task")

        self._hardware_constraints = value

    @property
    def auto_delete(self):
        """Autodelete this Task if it is finished and your max number of task is reach

        Can be set until :meth:`run` or :meth:`submit` is called.

        :type: :class:`bool`
        :getter: Returns is this task must autodelete
        :setter: Sets this task's autodelete
        :default_value: "False"

        :raises AttributeError: if you try to reset the auto_delete after the task is submit
        """
        self._update_if_summary()
        return self._auto_delete

    @auto_delete.setter
    def auto_delete(self, value: bool):
        """Setter for auto_delete, this can only be set before tasks submission
        """
        self._update_if_summary()
        if self.uuid is not None:
            raise AttributeError("can't set attribute on a launched task")
        self._auto_delete = value

    @property
    def completion_ttl(self):
        """The task will be auto delete `completion_ttl` after it is finished

        Can be set until :meth:`run` or :meth:`submit` is called.

        :getter:  Returns this task's completed time to live.
        :type: :class:`str`
        :setter: Sets this task's this task's completed time to live.
        :type: :class:`str` or :class:`datetime.timedelta`
        :default_value: ""

        :raises AttributeError: if you try to set it after the task is submitted

        The `completion_ttl` must be a timedelta or a time span format string (example: 'd.hh:mm:ss' or 'hh:mm:ss')
        """
        self._update_if_summary()
        return self._completion_time_to_live

    @completion_ttl.setter
    def completion_ttl(self, value):
        """Setter for completion_ttl, this can only be set before tasks submission"""
        self._update_if_summary()
        if self._uuid is not None:
            raise AttributeError("can't set attribute on a submitted job")
        self._completion_time_to_live = _util.parse_to_timespan_string(value)

    @property
    def previous_state(self):
        """
        :type: :class:`str`
        :getter: Returns the running task's previous state
        """
        self._update_if_summary()
        return self._previous_state

    @property
    def state_transition_time(self):
        """
        :type: :class:`str`
        :getter: Returns the running task's transition state time

        task state transition time (UTC Time)
        """
        self._update_if_summary()
        return self._state_transition_time

    @property
    def previous_state_transition_time(self):
        """
        :type: :class:`str`
        :getter: Returns the running task's previous transition state time

        task previous state transition time (UTC Time)
        """
        self._update_if_summary()
        return self._previous_state_transition_time

    @property
    def last_modified(self):
        """
        :type: :class:`str`
        :getter: Returns the running task's last modification time

        task's last modified time (UTC Time)
        """
        self._update_if_summary()
        return self._last_modified

    @property
    def execution_time(self):
        """
        :type: :class:`str`
        :getter: Returns the running task's total CPU execution time.

        task's execution time of all it's instances.
        """
        self._update_if_summary()
        return self._execution_time

    @property
    def end_date(self):
        """
        :type: :class:`str`
        :getter: Returns the finished task's end date.

        task's end date (UTC Time)
        """
        self._update_if_summary()
        return self._end_date

    @property
    def wall_time(self):
        """
        :type: :class:`str`
        :getter: task's wall time.

        task's wall time
        """
        self._update_if_summary()
        return self._wall_time

    @property
    def snapshot_interval(self):
        """
        :type: :class:`int`
        :getter: task's snapshot interval in seconds.

        task's snapshot interval in seconds.
        """
        self._update_if_summary()
        return self._snapshot_interval

    @property
    def progress(self):
        """
        :type: :class:`float`
        :getter: task's progress in %.

        task's progress in %.
        """
        self._update_if_summary()
        return self._progress

    def _to_json(self):
        """Get a dict ready to be json packed from this task."""
        const_list = [
            {'key': key, 'value': value}
            for key, value in self._constants.items()
        ]
        constr_list = [
            {'key': key, 'value': value}
            for key, value in self._constraints.items()
        ]

        json_task = {
            'name': self._name,
            'profile': self._profile,
            'poolUuid': self._pool_uuid,
            'jobUuid': None if self._job_uuid == "" else self._job_uuid,
            'constants': const_list,
            'constraints': constr_list,
            'dependencies': {},
            'waitForPoolResourcesSynchronization': self._wait_for_pool_resources_synchronization,
            'uploadResultsOnCancellation': self._upload_results_on_cancellation,
            'labels': self._labels,
        }
        json_task['dependencies']["dependsOn"] = self._dependentOn

        if self._shortname is not None:
            json_task['shortname'] = self._shortname

        self._resource_object_advanced = [x.to_json() for x in self._resource_objects]
        json_task['advancedResourceBuckets'] = self._resource_object_advanced

        if self._result_object is not None:
            json_task['resultBucket'] = self._result_object.uuid

        if self._advanced_range is not None:
            json_task['advancedRanges'] = self._advanced_range
        else:
            json_task['instanceCount'] = self._instancecount

        json_task["tags"] = self._tags

        if self._snapshot_whitelist is not None:
            json_task['snapshotWhitelist'] = self._snapshot_whitelist
        if self._snapshot_blacklist is not None:
            json_task['snapshotBlacklist'] = self._snapshot_blacklist
        if self._results_whitelist is not None:
            json_task['resultsWhitelist'] = self._results_whitelist
        if self._results_blacklist is not None:
            json_task['resultsBlacklist'] = self._results_blacklist

        json_task['autoDeleteOnCompletion'] = self._auto_delete
        json_task['completionTimeToLive'] = self._completion_time_to_live
        json_task['hardwareConstraints'] = [x.to_json() for x in self._hardware_constraints]

        return json_task

    def _update_if_summary(self):
        """Trigger flush update if the task is made from a summary.

        This should be called before accessing any fields not contained in a summary task
        """
        if self._is_summary:
            self.update(True)

    def __repr__(self):
        return '{0} - {1} - {2} - {3} - InstanceCount : {4} - {5} - Resources : {6} - Results : {7}'\
            .format(self.name,
                    self.shortname,
                    self._uuid,
                    self._profile,
                    self._instancecount,
                    self.state,
                    ([bucket._uuid for bucket in self._resource_objects] if self._resource_objects is not None else ""),
                    (self._result_object.uuid if self._result_object is not None else ""))

    # Context manager
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        if (exc_type is None) or exc_type != MissingTaskException:
            self.delete()
        return False


class CompletedInstance(object):
    """Completed Instance Information

    .. note:: Read-only class
    """
    def __init__(self, json: Dict[str, Any]):
        self.instance_id = json['instanceId']
        """:type: :class:`int`

        Instance number."""

        self.state = json['state']
        """:type: :class:`str`

        Instance final state."""

        self.wall_time_sec = json['wallTimeSec']
        """:type: :class:`float`

        Instance wall time in seconds."""

        self.exec_time_sec = json['execTimeSec']
        """:type: :class:`float`

        Execution time in seconds."""

        self.exec_time_sec_ghz = json['execTimeSecGHz']
        """:type: :class:`float`

        Execution time in seconds GHz."""

        self.peak_memory_mb = json['peakMemoryMB']
        """:type: :class:`int`

        Peak memory size in MB."""

        self.average_ghz = json['averageGHz']
        """:type: :class:`float`

        Instance execution time GHz"""

        self.results = json['results']
        """:type: :class:list(`str`)

          Instance produced results"""

        self.execution_attempt_count = json.get('executionAttemptCount', 0)
        """:type: :class:`int`

        Number of execution attempt of an instance, (manly in case of preemption)."""

    def __repr__(self):
        if sys.version_info > (3, 0):
            return ', '.join("{0}={1}".format(key, val) for (key, val) in self.__dict__.items())
        else:
            return ', '.join("{0}={1}".format(key, val) for (key, val) in self.__dict__.iteritems())  # pylint: disable=no-member


class BulkTaskResponse(object):
    """Bulk Task Response Information

    .. note:: Read-only class
    """

    def __init__(self, json: Dict[str, Any]):
        self.status_code = json['statusCode']
        """:type: :class:`int`

        Status code."""

        self.uuid = json['uuid']
        """:type: :class:`str`

        Created Task Uuid."""

        self.message = json['message']
        """:type: :class:`str`

        User friendly error message."""

    def is_success(self):
        """Check that the task submit has been successful.

        :rtype: :class:`bool`
        :returns: The task creation success(depending on received uuid and the status code).
        """
        return self.status_code >= 200 and self.status_code < 300 and self.uuid

    def __repr__(self):
        if sys.version_info > (3, 0):
            return ', '.join("{0}={1}".format(key, val) for (key, val) in self.__dict__.items())
        else:
            return ', '.join("{0}={1}".format(key, val) for (key, val) in self.__dict__.iteritems())  # pylint: disable=no-member
