# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE.
# Copyright (C) NIWA & British Crown (Met Office) & Contributors.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

from collections import (
    Counter,
    defaultdict
)
from contextlib import suppress
from enum import Enum
from inspect import signature
import json
import re
from copy import deepcopy
from time import time
from typing import (
    Any,
    Dict,
    Optional,
    Set,
    Tuple,
    List,
    TYPE_CHECKING
)

from cylc.flow import LOG
from cylc.flow.exceptions import WorkflowConfigError, XtriggerConfigError
import cylc.flow.flags
from cylc.flow.hostuserutil import get_user
from cylc.flow.subprocctx import add_kwarg_to_sig
from cylc.flow.subprocpool import get_xtrig_func
from cylc.flow.xtriggers.wall_clock import _wall_clock
from cylc.flow.xtriggers.workflow_state import (
    workflow_state,
    _workflow_state_backcompat,
    _upgrade_workflow_state_sig,
)

if TYPE_CHECKING:
    from inspect import BoundArguments, Signature
    from cylc.flow.scheduler import Scheduler
    from cylc.flow.subprocctx import SubFuncContext
    from cylc.flow.task_proxy import TaskProxy


XTRIG_DUP_WARNING = (
    "Duplicate xtrigger prerequisites get satisfied naturally at"
    " once, but they can be satisfied separately with `cylc set`."
)


class TemplateVariables(Enum):
    """Templates variables for string replacement in xtrigger functions.

    The following string templates are available for use, if the trigger
    function needs any of this information, in function arguments in the
    workflow configuration.

    .. code-block:: cylc

       [scheduling]
           initial cycle point = now
           [[xtriggers]]
               my_xtrigger = my_xtrigger_fcn('%(workflow)s', '%(point)s')

    For an explanation of the substitution syntax, see
    `String Formatting Operations in the Python documentation
    <https://docs.python.org/3/library/stdtypes.html
    #printf-style-string-formatting>`_.

    """

    CyclePoint = 'point'
    """The cycle point of the dependent task."""

    DebugMode = 'debug'
    """True if Cylc is being run in debug mode (--debug, -vv)."""

    RunDir = 'workflow_run_dir'
    """The path to the workflow run directory."""

    ShareDir = 'workflow_share_dir'
    """The path to the workflow share directory."""

    TaskID = 'id'
    """The ID of the dependent task."""

    TaskName = 'name'
    """The name of the dependent task."""

    UserName = 'user_name'
    """The user account under which the workflow is being run."""

    Workflow = 'workflow'
    """The workflow ID."""

    # BACK COMPAT: workflow_name deprecated
    # url:
    #     TODO
    # from:
    #     Cylc 8
    # remove at:
    #     Cylc 8.x
    WorkflowName = 'workflow_name'
    """The workflow ID.

    .. deprecated:: 8.0.0

       Use ``workflow`` instead.
    """

    # BACK COMPAT: suite_name deprecated
    # url:
    #     TODO
    # from:
    #     Cylc 8
    # remove at:
    #     Cylc 8.x
    SuiteName = 'suite_name'
    """The workflow ID.

    .. deprecated:: 8.0.0

       Use ``workflow`` instead.
    """

    # BACK COMPAT: suite_run_dir deprecated
    # url:
    #     TODO
    # from:
    #     Cylc 8
    # remove at:
    #     Cylc 8.x
    SuiteRunDir = 'suite_run_dir'
    """The path to the workflow run directory.

    .. deprecated:: 8.0.0

       Use ``workflow_run_dir`` instead.
    """

    # BACK COMPAT: suite_share_dir deprecated
    # url:
    #     TODO
    # from:
    #     Cylc 8
    # remove at:
    #     Cylc 8.x
    SuiteShareDir = 'suite_share_dir'
    """The path to the workflow share directory.

    .. deprecated:: 8.0.0

       Use ``workflow_share_dir`` instead.
    """


# Extract 'foo' from string templates '%(foo)s', avoiding '%%' escaping
# ('%%(foo)s` is not a string template).
RE_STR_TMPL = re.compile(r'(?<!%)%\(([\w]+)\)s')


class XtriggerCollator:
    """Collate and validate parsed xtriggers.

    Separate from XtriggerManager to simplify validation and testing.

    """

    def __init__(self):
        # Map xtrig label to function context.
        self.functx_map: 'Dict[str, SubFuncContext]' = {}
        # Clock labels, to avoid repeated string comparisons
        self.wall_clock_labels: Set[str] = set()
        # Workflow-wide default, used when not specified in xtrigger kwargs.
        self.sequential_xtriggers_default = False
        # Labels whose xtriggers are sequentially checked.
        self.sequential_xtrigger_labels: Set[str] = set()

    def update(self, xtriggers: 'XtriggerCollator'):
        self.functx_map.update(xtriggers.functx_map)
        self.wall_clock_labels.update(xtriggers.wall_clock_labels)
        self.sequential_xtrigger_labels.update(
            xtriggers.sequential_xtrigger_labels)

    def purge_user_xtriggers(self):
        """Purge user-defined triggers before a reload.

        User-defined triggers need to be recreated from the config file.
        Auto-defined triggers (retries) need to be kept.

        """
        nuke = []
        for label in self.functx_map:
            if (
                label.startswith("_cylc_wallclock")
                or not label.startswith("_cylc")
            ):
                # _cylc_wallclock xtriggers are user-defined
                # otherwise all _cylc xtriggers are automatic.
                nuke.append(label)
        for label in nuke:
            del self.functx_map[label]
            with suppress(KeyError):
                self.wall_clock_labels.remove(label)
            with suppress(KeyError):
                self.sequential_xtrigger_labels.remove(label)

    def add_trig(self, label: str, fctx: 'SubFuncContext', fdir: str) -> None:
        """Add a new xtrigger function.

        Args:
            label: xtrigger label
            fctx: function context
            fdir: module directory

        """
        if (
            not label.startswith('_cylc_retry_') and
            not label.startswith('_cylc_submit_retry_')
        ):
            # (the "_wall_clock" function fails "wall_clock" validation)
            self._validate(label, fctx, fdir)

        self.functx_map[label] = fctx

        if fctx.func_kwargs.pop(
            'sequential',
            self.sequential_xtriggers_default
        ):
            self.sequential_xtrigger_labels.add(label)

        if fctx.func_name == "wall_clock":
            self.wall_clock_labels.add(label)

    def report_duplicates(self):
        """Report labels that point to the same xtrigger signature."""

        counts = Counter([v.get_signature() for v in self.functx_map.values()])
        dups = defaultdict(list)
        for label, fctx in self.functx_map.items():
            sig = fctx.get_signature()
            if counts[sig] < 2:
                continue
            dups[sig].append(label)

        for sig, labels in dups.items():
            LOG.info(f"Duplicate xtriggers: {', '.join(labels)} = {sig}")
        if dups:
            LOG.warning(XTRIG_DUP_WARNING)

    @classmethod
    def _validate(
        cls,
        label: str,
        fctx: 'SubFuncContext',
        fdir: str,
    ) -> None:
        """Check xtrigger existence, string templates and function signature.

        Also call a specific xtrigger argument validation function, "validate",
        if defined in the xtrigger module.

        Args:
            label: xtrigger label
            fctx: function context
            fdir: function directory

        Raises:
            XtriggerConfigError:
                * If the function module was not found.
                * If the function was not found in the xtrigger module.
                * If the function is not callable.
                * If any string template in the function context
                  arguments are not present in the expected template values.
                * If the arguments do not match the function signature.

        """
        sig_str = fctx.get_signature()

        try:
            func = get_xtrig_func(fctx.mod_name, fctx.func_name, fdir)
        except (ImportError, AttributeError) as exc:
            raise XtriggerConfigError(label, sig_str, exc) from None
        try:
            sig = signature(func)
        except TypeError as exc:
            # not callable
            raise XtriggerConfigError(label, sig_str, exc) from None

        sig = cls._handle_sequential_kwarg(label, fctx, sig)

        # Validate args and kwargs against the function signature
        try:
            bound_args = sig.bind(*fctx.func_args, **fctx.func_kwargs)
        except TypeError as exc:
            err = XtriggerConfigError(label, sig_str, exc)
            if func is workflow_state:
                bound_args = cls._try_workflow_state_backcompat(
                    label, fctx, err
                )
            else:
                raise err from None

        # Specific xtrigger.validate(), if available.
        # Note arg string templating has not been done at this point.
        cls._try_xtrig_validate_func(
            label, fctx, fdir, bound_args, sig_str
        )

        # Check any string templates in the function arg values (note this
        # won't catch bad task-specific values - which are added dynamically).
        template_vars = set()
        for argv in fctx.func_args + list(fctx.func_kwargs.values()):
            if not isinstance(argv, str):
                # Not a string arg.
                continue

            # check template variables are valid
            for match in RE_STR_TMPL.findall(argv):
                try:
                    template_vars.add(TemplateVariables(match))
                except ValueError:
                    raise XtriggerConfigError(
                        label, sig_str,
                        f"Illegal template in xtrigger: {match}",
                    ) from None

        # check for deprecated template variables
        deprecated_variables = template_vars & {
            TemplateVariables.WorkflowName,
            TemplateVariables.SuiteName,
            TemplateVariables.SuiteRunDir,
            TemplateVariables.SuiteShareDir,
        }
        if deprecated_variables:
            LOG.warning(
                f'Xtrigger "{label}" uses deprecated template variables:'
                f' {", ".join(t.value for t in deprecated_variables)}'
            )

    @staticmethod
    def _handle_sequential_kwarg(
        label: str, fctx: 'SubFuncContext', sig: 'Signature'
    ) -> 'Signature':
        """Handle reserved 'sequential' kwarg in xtrigger functions."""
        sequential_param = sig.parameters.get('sequential', None)
        if sequential_param:
            if not isinstance(sequential_param.default, bool):
                raise XtriggerConfigError(
                    label, fctx.func_name,
                    (
                        "xtrigger has a reserved argument"
                        " 'sequential' with no boolean default"
                    )
                )
            fctx.func_kwargs.setdefault('sequential', sequential_param.default)

        if 'sequential' in fctx.func_kwargs:
            # xtrig marked as sequential in function call
            value = fctx.func_kwargs['sequential']
            if not isinstance(value, bool):
                raise XtriggerConfigError(
                    label, fctx.func_name,
                    f"invalid argument 'sequential={value}' - must be boolean"
                )
            if not sequential_param:
                sig = add_kwarg_to_sig(sig, 'sequential', value)
        return sig

    @staticmethod
    def _try_xtrig_validate_func(
        label: str,
        fctx: 'SubFuncContext',
        fdir: str,
        bound_args: 'BoundArguments',
        signature_str: str,
    ):
        """Call an xtrigger's `validate()` function if it is implemented.

        Raise XtriggerConfigError if validation fails.

        """
        vname = "validate"
        if fctx.func_name == _workflow_state_backcompat.__name__:
            vname = "_validate_backcompat"

        try:
            xtrig_validate_func = get_xtrig_func(fctx.mod_name, vname, fdir)
        except (AttributeError, ImportError):
            return
        bound_args.apply_defaults()
        try:
            xtrig_validate_func(bound_args.arguments)
        except Exception as exc:  # Note: catch all errors
            if not isinstance(exc, WorkflowConfigError):
                LOG.exception(exc)
            raise XtriggerConfigError(label, signature_str, exc) from None

    # BACK COMPAT: workflow_state_backcompat
    # from: 8.0.0
    # to: 8.3.0
    # remove at: 8.x
    @classmethod
    def _try_workflow_state_backcompat(
        cls,
        label: str,
        fctx: 'SubFuncContext',
        err: XtriggerConfigError,
    ) -> 'BoundArguments':
        """Try to validate args against the old workflow_state signature.

        Raise the original signature check error if this signature check fails.

        Returns the bound arguments for the old signature.
        """
        sig = cls._handle_sequential_kwarg(
            label, fctx, signature(_workflow_state_backcompat)
        )
        try:
            bound_args = sig.bind(*fctx.func_args, **fctx.func_kwargs)
        except TypeError:
            # failed signature check for backcompat function
            raise err from None  # original signature check error

        old_sig_str = fctx.get_signature()
        upg_sig_str = "workflow_state({})".format(
            ", ".join(
                f'{k}={v}' for k, v in
                _upgrade_workflow_state_sig(bound_args.arguments).items()
                if v is not None
            )
        )
        LOG.warning(
            "(8.3.0) Deprecated function signature used for "
            "workflow_state xtrigger was automatically upgraded. Please "
            "alter your workflow to use the new syntax:\n"
            f"    {old_sig_str} --> {upg_sig_str}"
        )
        fctx.func_name = _workflow_state_backcompat.__name__
        return bound_args


class XtriggerManager:
    """Manage clock triggers and xtrigger functions.

    # Example:
    [scheduling]
        [[xtriggers]]
            clock_0 = wall_clock()  # offset PT0H
            clock_1 = wall_clock(offset=PT1H)
                 # or wall_clock(PT1H)
            workflow_x = workflow_state(
                workflow_task_id=other, point=%(task_cycle_point)s):PT30S
        [[graph]]
            PT1H = '''
                @clock_1 & @workflow_x => foo & bar
                @wall_clock = baz  # pre-defined zero-offset clock
            '''

    Task proxies store xtriggers labels: clock_0, workflow_x, etc. above, and
    record whether or not their dependence on xtriggers is satisfied.

    Labels are mapped to the defined function call signatures.

    The interval ("name(args):INTVL") determines call frequency.

    This XtriggerManager class handles all xtrigger execution logic centrally,
    to avoid duplication when multiple tasks depend on the same xtrigger.

    Uniqueness is determined by function signature (name and arguments). So
    workflow_x above defines a different xtrigger for each cycle point. A new
    call will not be made before the previous one has returned.

    Xtrigger functions are called asynchronously in the subprocess pool,
    except for clock triggers, called synchronously because they're quick.

    If parentless tasks have xtriggers that are fundamentally sequential in
    nature, spawning them out to the runahead limit can result in unnecessary
    xtrigger activity and UI clutter, so clock-triggered tasks get spawned
    sequentially (as each xtrigger completes) by default, and other xtriggers
    can optionally be configured to spawn sequentially.

    # Example:
    [scheduling]
        sequential xtriggers = True
        [[xtriggers]]
            # "sequential=False" here overrides workflow and function default.
            clock_0 = wall_clock(sequential=False)
            workflow_x = workflow_state(
                workflow_task_id=other, point=%(task_cycle_point)s):PT30S
        [[graph]]
            PT1H = '''
                @workflow_x => foo & bar  # spawned on workflow_x satisfaction
                @clock_0 => baz  # baz spawned out to RH
            '''

    Args:
        schd: Scheduler
        workflow_run_dir: workflow run directory
        workflow_share_dir: workflow share directory

    """

    def __init__(
        self,
        schd: 'Scheduler',
        workflow_run_dir: Optional[str] = None,
        workflow_share_dir: Optional[str] = None,
    ):
        self.schd = schd
        workflow = schd.workflow
        user = schd.owner
        # When next to call a function, by signature.
        self.t_next_call: dict = {}
        # Succeeded triggers and their function results, by signature.
        self.sat_xtrig: dict = {}
        # Signatures of active functions (waiting on callback).
        self.active: list = []

        # A record of parentless sequential xtriggered tasks
        # that have had their next occurrance spawned.
        self.sequential_has_spawned_next: Set[str] = set()

        self.workflow_run_dir = workflow_run_dir

        # For function arg templating.
        if not user:
            user = get_user()
        self.farg_templ: Dict[str, Any] = {
            TemplateVariables.Workflow.value: workflow,
            TemplateVariables.UserName.value: user,
            TemplateVariables.RunDir.value: workflow_run_dir,
            TemplateVariables.ShareDir.value: workflow_share_dir,
            TemplateVariables.DebugMode.value: cylc.flow.flags.verbosity > 1,
            # deprecated
            TemplateVariables.WorkflowName.value: workflow,
            TemplateVariables.SuiteName.value: workflow,
            TemplateVariables.SuiteRunDir.value: workflow,
            TemplateVariables.SuiteShareDir.value: workflow,
        }

        self.proc_pool = schd.proc_pool
        self.workflow_db_mgr = schd.workflow_db_mgr
        self.broadcast_mgr = schd.broadcast_mgr
        self.data_store_mgr = schd.data_store_mgr
        self.do_housekeeping = False
        self.xtriggers = XtriggerCollator()

    def add_xtriggers(self, xtriggers: 'XtriggerCollator', reload=False):
        """Add validated xtriggers, parsed from the workflow config."""
        if reload:
            self.xtriggers.purge_user_xtriggers()
        self.xtriggers.update(xtriggers)
        self.xtriggers.sequential_xtriggers_default = (
            xtriggers.sequential_xtriggers_default
        )

    def mutate_trig(self, label, kwargs):
        self.xtriggers.functx_map[label].func_kwargs.update(kwargs)

    def load_xtrigger_for_restart(self, row_idx: int, row: Tuple[str, str]):
        """Load succeeded xtrigger results from workflow DB.

        Note this is succeeded xtriggers, not task xtrigger prerequisites
        (which can be manually satisfied independently of the xtrigger).

        Args:
            row_idx (int): row index (used for logging)
            row (Tuple[str, str]): tuple with the signature and results (json)
        Raises:
            ValueError: if the row cannot be parsed as JSON
        """
        if row_idx == 0:
            LOG.info("LOADING succeeded xtriggers")
        sig, results = row
        self.sat_xtrig[sig] = json.loads(results)
        # Tell the datastore this xtrigger succeeded.
        self.data_store_mgr.delta_xtrigger(sig, True)

    def _get_xtrigs(
        self, itask: 'TaskProxy', unsat_only: bool = False,
        sigs_only: bool = False
    ) -> 'List[Any]':
        """(Internal helper method.)

        Args:
            itask: the task instance
            unsat_only: retrieve only unsatisfied xtrigger prerequisites
            sigs_only: append only the xtrigger function signature

        Returns:
            List[Union[str, Tuple[str, str, SubFuncContext, bool]]]: a list
                with either signature (if sigs_only True) or with tuples of
                label, signature, function context, and flag for satisfied.
        """
        res: 'List[Any]' = []
        for label, satisfied in itask.state.xtriggers.items():
            if unsat_only and satisfied:
                continue
            ctx = self.get_xtrig_ctx(itask, label)
            sig = ctx.get_signature()
            if sigs_only:
                res.append(sig)
            else:
                res.append((label, sig, ctx, satisfied))
        return res

    def get_xtrig_ctx(
        self,
        itask: 'TaskProxy',
        label: str,
    ) -> 'SubFuncContext':
        """Get a real function context from the template.

        Args:
            itask: task proxy
            label: xtrigger label
        Returns:
            function context
        """
        farg_templ = {
            TemplateVariables.CyclePoint.value: str(itask.point),
            TemplateVariables.TaskName.value: str(itask.tdef.name),
            TemplateVariables.TaskID.value: str(itask.identity)
        }
        farg_templ.update(self.farg_templ)
        ctx = deepcopy(self.xtriggers.functx_map[label])

        args = []
        kwargs = {}
        if label in self.xtriggers.wall_clock_labels:
            if "trigger_time" in ctx.func_kwargs:  # noqa: SIM401 (readabilty)
                # Internal (retry timer): trigger_time already set.
                kwargs["trigger_time"] = ctx.func_kwargs["trigger_time"]
            else:
                # External (clock xtrigger): convert offset to trigger_time.
                # Datetime cycling only.
                kwargs["trigger_time"] = itask.get_clock_trigger_time(
                    itask.point,
                    ctx.func_kwargs.get(
                        "offset",
                        ctx.func_args[0] if ctx.func_args else None
                    )
                )
        else:
            # Other xtrig functions: substitute template values.
            for val in ctx.func_args:
                with suppress(TypeError):
                    val = val % farg_templ
                args.append(val)
            for key, val in ctx.func_kwargs.items():
                with suppress(TypeError):
                    val = val % farg_templ
                kwargs[key] = val
        ctx.func_args = args
        ctx.func_kwargs = kwargs

        ctx.update_command(self.workflow_run_dir)
        return ctx

    def call_xtriggers_async(self, itask: 'TaskProxy'):
        """Call itask's xtrigger functions via the process pool...

        ...if previous call not still in-process and retry period is up.

        Args:
            itask: task proxy to check.
        """
        for label, sig, ctx, _ in self._get_xtrigs(itask, unsat_only=True):
            if label in self.xtriggers.wall_clock_labels:
                # Special case: quick synchronous clock check.
                if sig in self.sat_xtrig:
                    # Already satisfied, just update the task
                    itask.state.xtriggers[label] = True
                    if self.all_task_seq_xtriggers_satisfied(itask):
                        self.schd.pool.check_spawn_psx_task(itask)
                elif _wall_clock(*ctx.func_args, **ctx.func_kwargs):
                    # Newly satisfied
                    itask.state.xtriggers[label] = True
                    self.sat_xtrig[sig] = {}
                    self.data_store_mgr.delta_xtrigger(sig, True)
                    self.workflow_db_mgr.put_xtriggers({sig: {}})
                    LOG.info('xtrigger succeeded: %s = %s', label, sig)
                    if self.all_task_seq_xtriggers_satisfied(itask):
                        self.schd.pool.check_spawn_psx_task(itask)
                    self.do_housekeeping = True
                continue
            # General case: potentially slow asynchronous function call.
            if sig in self.sat_xtrig:
                # Already satisfied, just update the task
                LOG.info(f"[{itask}] satisfying xtrigger prerequisite: {sig}")
                if not itask.state.xtriggers[label]:
                    itask.state.xtriggers[label] = True
                    res = {}
                    for key, val in self.sat_xtrig[sig].items():
                        res["%s_%s" % (label, key)] = val
                    if res:
                        xtrigger_env = [{'environment': {key: str(val)}} for
                                        key, val in res.items()]
                        self.broadcast_mgr.put_broadcast(
                            [str(itask.point)],
                            [itask.tdef.name],
                            xtrigger_env
                        )
                    if self.all_task_seq_xtriggers_satisfied(itask):
                        self.schd.pool.check_spawn_psx_task(itask)
                continue

            # Call the function to check the xtrigger.
            if sig in self.active:
                # Already waiting on this result.
                continue

            if sig not in self.t_next_call:
                # Log at first call only.
                LOG.info(f"Commencing xtrigger, {ctx.get_description(True)}")

            now = time()
            if sig in self.t_next_call and now < self.t_next_call[sig]:
                # Too soon to call this one again.
                continue
            self.t_next_call[sig] = now + ctx.intvl
            # Queue to the process pool, and record as active.
            self.active.append(sig)
            self.proc_pool.put_command(ctx, callback=self.callback)

    def housekeep(self, itasks):
        """Forget succeeded xtriggers no longer needed by any task.

        Check self.do_housekeeping before calling this method.

        Args:
            itasks: list of all task proxies.
        """
        all_xtrig = []
        for itask in itasks:
            all_xtrig += self._get_xtrigs(
                itask, sigs_only=True, unsat_only=True)
        for sig in list(self.sat_xtrig):
            if sig not in all_xtrig:
                LOG.debug(f"Housekeeping xtrigger result: {sig}")
                del self.sat_xtrig[sig]
                with suppress(KeyError):
                    del self.t_next_call[sig]
        self.do_housekeeping = False

    def all_task_seq_xtriggers_satisfied(self, itask: 'TaskProxy') -> bool:
        """Check if all sequential xtriggers are satisfied for a task."""
        return itask.is_xtrigger_sequential and all(
            itask.state.xtriggers[label]
            for label in itask.state.xtriggers
            if label in self.xtriggers.sequential_xtrigger_labels
        )

    def callback(self, ctx: 'SubFuncContext'):
        """Callback for asynchronous xtrigger functions.

        Record completion status (succeeded) and function results dict.

        Log a warning if the xtrigger functions errors, to distinguish
        errors from not-succeeded.

        Args:
            ctx (SubFuncContext): function context
        """
        sig = ctx.get_signature()
        self.active.remove(sig)

        if ctx.ret_code != 0:
            msg = f"ERROR in xtrigger {sig}"
            if ctx.err:
                msg += f"\n{ctx.err}"
            LOG.warning(msg)

        try:
            succeeded, results = json.loads(ctx.out)
        except (ValueError, TypeError):
            return

        LOG.debug('%s: returned %s', sig, results)
        if not succeeded:
            return

        self.data_store_mgr.delta_xtrigger(sig, succeeded)
        self.workflow_db_mgr.put_xtriggers({sig: results})
        LOG.info(f"xtrigger succeeded: {ctx.get_description()}")
        self.sat_xtrig[sig] = results

        self.do_housekeeping = True

    def force_satisfy(
        self,
        itask: 'TaskProxy',
        xtriggers: 'Dict[str, bool]',
        log: bool = True,
    ) -> None:
        """Force un/satisfy one or all xtrigger prerequisites of itask.

        Ignores xtriggers not valid for itask.
        (However, these are now weeded out by the caller).

        Args:
            itask: task proxy
            xtriggers: xtrigger prerequisites to un/satisfy
            log: if True outcome will be logged

        """
        # [(label, satisfied), ]
        xtrigs = list(xtriggers.items())

        if len(xtrigs) == 1 and xtrigs[0][0] == 'all':
            xtrigs = [(x, xtrigs[0][1]) for x in itask.state.xtriggers]

        for label, satisfied in xtrigs:
            if label not in itask.state.xtriggers:
                continue

            ctx = self.get_xtrig_ctx(itask, label)
            sig = ctx.get_signature()

            # Un/satisfy the xtrigger prerequisite and log what we did.
            # (Say "prerequisite": the xtrigger itself is not touched).

            prefix = f"[{itask}] prerequisite"
            suffix = ctx.get_description()
            state = "satisfied" if satisfied else "unsatisfied"

            if itask.state.xtriggers[label] == satisfied:
                if log:
                    LOG.info(f"{prefix} already {state}: {suffix}")
                continue

            itask.state.xtriggers[label] = satisfied
            if satisfied and self.all_task_seq_xtriggers_satisfied(itask):
                self.schd.pool.check_spawn_psx_task(itask)

            self.data_store_mgr.delta_task_xtrigger(
                itask, label, sig, satisfied)
            if log:
                LOG.info(f"{prefix} force-{state}: {suffix}")

    def force_satisfy_all(self, itask: 'TaskProxy', log: bool = True):
        """Force satisfy all xtriggers for the provided task."""
        self.force_satisfy(
            itask,
            dict.fromkeys(itask.state.xtriggers, True),
            log=log,
        )
