# 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
from .result import serializeValue, ChangeRecord
from .util import UnfurlError, UnfurlTaskError, toEnum
from .merge import mergeDicts
from .runtime import OperationalInstance
from .configurator import (
    TaskView,
    ConfiguratorResult,
    TaskRequest,
    JobRequest,
    Dependency,
)
from .plan import Plan
from .localenv import LocalEnv
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
        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:
                self.target.created = self.configSpec.operation not in [
                    "check",
                    "discover",
                ]
        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
        ):
            self.target._lastStateChange = 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))

            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 = self._attributeManager.commitChanges()
        self.changeList.append(changes)
        return changes

    def hasInputsChanged(self):
        """
        Evaluate configuration spec's inputs and compare with the current inputs' values
        """
        return False
        # XXX self.lastConfigChange isn't set right now
        # we need to figure out which one!
        # what to do about custom workflows?
        # what if the spec changed and the old configure operation doesn't apply anymore?
        # watchout for sensitive values
        _parameters = None
        lastConfigChange = findLastConfigureOperation(self)
        if lastConfigChange:
            changeset = self._manifest.loadConfigChange(lastConfigChange)
            _parameters = changeset.inputs
        if not _parameters:
            return not not self.inputs

        inputs = self.inputs.getResolved()
        if set(inputs.keys()) != set(_parameters.keys()):
            return True  # params were added or removed

        # XXX3 not all parameters need to be live
        # add an optional liveParameters attribute to config spec to specify which ones to check

        # compare old with new
        for name, val in inputs.items():
            if serializeValue(val) != _parameters[name]:
                return True
            # if the value changed since the last time we checked
            if Dependency.hasValueChanged(val, lastConfigChange):
                return True
        return False

    def hasDependenciesChanged(self):
        # if (
        #     self.target.hasAttributesChanged()  # XXX define!
        # ):  # should be instance method and cache result?
        #     return True  # XXX

        # 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.values())

    def refreshDependencies(self):
        for d in self.dependencies.values():
            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,
            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 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 filterConfig(self, config, target):
        opts = self.jobOptions
        if opts.readonly and config.workflow != "discover":
            return None, "read only"
        if opts.requiredOnly and not config.required:
            return None, "required"
        if opts.instance and target.name != opts.instance:
            return None, "instance"
        if opts.instances and target.name not in opts.instances:
            return None, "instances"
        return config, None

    def getCandidateTasks(self):
        # XXX plan might call job.runJobRequest(configuratorJob) before yielding
        planGen = self.plan.executePlan()
        result = None
        try:
            while True:
                req = planGen.send(result)
                configSpec = req.configSpec
                if req.error:
                    # placeholder configspec for errors: has an error message instead of className
                    # create a task so we can record this failure like other task failures
                    errorTask = ConfigTask(
                        self, configSpec, req.target, reason=req.reason
                    )
                    # the task won't run if we associate an exception with it:
                    UnfurlTaskError(errorTask, configSpec.className, logging.WARNING)
                    result = yield errorTask
                    continue

                configSpecName = configSpec.name
                configSpec, filterReason = self.filterConfig(configSpec, req.target)
                if not configSpec:
                    logger.debug(
                        "skipping configspec %s for %s: doesn't match %s filter",
                        configSpecName,
                        req.target.name,
                        filterReason,
                    )
                    result = None  # treat as filtered step
                    continue

                oldResult = self.runner.isConfigAlreadyHandled(configSpec, req.target)
                if oldResult:
                    # configuration may have premptively run while executing another task
                    logger.debug(
                        "configspec %s for target %s already handled",
                        configSpecName,
                        req.target.name,
                    )
                    result = oldResult
                    continue

                task = self.createTask(configSpec, req.target, reason=req.reason)
                if (
                    req.reason == "config changed"
                    and not task.hasInputsChanged()
                    and not task.hasDependenciesChanged()
                ):
                    result = None  # nothing change we don't need to run this task
                    continue

                result = yield task
        except StopIteration:
            pass

    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.validateJobOptions()
        taskGen = self.getCandidateTasks()
        result = None
        try:
            while True:
                task = taskGen.send(result)
                self.runner.addWork(task)
                if not self.shouldRunTask(task):
                    result = None  # treat as filtered step
                    continue

                if self.jobOptions.planOnly:
                    errors = self.cantRunTask(task)
                    if errors:
                        result = task.finished(
                            ConfiguratorResult(False, False, result=errors)
                        )
                    else:
                        # pretend run was successful and modified its target
                        status = None
                        if task.configSpec.operation in ["check", "discover"]:
                            # deploy and undeploy set status later (see getSuccessStatus())
                            status = Status.ok
                        result = task.finished(ConfiguratorResult(True, True, status))
                        logger.info("Simulated task: " + task.summary())
                else:
                    logger.info("Running task %s", task)
                    result = self.runTask(task)

                if self.shouldAbort(task):
                    return self.rootResource
        except StopIteration:
            pass

        # 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

        # XXX
        # if not self.parentJob:
        #   # create a job that will re-run configurations whose parameters or runtime dependencies have changed
        #   # ("config changed" tasks)
        #   # XXX3 check for orphaned resources and mark them as orphaned
        #   #  (a resource is orphaned if it was added as a dependency and no longer has dependencies)
        #   #  (orphaned resources can be deleted by the configuration that created them or manages that type)
        #   maxloops = 10 # XXX3 better loop detection
        #   for count in range(maxloops):
        #     jobOptions = JobOptions(parentJob=self, repair='none')
        #     plan = Plan(self.rootResource, self.runner.manifest.tosca, jobOptions)
        #     job = Job(self.runner, self.rootResource, plan, jobOptions)
        #     job.run()
        #     # break when there are no more tasks to run
        #     if not len(job.workDone) or self.shouldAbort(job):
        #       break
        #   else:
        #     raise UnfurlError("too many final dependency runs")

        return self.rootResource

    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 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

        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
        return priority > Priority.ignore

    def cantRunTask(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
        """
        canRun = False
        reason = ""
        try:
            if task._errors:
                canRun = False
                reason = "could not create task"
                return
            if task.dryRun and not task.configurator.canDryRun(task):
                canRun = False
                reason = "dry run not supported"
                return
            missing = []
            skipDependencyCheck = False
            if not skipDependencyCheck:
                dependencies = list(task.target.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:
                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:
                            canRun = True
        except Exception:
            UnfurlTaskError(task, "cantRunTask failed unexpectedly")
            reason = "unexpected exception in cantRunTask"
            canRun = False

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

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

    def jsonSummary(self, pprint=False):
        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 pprint:
            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 summary(self):
        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

    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 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).
        """
        errors = self.cantRunTask(task)
        if errors:
            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, TaskRequest):
                if depth >= self.MAX_NESTED_SUBTASKS:
                    UnfurlTaskError(task, "too many subtasks spawned")
                    change = task.finished(ConfiguratorResult(False, None))
                else:
                    subtask = self.createTask(result.configSpec, result.target)
                    self.runner.addWork(subtask)
                    # returns the subtask with result
                    change = self.runTask(subtask, depth + 1)
            elif isinstance(result, JobRequest):
                job = self.runJobRequest(result)
                change = job
            elif isinstance(result, ConfiguratorResult):
                retVal = task.finished(result)
                logger.info(
                    "finished running 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))


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 isConfigAlreadyHandled(self, configSpec, target):
        return None  # XXX
        # return configSpec.name in self.currentJob.workDone

    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
                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)
