# Copyright (c) 2020 Adam Souzis
# SPDX-License-Identifier: MIT
"""
A Job is generated by comparing a list of specs with the last known state of the system.
Job runs tasks, each of which has a configuration spec that is executed on the running system
Each task tracks and records its modifications to the system's state
"""

import collections
import types
import itertools
import os
import json
from .support import Status, Priority, Defaults, AttributeManager, Reason, NodeState
from .result import serializeValue, ChangeRecord
from .util import UnfurlError, UnfurlTaskError, toEnum
from .merge import mergeDicts
from .runtime import OperationalInstance
from .configurator import (
    TaskView,
    ConfiguratorResult,
    PlanRequest,
    TaskRequestGroup,
    SetStateRequest,
    JobRequest,
    Dependency,
)
from .plan import Plan
from .localenv import LocalEnv

# note: need to import configurators even though it is unused
from . import display, initLogging, configurators

try:
    from time import perf_counter
except ImportError:
    from time import clock as perf_counter
import logging

logger = logging.getLogger("unfurl")


class ConfigChange(OperationalInstance, ChangeRecord):
    """
    Represents a configuration change made to the system.
    It has a operating status and a list of dependencies that contribute to its status.
    There are two kinds of dependencies:

    1. Live resource attributes that the configuration's inputs depend on.
    2. Other configurations and resources it relies on to function properly.
    """

    def __init__(
        self, parentJob=None, startTime=None, status=None, previousId=None, **kw
    ):
        OperationalInstance.__init__(self, status, **kw)
        if parentJob:  # use the parent's job id and startTime
            ChangeRecord.__init__(self, parentJob.changeId, parentJob.startTime)
        else:  # generate a new job id and use the given startTime
            ChangeRecord.__init__(self, startTime=startTime, previousId=previousId)


class JobOptions(object):
    """
    Options available to select which tasks are run, e.g. read-only

    does the config apply to the operation?
    is it out of date?
    is it in a ok state?
    """

    defaults = dict(
        parentJob=None,
        startTime=None,
        out=None,
        verbose=0,
        instance=None,
        instances=None,
        template=None,
        useConfigurator=False,
        # default options:
        add=True,  # add new templates
        update=True,  # run configurations that whose spec has changed but don't require a major version change
        repair="error",  # or 'degraded' or "missing" or "none", run configurations that are not operational and/or degraded
        upgrade=False,  # run configurations with major version changes or whose spec has changed
        force=False,  # (re)run operation regardless of instance's status or state
        verify=False,  # XXX3 discover first and set status if it differs from expected state
        readonly=False,  # only run configurations that won't alter the system
        check=False,  # if new instances exist before deploying
        dryrun=False,
        planOnly=False,
        requiredOnly=False,
        prune=False,
        destroyunmanaged=False,
        append=None,
        replace=None,
        commit=False,
        dirty="auto",  # run the job even if the repository has uncommitted changrs
        message=None,
        workflow=Defaults.workflow,
    )

    def __init__(self, **kw):
        options = self.defaults.copy()
        options["instance"] = kw.get("resource")  # old option name
        if kw.get("starttime"):
            options["startTime"] = kw["starttime"]
        options.update(kw)
        self.__dict__.update(options)
        self.userConfig = kw

    def getUserSettings(self):
        # only include settings different from the defaults
        return {
            k: self.userConfig[k]
            for k in set(self.userConfig) & set(self.defaults)
            if k != "out" and self.userConfig[k] != self.defaults[k]
        }


class ConfigTask(ConfigChange, TaskView):
    """
    receives a configSpec and a target node instance
    instantiates and runs Configurator
    updates Configurator's target's status and lastConfigChange
    """

    def __init__(self, job, configSpec, target, reason=None):
        ConfigChange.__init__(self, job)
        TaskView.__init__(self, job.runner.manifest, configSpec, target, reason)
        self.dryRun = job.dryRun
        self.verbose = job.verbose
        self._configurator = None
        self.generator = None
        self.job = job
        self.changeList = []
        self.result = None
        self.outputs = None
        # self._completedSubTasks = []

        # set the attribute manager on the root resource
        self._attributeManager = AttributeManager(self._manifest.yaml)
        self.target.root.attributeManager = self._attributeManager

    def priority():
        doc = "The priority property."

        def fget(self):
            if self._priority is None:
                return self.configSpec.shouldRun()
            else:
                return self._priority

        def fset(self, value):
            self._priority = value

        def fdel(self):
            del self._priority

        return locals()

    priority = property(**priority())

    @property
    def configurator(self):
        if self._configurator is None:
            self._configurator = self.configSpec.create()
        return self._configurator

    def startRun(self):
        self.generator = self.configurator.getGenerator(self)
        assert isinstance(self.generator, types.GeneratorType)

    def send(self, change):
        result = None
        # if isinstance(change, ConfigTask):
        #     self._completedSubTasks.append(change)
        try:
            result = self.generator.send(change)
        finally:
            # serialize configuration changes
            self.commitChanges()
        return result

    def start(self):
        self.startRun()

    def _updateStatus(self, result):
        """
        Update the instances status with the result of the operation.
        If status wasn't explicitly set but the operation changed the instance's configuration
        or state, choose a status based on the type of operation.
        """

        if result.status is not None:
            # status was explicitly set
            self.target.localStatus = result.status
            if self.target.present and self.target.created is None:
                if self.configSpec.operation not in [
                    "check",
                    "discover",
                ]:
                    self.target.created = self.changeId
        elif not result.success:
            # if any task failed and (maybe) modified, target.status will be set to error or unknown
            if result.modified:
                self.target.localStatus = (
                    Status.error if self.required else Status.degraded
                )
            elif result.modified is None:
                self.target.localStatus = Status.unknown
            # otherwise doesn't modify target status

    def _updateLastChange(self, result):
        """
        If the target's configuration or state has changed, set the instance's lastChange
        state to this tasks' changeid.
        """
        if self.target.lastChange is None:
            # hacky but always save _lastConfigChange the first time to
            # distinguish this from a brand new resource
            self.target._lastConfigChange = self.changeId
        if result.modified or self._resourceChanges.getAttributeChanges(
            self.target.key
        ):
            if self.target.lastChange != self.changeId:
                # save to create a linked list of tasks that modified the target
                self.previousId = self.target.lastChange
            self.target._lastStateChange = self.changeId

    def finishedWorkflow(self, successStatus):
        instance = self.target
        if instance.localStatus == successStatus:
            return
        instance.localStatus = successStatus
        if instance.lastChange != self.changeId:
            # save to create a linked list of tasks that modified the target
            self.previousId = instance.lastChange
        instance._lastConfigChange = self.changeId
        if successStatus == Status.ok and instance.created is None:
            instance.created = self.changeId

    def finished(self, result):
        assert result
        if self.generator:
            self.generator.close()
            self.generator = None

        self.outputs = result.outputs

        # don't set the changeId until we're finish so that we have a higher changeid
        # than nested tasks and jobs that ran
        # (task that never run will have the same changeId as its parent)
        self.setTaskId(self.job.runner.incrementTaskCount())
        # XXX2 if attributes changed validate using attributesSchema
        # XXX2 Check that configuration provided the metadata that it declared (check postCondition)

        if self.changeList:
            # merge changes together (will be saved with changeset)
            changes = self.changeList
            accum = changes.pop(0)
            while changes:
                accum = mergeDicts(accum, changes.pop(0))

            # note: this might set _lastConfigChange on instances other than this target
            self._resourceChanges.updateChanges(
                accum, self._attributeManager.statuses, self.target, self.changeId
            )
            # XXX implement:
            # if not result.applied:
            #    self._resourceChanges.rollback(self.target)

        # now that resourceChanges finalized:
        self._updateStatus(result)
        self._updateLastChange(result)
        self.result = result
        self.localStatus = Status.ok if result.success else Status.error
        return self

    def modifiedTarget(self):
        return (
            (self.result and self.result.modified)
            or self.target._lastStateChange == self.changeId
            or self.target._lastConfigChange == self.changeId
        )

    def commitChanges(self):
        """
        This can be called multiple times if the configurator yields multiple times.
        Save the changes made each time.
        """
        changes, liveDependencies = self._attributeManager.commitChanges()
        self.changeList.append(changes)
        # record the live attributes that we are dependent on
        for key, attributes in liveDependencies.items():
            for name, value in attributes.items():
                self.addDependency(key + "::" + name, value)
        return changes, liveDependencies

    # unused
    # def findLastConfigureOperation(self):
    #     if not self._manifest.changeSets:
    #         return None
    #     previousId = self.target.lastChange
    #     while previousId:
    #         previousChange = self._manifest.changeSets.get(previousId)
    #         if not previousChange:
    #             return None
    #         if previousChange.target != self.target.key:
    #             return (
    #                 None  # XXX handle case where lastChange was set by another target
    #             )
    #         if previousChange.operation == self.configSpec.operation:
    #             return previousChange
    #         previousId = previousChange.previousId
    #     return None

    def hasInputsChanged(self):
        """
        Evaluate configuration spec's inputs and compare with the current inputs' values
        """
        changeset = self._manifest.findLastOperation(
            self.target.key, self.configSpec.operation
        )
        if not changeset:
            return False

        return self.configurator.checkDigest(self, changeset)

    def hasDependenciesChanged(self):
        return False
        # XXX artifacts
        # XXX identity of requirements (how? what about imported nodes? instance keys?)
        # dynamic dependencies:
        # return any(d.hasChanged(self) for d in self.dependencies)

    # XXX unused
    # def refreshDependencies(self):
    #     for d in self.dependencies:
    #         d.refresh(self)

    @property
    def name(self):
        name = self.configSpec.name
        if self.configSpec.operation and self.configSpec.operation not in name:
            name = name + ":" + self.configSpec.operation
        if self.reason and self.reason not in name:
            return name + ":" + self.reason
        return name

    def summary(self, asJson=False):
        if self.target.name != self.target.template.name:
            rname = "%s (%s)" % (self.target.name, self.target.template.name)
        else:
            rname = self.target.name

        if self.configSpec.name != self.configSpec.className:
            cname = "%s (%s)" % (self.configSpec.name, self.configSpec.className)
        else:
            cname = self.configSpec.name

        if self._configurator:
            cClass = self._configurator.__class__
            configurator = "%s.%s" % (cClass.__module__, cClass.__name__)
        else:
            configurator = self.configSpec.className

        summary = dict(
            status=self.status.name,
            target=self.target.name,
            operation=self.configSpec.operation,
            template=self.target.template.name,
            type=self.target.template.type,
            targetStatus=self.target.status.name,
            targetState=self.target.state and self.target.state.name or None,
            changed=self.modifiedTarget(),
            configurator=configurator,
            priority=self.priority.name,
            reason=self.reason or "",
        )

        if asJson:
            return summary
        else:
            return (
                "{operation} on instance {rname} (type {type}, status {targetStatus}) "
                + "using configurator {cname}, priority: {priority}, reason: {reason}"
            ).format(cname=cname, rname=rname, **summary)

    def __repr__(self):
        return "ConfigTask(%s:%s)" % (self.target, self.name)


class Job(ConfigChange):
    """
    runs ConfigTasks and child Jobs
    """

    MAX_NESTED_SUBTASKS = 100

    def __init__(self, runner, rootResource, jobOptions, previousId=None):
        assert isinstance(jobOptions, JobOptions)
        self.__dict__.update(jobOptions.__dict__)
        super(Job, self).__init__(self.parentJob, self.startTime, Status.ok, previousId)
        self.dryRun = jobOptions.dryrun

        self.jobOptions = jobOptions
        self.runner = runner
        self.rootResource = rootResource
        self.jobRequestQueue = []
        self.unexpectedAbort = None
        self.workDone = collections.OrderedDict()
        self.timeElapsed = 0

    def getOperationalDependencies(self):
        # XXX3 this isn't right, root job might have too many and child job might not have enough
        # plus dynamic configurations probably shouldn't be included if yielded by a configurator
        for task in self.workDone.values():
            yield task

    def getOutputs(self):
        return self.rootResource.outputs.attributes

    def runQuery(self, query, trace=0):
        from .eval import evalForFunc, RefContext

        return evalForFunc(query, RefContext(self.rootResource, trace=trace))

    def createTask(self, configSpec, target, reason=None):
        # XXX2 if operation_host set, create remote task instead
        task = ConfigTask(self, configSpec, target, reason=reason)
        try:
            task.inputs
            task.configurator
        except Exception:
            UnfurlTaskError(task, "unable to create task")

        # if configSpec.hasBatchConfigurator():
        # search targets parents for a batchConfigurator
        # XXX how to associate a batchConfigurator with a resource and when is its task created?
        # batchConfigurator tasks more like a job because they have multiple changeids
        #  batchConfiguratorJob = findBatchConfigurator(configSpec, target)
        #  batchConfiguratorJob.add(task)
        #  return None

        return task

    def validateJobOptions(self):
        if self.jobOptions.instance and not self.rootResource.findResource(
            self.jobOptions.instance
        ):
            logger.warning(
                'selected instance not found: "%s"', self.jobOptions.instance
            )

    def run(self):
        self.createPlan()
        self.workDone = collections.OrderedDict()
        self.apply(self.taskRequests)
        # the only jobs left will be those that were added to resources already iterated over
        # and were not yielding inside runTask
        while self.jobRequestQueue:
            jobRequest = self.jobRequestQueue[0]
            job = self.runJobRequest(jobRequest)
            if self.shouldAbort(job):
                return self.rootResource

        return self.rootResource

    def getCandidateTaskRequests(self):
        # XXX plan might call job.runJobRequest(configuratorJob) before yielding
        planGen = self.plan.executePlan()
        result = None
        try:
            while True:
                req = planGen.send(result)
                result = yield req
        except StopIteration:
            pass

    def createPlan(self):
        self.validateJobOptions()
        self.taskRequests = list(self.getCandidateTaskRequests())

    def _getSuccessStatus(self, workflow, success):
        if isinstance(success, Status):
            return success
        if success:
            return self.plan.getSuccessStatus(workflow)
        return None

    def applyGroup(self, depth, groupRequest):
        workflow = groupRequest.workflow
        task, success = self.apply(groupRequest.children, depth, groupRequest)
        if task:
            successStatus = self._getSuccessStatus(workflow, success)
            # logging.debug("successStatus %s for %s", task.target.state, workflow)
            if successStatus is not None:
                # one of the child tasks succeeded and the workflow is one that modifies the target
                # update the target's status
                task.finishedWorkflow(successStatus)
        return task

    def apply(self, taskRequests, depth=0, parent=None):
        failed = False
        task = None
        successStatus = False
        workflow = parent and parent.workflow or None
        for taskRequest in taskRequests:
            # if parent is set, stop processing requests once one fails
            if parent and failed:
                logger.debug(
                    "Skipping task %s because previous operation failed", taskRequest
                )
                continue
            logger.info("Running task %s", taskRequest)

            if isinstance(taskRequest, TaskRequestGroup):
                _task = self.applyGroup(depth, taskRequest)
            else:
                _task = self._runOperation(taskRequest, workflow, depth)
            if not _task:
                continue
            task = _task

            if task.result.success:
                if parent and task.target is parent.target:
                    # if the task explicitly set the status use that
                    if task.result.status is not None:
                        successStatus = task.result.status
                    else:
                        successStatus = True
            else:
                failed = True
        return task, successStatus

    def runJobRequest(self, jobRequest):
        logger.debug("running jobrequest: %s", jobRequest)
        self.jobRequestQueue.remove(jobRequest)
        resourceNames = [r.name for r in jobRequest.instances]
        jobOptions = JobOptions(
            parentJob=self, repair="missing", instances=resourceNames
        )
        childJob = self.runner.createJob(jobOptions)
        childJob.setTaskId(self.runner.incrementTaskCount())
        assert childJob.parentJob is self
        childJob.run()
        return childJob

    def _dependencyCheck(self, instance):
        dependencies = list(instance.getOperationalDependencies())
        missing = [dep for dep in dependencies if not dep.operational and dep.required]
        if missing:
            reason = "required dependencies not operational: %s" % ", ".join(
                ["%s is %s" % (dep.name, dep.status.name) for dep in missing]
            )
        else:
            reason = ""
        return missing, reason

    def shouldRunTask(self, task):
        """
        Checked at runtime right before each task is run
        """
        try:
            if task._configurator:
                priority = task.configurator.shouldRun(task)
            else:
                priority = task.priority
        except Exception:
            # unexpected error don't run this
            UnfurlTaskError(task, "shouldRun failed unexpectedly")
            return False, "unexpected failure"

        if isinstance(priority, bool):
            priority = priority and Priority.required or Priority.ignore
        else:
            priority = toEnum(Priority, priority)
        if priority != task.priority:
            logger.debug(
                "configurator changed task %s priority from %s to %s",
                task,
                task.priority,
                priority,
            )
            task.priority = priority
        if not priority > Priority.ignore:
            return False, "configurator cancelled"

        if task.reason == Reason.reconfigure:
            if task.hasInputsChanged() or task.hasDependenciesChanged():
                return True, "change detected"
            else:
                return False, "no change detected"

        return True, "proceed"

    def canRunTask(self, task):
        """
        Checked at runtime right before each task is run

        * validate inputs
        * check pre-conditions to see if it can be run
        * check task if it can be run
        """
        can_run = False
        reason = ""
        try:
            if task._errors:
                can_run = False
                reason = "could not create task"
            elif task.dryRun and not task.configurator.canDryRun(task):
                can_run = False
                reason = "dry run not supported"
            else:
                missing = []
                if task.configSpec.operation != "check":
                    missing, reason = self._dependencyCheck(task.target)
                if not missing:
                    errors = task.configSpec.findInvalidateInputs(task.inputs)
                    if errors:
                        reason = "invalid inputs: %s" % str(errors)
                    else:
                        preErrors = task.configSpec.findInvalidPreconditions(
                            task.target
                        )
                        if preErrors:
                            reason = "invalid preconditions: %s" % str(preErrors)
                        else:
                            errors = task.configurator.canRun(task)
                            if not errors or not isinstance(errors, bool):
                                reason = "configurator declined: %s" % str(errors)
                            else:
                                can_run = True
        except Exception:
            UnfurlTaskError(task, "cantRunTask failed unexpectedly")
            reason = "unexpected exception in cantRunTask"
            can_run = False

        if can_run:
            return True, ""
        else:
            logger.info("could not run task %s: %s", task, reason)
            return False, "could not run: " + reason

    def shouldAbort(self, task):
        return False  # XXX3

    def _setState(self, req):
        logger.debug("setting state %s for %s", req.set_state, req.target)
        resource = req.target
        if "managed" in req.set_state:
            resource.created = False if req.set_state == "unmanaged" else self.changeId
        else:
            try:
                resource.state = req.set_state
            except KeyError:
                resource.localStatus = toEnum(Status, req.set_state)

    def _entryTest(self, req, workflow):
        """
        Operations can dynamically advance the state of a instance so that an operation
        added by the plan no longer needs to run.
        For example, a check operation may determine a resource is already created
        or a create operation may also configure and start an instance.
        """
        resource = req.target
        logging.debug(
            "_entryTest current state %s start state %s op %s workflow %s",
            resource.state,
            req.startState,
            req.configSpec.operation,
            workflow,
        )
        if req.configSpec.operation == "check":
            missing, reason = self._dependencyCheck(resource)
            if missing:
                return False, reason
            if not workflow:
                if (
                    self.isChangeId(resource.parent.created)
                    and self.getJobId(resource.parent.created) == self.changeId
                ):
                    # optimization:
                    # if parent was created during this job we don't need to run check operation
                    # we know we couldn't have existed
                    resource._localStatus = Status.pending
                    return False, "skipping check on a new instance"
                else:
                    return True, "passed"

        if self.jobOptions.force:  # always run
            return True, "passed"

        if self.plan.getSuccessStatus(workflow) == resource.status:
            return False, "instance already has desired status"

        if req.startState and resource.state and workflow == "deploy":
            # if we set a startState and force isn't set then only run
            # if we haven't already advanced to that state by another operation
            entryTest = NodeState(req.startState + 1)
            if resource.state > NodeState.started:
                # "started" is the final deploy state, target state must be stopped or deleted or error
                if req.configSpec.operation == "start":
                    # start operations can't handle deleted nodes
                    return (
                        resource.state <= NodeState.stopped,
                        "can't start a missing instance",
                    )
            elif resource.state > entryTest:
                return False, "instance already entered state"
        return True, "passed"

    def _runOperation(self, req, workflow, depth):
        if isinstance(req, SetStateRequest):
            self._setState(req)
            return None
        if req.error:
            return None

        test, msg = self._entryTest(req, workflow)
        if not test:
            logger.debug(
                "skipping operation %s for instance %s with state %s and status %s: %s",
                req.configSpec.operation,
                req.target.name,
                req.target.state,
                req.target.status,
                msg,
            )
            return None
        task = self.createTask(req.configSpec, req.target, reason=req.reason)
        if task:
            proceed, msg = self.shouldRunTask(task)
            if not proceed:
                logger.debug(
                    "skipping task %s for instance %s with state %s and status %s: %s",
                    req.configSpec.operation,
                    req.target.name,
                    req.target.state,
                    req.target.status,
                    msg,
                )
                return None
            resource = req.target
            startingStatus = resource._localStatus
            if req.startState is not None:
                resource.state = req.startState
            startingState = resource.state
            self.runner.addWork(task)
            self.runTask(task, depth)

            if workflow == "deploy" and resource.created is None:
                resource.created = task.changeId

            # if # task succeeded but didn't update nodestate
            if task.result.success and resource.state == startingState:
                if req.startState is not None:
                    # advance the state if a startState was set in the TaskRequest
                    resource.state = NodeState(req.startState + 1)
                elif (
                    req.configSpec.operation == "check"
                    and startingStatus != resource._localStatus
                ):
                    # if check operation set status but didn't update state, set a default state
                    state = {
                        Status.ok: NodeState.started,
                        Status.error: NodeState.error,
                        Status.absent: NodeState.deleted,
                        Status.pending: NodeState.initial,
                    }.get(resource._localStatus)
                    if state is not None:
                        resource.state = state

            # logger.debug(
            #     "changed %s to %s, wanted %s",
            #     startingState,
            #     task.target.state,
            #     req.startState,
            # )
            logger.info(
                "finished taskrequest %s: %s %s %s",
                task,
                "success" if task.result.success else "failed",
                task.target.status.name,
                task.target.state and task.target.state.name or "",
            )

        return task

    def runTask(self, task, depth=0):
        """
        During each task run:
        * Notification of metadata changes that reflect changes made to resources
        * Notification of add or removing dependency on a resource or properties of a resource
        * Notification of creation or deletion of a resource
        * Requests a resource with requested metadata, if it doesn't exist, a task is run to make it so
        (e.g. add a dns entry, install a package).

        Returns a task.
        """
        ok, errors = self.canRunTask(task)
        if not ok:
            return task.finished(ConfiguratorResult(False, False, result=errors))

        task.start()
        change = None
        while True:
            try:
                result = task.send(change)
            except Exception:
                UnfurlTaskError(task, "configurator.run failed")
                return task.finished(ConfiguratorResult(False, None, Status.error))
            if isinstance(result, PlanRequest):
                if depth >= self.MAX_NESTED_SUBTASKS:
                    UnfurlTaskError(task, "too many subtasks spawned")
                    change = task.finished(ConfiguratorResult(False, None))
                else:
                    change, success = self.apply([result], depth + 1)
            elif isinstance(result, JobRequest):
                job = self.runJobRequest(result)
                change = job
            elif isinstance(result, ConfiguratorResult):
                retVal = task.finished(result)
                logger.debug(
                    "completed task %s: %s; %s", task, task.target.status, result
                )
                return retVal
            else:
                UnfurlTaskError(task, "unexpected result from configurator")
                return task.finished(ConfiguratorResult(False, None, Status.error))

    ###########################################################################
    ### Reporting methods
    ###########################################################################
    def _jsonPlanSummary(self, pretty=False):
        def _summary(requests, target, parent):
            for request in requests:
                isGroup = isinstance(request, TaskRequestGroup)
                if isGroup and not request.children:
                    continue
                if request.target is not target:
                    target = request.target
                    children = []
                    node = dict(
                        name=target.name,
                        status=str(target.status),
                        state=str(target.state),
                        managed=target.created,
                        plan=children,
                    )
                    parent.append(node)
                    parent = children
                if isGroup:
                    children = []
                    group = {}
                    if request.workflow:
                        group["workflow"] = str(request.workflow)
                    group["sequence"] = children
                    parent.append(group)
                    _summary(request.children, target, children)
                else:
                    parent.append(
                        dict(
                            operation=request.configSpec.operation
                            or request.configSpec.name,
                            reason=request.reason,
                        )
                    )

        summary = []
        _summary(self.taskRequests, None, summary)
        if not pretty:
            return summary
        else:
            return json.dumps(summary, indent=2)

    def jsonSummary(self, pretty=False):
        if self.jobOptions.planOnly:
            return self._jsonPlanSummary(pretty)

        job = dict(id=self.changeId, status=self.status.name)
        job.update(self.stats())
        if not self.startTime:  # skip if startTime was explicitly set
            job["timeElapsed"] = self.timeElapsed
        summary = dict(
            job=job,
            outputs=serializeValue(self.getOutputs()),
            tasks=[task.summary(True) for task in self.workDone.values()],
        )
        if pretty:
            return json.dumps(summary, indent=2)
        return summary

    def stats(self, asMessage=False):
        tasks = self.workDone.values()
        key = lambda t: t._localStatus or Status.unknown
        tasks = sorted(tasks, key=key)
        stats = dict(total=len(tasks), ok=0, error=0, unknown=0, skipped=0)
        for k, g in itertools.groupby(tasks, key):
            if not k:  # is a Status
                stats["skipped"] = len(list(g))
            else:
                stats[k.name] = len(list(g))
        stats["changed"] = len([t for t in tasks if t.modifiedTarget()])
        if asMessage:
            return "{total} tasks ({changed} changed, {ok} ok, {error} failed, {unknown} unknown, {skipped} skipped)".format(
                **stats
            )
        return stats

    def _planSummary(self):
        """
        Node "site" (status, state, created):
          check: Install.check
          workflow: # if group
            Standard.create (reason add)
            Standard.configure (reason add)
        """

        def _summary(requests, target, indent):
            for request in requests:
                isGroup = isinstance(request, TaskRequestGroup)
                if isGroup and not request.children:
                    continue
                if request.target is not target:
                    target = request.target
                    status = ", ".join(
                        filter(
                            None,
                            (
                                target.status.name if target.status is not None else "",
                                target.state.name if target.state is not None else "",
                                "managed" if target.created else "",
                            ),
                        )
                    )
                    nodeStr = 'Node "%s" (%s):' % (target.name, status)
                    output.append(" " * indent + nodeStr)
                if isGroup:
                    output.append(
                        "%s- %s:" % (" " * indent, (request.workflow or "sequence"))
                    )
                    _summary(request.children, target, indent + 4)
                else:
                    output.append(" " * indent + "- " + request.name)

        opts = self.jobOptions.getUserSettings()
        options = ",".join(["%s = %s" % (k, opts[k]) for k in opts if k != "planOnly"])
        header = "Plan for %s" % self.workflow
        if options:
            header += " (%s)" % options
        output = [header + ":\n"]
        _summary(self.taskRequests, None, 0)
        if len(output) <= 1:
            output.append("Nothing to do.")
        return "\n".join(output)

    def summary(self):
        if self.jobOptions.planOnly:
            return self._planSummary()

        outputString = ""
        outputs = self.getOutputs()
        if outputs:
            outputString = "\nOutputs:\n    " + "\n    ".join(
                "%s: %s" % (name, value)
                for name, value in serializeValue(outputs).items()
            )

        if not self.workDone:
            return "Job %s completed: %s. Found nothing to do. %s" % (
                self.changeId,
                self.status.name,
                outputString,
            )

        def format(i, task):
            return "%d. %s; %s" % (i, task.summary(), task.result or "skipped")

        line1 = "Job %s completed in %.3fs: %s. %s:\n    " % (
            self.changeId,
            self.timeElapsed,
            self.status.name,
            self.stats(asMessage=True),
        )
        tasks = "\n    ".join(
            format(i + 1, task) for i, task in enumerate(self.workDone.values())
        )
        return line1 + tasks + outputString


class Runner(object):
    def __init__(self, manifest):
        self.manifest = manifest
        assert self.manifest.tosca
        self.taskCount = 0
        self.currentJob = None

    def addWork(self, task):
        key = id(task)
        self.currentJob.workDone[key] = task
        task.job.workDone[key] = task

    def createJob(self, joboptions, previousId=None):
        """
        Selects task to run based on the workflow and job options
        """
        root = self.manifest.getRootResource()
        assert self.manifest.tosca
        job = Job(self, root, joboptions, previousId)

        if (
            self.manifest.localEnv
            and not joboptions.parentJob
            and not joboptions.startTime
        ):
            logPath = self.manifest.getJobLogPath(job.getStartTime(), ".log")
            if not os.path.isdir(os.path.dirname(logPath)):
                os.makedirs(os.path.dirname(logPath))
            initLogging(logfile=logPath)
            path = self.manifest.path
            if joboptions.planOnly:
                logger.info("creating %s plan for %s", joboptions.workflow, path)
            else:
                logger.info("starting %s job for %s", joboptions.workflow, path)

        WorkflowPlan = Plan.getPlanClassForWorkflow(joboptions.workflow)
        if not WorkflowPlan:
            raise UnfurlError("unknown workflow: %s" % joboptions.workflow)
        job.plan = WorkflowPlan(root, self.manifest.tosca, joboptions)
        return job

    def incrementTaskCount(self):
        self.taskCount += 1
        return self.taskCount

    def run(self, jobOptions=None):
        job = None
        try:
            cwd = os.getcwd()
            if self.manifest.getBaseDir():
                os.chdir(self.manifest.getBaseDir())
            if jobOptions is None:
                jobOptions = JobOptions()

            if jobOptions.dirty == "auto":  # default to false if committing
                checkIfClean = jobOptions.commit
            else:
                checkIfClean = jobOptions.dirty == "abort"
            if not jobOptions.planOnly and checkIfClean:
                for repo in self.manifest.repositories.values():
                    if repo.isDirty():
                        logger.error(
                            "aborting run: uncommitted files in %s (--dirty=ok to override)",
                            repo.workingDir,
                        )
                        return None

            job = self.createJob(
                jobOptions, self.manifest.lastJob and self.manifest.lastJob["changeId"]
            )
            startTime = perf_counter()
            self.currentJob = job
            try:
                display.verbosity = jobOptions.verbose
                if jobOptions.planOnly:
                    job.createPlan()
                else:
                    job.run()
            except Exception:
                job.localStatus = Status.error
                job.unexpectedAbort = UnfurlError(
                    "unexpected exception while running job", True, True
                )
            self.currentJob = None
            self.manifest.commitJob(job)
        finally:
            if job:
                job.timeElapsed = perf_counter() - startTime
            os.chdir(cwd)
        return job


def runJob(manifestPath=None, _opts=None):
    """
    Loads the given Ensemble and creates and runs a job.

    Args:
        manifestPath (:obj:`str`, optional) The path the Ensemble manifest.
         If None, it will look for an ensemble in the current working directory.
        _opts (:obj:`dict`, optional) A dictionary of job options. Names and values should match
          the names of the command line options for creating jobs.

    Returns:
        (:obj:`Job`): The job that just ran.
    """
    _opts = _opts or {}
    localEnv = LocalEnv(manifestPath, _opts.get("home"))
    opts = JobOptions(**_opts)
    path = localEnv.manifestPath
    if not opts.planOnly:
        logger.info("creating %s job for %s", opts.workflow, path)
    try:
        manifest = localEnv.getManifest()
    except Exception as e:
        logger.error(
            "failed to load manifest at %s: %s",
            path,
            str(e),
            exc_info=opts.verbose >= 2,
        )
        return None

    runner = Runner(manifest)
    return runner.run(opts)
