"""
Note that modules in this package have to use absolute imports because
Repository.item_classes loads them as files.
"""
from copy import copy
from datetime import datetime
from inspect import cleandoc
from os.path import join
from textwrap import TextWrapper

from bundlewrap.exceptions import (
    BundleError,
    FaultUnavailable,
    ItemDependencyError,
    ItemSkipped,
)
from bundlewrap.operations import run_local
from bundlewrap.utils import cached_property, Fault
from bundlewrap.utils.dicts import dict_to_text, diff_dict, hash_state_dict, validate_state_dict
from bundlewrap.utils.text import blue, bold, green, italic, red, wrap_question
from bundlewrap.utils.text import force_text, mark_for_translation as _
from bundlewrap.utils.ui import io

ALLOWED_ITEM_AUTO_ATTRIBUTES = {
    'after',
    'before',
    'needed_by',
    'needs',
}
BUILTIN_ITEM_ATTRIBUTES = {
    'after': set(),
    'before': set(),
    'cascade_skip': None,
    'comment': None,
    'needed_by': set(),
    'needs': set(),
    'preceded_by': set(),
    'precedes': set(),
    'error_on_missing_fault': False,
    'skip': False,
    'tags': set(),
    'triggered': False,
    'triggered_by': set(),
    'triggers': set(),
    'unless': "",
    'when_creating': {},
    'canned_actions_inherit_tags': True,
}

wrapper = TextWrapper(
    break_long_words=False,
    break_on_hyphens=False,
    expand_tabs=False,
    replace_whitespace=False,
)


def format_comment(comment):
    result = "\n\n"
    for line in wrapper.wrap(cleandoc(comment)):
        for inlineline in line.split("\n"):
            result += "{} {}\n".format(bold("#"), italic(inlineline))
    return result


def keys_to_fix(expected_state, actual_state):
    if expected_state is None:
        return set()
    if actual_state is None:
        return set(expected_state.keys())
    differing_keys = set()
    for key, value in expected_state.items():
        if value != actual_state[key]:
            differing_keys.add(key)
    return differing_keys


class ItemStatus:
    """
    Holds information on a particular Item such as whether it needs
    fixing and what's broken.
    """

    def __init__(self, expected_state, actual_state):
        self.expected_state = expected_state
        self.actual_state = actual_state
        self.keys_to_fix = set()
        self.must_be_deleted = (self.actual_state is not None and self.expected_state is None)
        self.must_be_created = (self.expected_state is not None and self.actual_state is None)
        if not self.must_be_deleted and not self.must_be_created:
            self.keys_to_fix = keys_to_fix(expected_state, actual_state)

    def __repr__(self):
        return "<ItemStatus correct:{}>".format(self.correct)

    @property
    def correct(self):
        return not self.must_be_deleted and not self.must_be_created and not bool(self.keys_to_fix)


def make_normalize(attribute_default):
    """
    This is to ensure you can pass filter() results and such in place of
    lists and have them converted to the proper type automatically.
    """
    if type(attribute_default) in (dict, list, set, tuple):
        def normalize(attribute_value):
            if attribute_value is None:
                return attribute_value
            else:
                return type(attribute_default)(attribute_value)

        return normalize
    else:
        return copy


class Item:
    """
    A single piece of configuration (e.g. a file, a package, a service).
    """
    BUNDLE_ATTRIBUTE_NAME = None
    ITEM_ATTRIBUTES = {}
    ITEM_TYPE_NAME = None
    REJECT_UNKNOWN_ATTRIBUTES = True
    REQUIRED_ATTRIBUTES = []
    SKIP_REASON_CMDLINE = 1
    SKIP_REASON_DEP_FAILED = 2
    SKIP_REASON_FAULT_UNAVAILABLE = 3
    SKIP_REASON_INTERACTIVE = 4
    SKIP_REASON_INTERACTIVE_ONLY = 5
    SKIP_REASON_NO_TRIGGER = 6
    SKIP_REASON_SOFTLOCK = 7
    SKIP_REASON_UNLESS = 8
    SKIP_REASON_DEP_SKIPPED = 9
    SKIP_REASON_ATTR = 10
    SKIP_REASON_DESC = {
        SKIP_REASON_CMDLINE: _("cmdline"),
        SKIP_REASON_DEP_FAILED: _("dependency failed"),
        SKIP_REASON_FAULT_UNAVAILABLE: _("Fault unavailable"),
        SKIP_REASON_INTERACTIVE: _("declined interactively"),
        SKIP_REASON_INTERACTIVE_ONLY: _("interactive only"),
        SKIP_REASON_NO_TRIGGER: _("not triggered"),
        SKIP_REASON_SOFTLOCK: _("soft locked"),
        SKIP_REASON_UNLESS: _("unless"),
        SKIP_REASON_DEP_SKIPPED: _("dependency skipped"),
        SKIP_REASON_ATTR: _("attribute"),
    }
    STATUS_OK = 1
    STATUS_FIXED = 2
    STATUS_FAILED = 3
    STATUS_SKIPPED = 4
    STATUS_ACTION_SUCCEEDED = 5
    WHEN_CREATING_ATTRIBUTES = {}

    @classmethod
    def block_concurrent(cls, node_os, node_os_version):
        """
        Return a list of item types that cannot be applied in parallel
        with this item type.
        """
        return []

    def __init__(
        self,
        bundle,
        name,
        attributes,
        skip_validation=False,
        skip_name_validation=False,
    ):
        self.attributes = {}
        self.bundle = bundle
        self.has_been_triggered = False
        self.item_dir = join(bundle.bundle_dir, self.BUNDLE_ATTRIBUTE_NAME)
        self.item_data_dir = join(bundle.bundle_data_dir, self.BUNDLE_ATTRIBUTE_NAME)
        self.name = name
        self.node = bundle.node
        self.when_creating = {}
        self._command_results = []
        self._faults_missing_for_attributes = set()
        self._precedes_items = set()

        if not skip_validation:
            if not skip_name_validation:
                self._validate_name(bundle, name)
                self.validate_name(bundle, name)
            self._validate_attribute_names(bundle, self.id, attributes)
            self._validate_required_attributes(bundle, self.id, attributes)
            self.validate_attributes(bundle, self.id, attributes)

        try:
            attributes = self.patch_attributes(attributes)
        except FaultUnavailable:
            self._faults_missing_for_attributes.add(_("unknown"))

        for attribute_name, attribute_default in BUILTIN_ITEM_ATTRIBUTES.items():
            normalize = make_normalize(attribute_default)
            try:
                setattr(self, attribute_name, force_text(normalize(attributes.get(
                    attribute_name,
                    copy(attribute_default),
                ))))
            except FaultUnavailable:
                self._faults_missing_for_attributes.add(attribute_name)
                setattr(self, attribute_name, BUILTIN_ITEM_ATTRIBUTES[attribute_name])

        for attribute_name, attribute_default in self.ITEM_ATTRIBUTES.items():
            if attribute_name not in BUILTIN_ITEM_ATTRIBUTES:
                normalize = make_normalize(attribute_default)
                try:
                    self.attributes[attribute_name] = force_text(normalize(attributes.get(
                        attribute_name,
                        copy(attribute_default),
                    )))
                except FaultUnavailable:
                    self._faults_missing_for_attributes.add(attribute_name)

        for attribute_name, attribute_default in self.WHEN_CREATING_ATTRIBUTES.items():
            normalize = make_normalize(attribute_default)
            try:
                self.when_creating[attribute_name] = force_text(normalize(
                    attributes.get('when_creating', {}).get(
                        attribute_name,
                        copy(attribute_default),
                    )
                ))
            except FaultUnavailable:
                self._faults_missing_for_attributes.add('when_creating/' + attribute_name)

        if not self.REJECT_UNKNOWN_ATTRIBUTES:
            for key, value in attributes.items():
                if key in BUILTIN_ITEM_ATTRIBUTES:
                    continue
                if isinstance(value, Fault):
                    try:
                        value = value.value
                    except FaultUnavailable:
                        self._faults_missing_for_attributes.add(key)
                        continue
                self.attributes.setdefault(key, value)

        if self.cascade_skip is None:
            self.cascade_skip = not (self.skip or self.triggered or self.unless)

        if self.id in self.triggers:
            raise BundleError(_(
                "item {item} in bundle '{bundle}' can't trigger itself"
            ).format(
                bundle=self.bundle.name,
                item=self.id,
            ))

    def __eq__(self, other):
        return self.id == other.id

    def __hash__(self):
        return hash(self.id)

    def __lt__(self, other):
        return self.id < other.id

    def __str__(self):
        return self.id

    def __repr__(self):
        return "<Item {}>".format(self.id)

    def _check_loopback_dependency(self):
        """
        Alerts the user if they have an item depend on itself.
        """
        if (
            self.id in self.after or
            self.id in self.before or
            self.id in self.needs or
            self.id in self.needed_by
        ):
            raise ItemDependencyError(_(
                "'{item}' in bundle '{bundle}' on node '{node}' cannot depend on itself"
            ).format(
                item=self.id,
                bundle=self.bundle.name,
                node=self.node.name,
            ))

    @cached_property
    def cached_expected_state(self):
        if self._faults_missing_for_attributes:
            self._raise_for_faults()

        expected_state = self.expected_state
        try:
            validate_state_dict(expected_state)
        except ValueError as e:
            raise ValueError(_(
                "{item} from bundle '{bundle}' returned invalid expected_state: {msg}"
            ).format(
                bundle=self.bundle.name,
                item=self.id,
                msg=repr(e),
            ))
        return expected_state

    @cached_property
    def cached_actual_state(self):
        status = self.actual_state
        try:
            validate_state_dict(status)
        except ValueError as e:
            raise ValueError(_(
                "{item} from bundle '{bundle}' returned invalid status: {msg}"
            ).format(
                bundle=self.bundle.name,
                item=self.id,
                msg=repr(e),
            ))
        return status

    @cached_property
    def cached_status(self):
        return self.get_status()

    @cached_property
    def cached_unless_result(self):
        """
        Returns True if 'unless' wants to skip this item.
        """
        if self.unless and (self.ITEM_TYPE_NAME == 'action' or not self.cached_status.correct):
            with io.job(_("{node}  {bundle}  {item}  running 'unless' ...").format(
                bundle=bold(self.bundle.name),
                item=self.id,
                node=bold(self.node.name),
            )):
                unless_result = self.node.run(self.unless, may_fail=True)
                return unless_result.return_code == 0
        else:
            return False

    def _triggers_preceding_items(self, interactive=False):
        """
        Preceding items will execute this to figure out if they're
        triggered.
        """
        if self.cached_unless_result:
            # 'unless' says we don't need to run
            return False
        if self.ITEM_TYPE_NAME == 'action':
            # so we have an action where 'unless' says it must be run
            # but the 'interactive' attribute might still override that
            if self.attributes['interactive'] and not interactive:
                return False
            else:
                return True
        return not self.cached_status.correct

    def _raise_for_faults(self):
        raise FaultUnavailable(_(
            "{item} on {node} is missing faults "
            "for these attributes: {attrs} "
            "(most of the time this means you're missing "
            "a required key in your .secrets.cfg)"
        ).format(
            attrs=", ".join(sorted(self._faults_missing_for_attributes)),
            item=self.id,
            node=self.node.name,
        ))

    def _skip_with_soft_locks(self, mine, others):
        """
        Returns True/False depending on whether the item should be
        skipped based on the given set of locks.
        """
        for lock in mine:
            if self.covered_by_autoskip_selector(lock['items']):
                io.debug(_("{item} on {node} whitelisted by lock {lock}").format(
                    item=self.id,
                    lock=lock['id'],
                    node=self.node.name,
                ))
                return False
        for lock in others:
            if self.covered_by_autoskip_selector(lock['items']):
                io.debug(_("{item} on {node} blacklisted by lock {lock}").format(
                    item=self.id,
                    lock=lock['id'],
                    node=self.node.name,
                ))
                return True
        return False

    def _test(self):
        with io.job(_("{node}  {bundle}  {item}").format(
            bundle=bold(self.bundle.name),
            item=self.id,
            node=bold(self.node.name),
        )):
            if self._faults_missing_for_attributes:
                self._raise_for_faults()
            return self.test()

    @classmethod
    def _validate_attribute_names(cls, bundle, item_id, attributes):
        if not isinstance(attributes, dict):
            raise BundleError(_(
                "invalid item '{item}' in bundle '{bundle}': not a dict"
            ).format(
                item=item_id,
                bundle=bundle.name,
            ))

        if cls.REJECT_UNKNOWN_ATTRIBUTES:
            invalid_attributes = set(attributes.keys()).difference(
                set(cls.ITEM_ATTRIBUTES.keys()).union(
                    set(BUILTIN_ITEM_ATTRIBUTES.keys())
                ),
            )
            if invalid_attributes:
                raise BundleError(_(
                    "invalid attribute(s) for '{item}' in bundle '{bundle}': {attrs}"
                ).format(
                    item=item_id,
                    bundle=bundle.name,
                    attrs=", ".join(invalid_attributes),
                ))

            invalid_attributes = set(attributes.get('when_creating', {}).keys()).difference(
                set(cls.WHEN_CREATING_ATTRIBUTES.keys())
            )
            if invalid_attributes:
                raise BundleError(_(
                    "invalid when_creating attribute(s) for '{item}' in bundle '{bundle}': {attrs}"
                ).format(
                    item=item_id,
                    bundle=bundle.name,
                    attrs=", ".join(invalid_attributes),
                ))

    @classmethod
    def _validate_name(cls, bundle, name):
        if not name:
            raise BundleError(_(
                "invalid name for {type} in bundle '{bundle}': must not be empty string"
            ).format(
                bundle=bundle.name,
                type=cls.ITEM_TYPE_NAME,
            ))
        if ":" in name:
            raise BundleError(_(
                "invalid name for {type} in bundle '{bundle}': {name} (must not contain colon)"
            ).format(
                bundle=bundle.name,
                name=name,
                type=cls.ITEM_TYPE_NAME,
            ))

    @classmethod
    def _validate_required_attributes(cls, bundle, item_id, attributes):
        missing = []
        for attrname in cls.REQUIRED_ATTRIBUTES:
            if attrname not in attributes:
                missing.append(attrname)
        if missing:
            raise BundleError(_(
                "{item} in bundle '{bundle}' missing required attribute(s): {attrs}"
            ).format(
                item=item_id,
                bundle=bundle.name,
                attrs=", ".join(missing),
            ))

    def apply(
        self,
        autoskip_selector=(),
        autoonly_selector=(),
        my_soft_locks=(),
        other_peoples_soft_locks=(),
        interactive=False,
        interactive_default=True,
        show_diff=True,
    ):
        self.node.repo.hooks.item_apply_start(
            repo=self.node.repo,
            node=self.node,
            item=self,
        )
        status_code = None
        status_before = None
        status_after = None
        details = None
        start_time = datetime.now()

        for item in self._precedes_items:
            try:
                if item._triggers_preceding_items(interactive=interactive):
                    io.debug(_(
                        "preceding item {item} on {node} has been triggered by {other_item}"
                    ).format(item=self.id, node=self.node.name, other_item=item.id))
                    self.has_been_triggered = True
                    break
                else:
                    io.debug(_(
                        "preceding item {item} on {node} has NOT been triggered by {other_item}"
                    ).format(item=self.id, node=self.node.name, other_item=item.id))
            except FaultUnavailable:
                io.debug(_(
                    "preceding item {item} on {node} has NOT been triggered by {other_item}, "
                    "because this other item is missing Faults"
                ).format(item=self.id, node=self.node.name, other_item=item.id))

        if self.skip:
            status_code = self.STATUS_SKIPPED
            details = self.SKIP_REASON_ATTR

        elif self.triggered and not self.has_been_triggered:
            io.debug(_(
                "skipping {item} on {node} because it wasn't triggered"
            ).format(item=self.id, node=self.node.name))
            status_code = self.STATUS_SKIPPED
            details = self.SKIP_REASON_NO_TRIGGER

        elif not self.covered_by_autoonly_selector(autoonly_selector):
            io.debug(_(
                "autoonly does not match {item} on {node}"
            ).format(item=self.id, node=self.node.name))
            status_code = self.STATUS_SKIPPED
            details = self.SKIP_REASON_CMDLINE

        elif self.covered_by_autoskip_selector(autoskip_selector):
            io.debug(_(
                "autoskip matches {item} on {node}"
            ).format(item=self.id, node=self.node.name))
            status_code = self.STATUS_SKIPPED
            details = self.SKIP_REASON_CMDLINE

        elif self._skip_with_soft_locks(my_soft_locks, other_peoples_soft_locks):
            status_code = self.STATUS_SKIPPED
            details = self.SKIP_REASON_SOFTLOCK

        elif self._faults_missing_for_attributes:
            if self.error_on_missing_fault:
                self._raise_for_faults()
            else:
                io.debug(_(
                    "skipping {item} on {node} because it is missing faults "
                    "for these attributes: {attrs} "
                    "(most of the time this means you're missing "
                    "a required key in your .secrets.cfg)"
                ).format(
                    attrs=", ".join(sorted(self._faults_missing_for_attributes)),
                    item=self.id,
                    node=self.node.name,
                ))
                status_code = self.STATUS_SKIPPED
                details = self.SKIP_REASON_FAULT_UNAVAILABLE

        else:
            try:
                status_before = self.cached_status
            except FaultUnavailable:
                if self.error_on_missing_fault:
                    self._raise_for_faults()
                else:
                    io.debug(_(
                        "skipping {item} on {node} because it is missing Faults "
                        "(most of the time this means you're missing "
                        "a required key in your .secrets.cfg)"
                    ).format(
                        item=self.id,
                        node=self.node.name,
                    ))
                    status_code = self.STATUS_SKIPPED
                    details = self.SKIP_REASON_FAULT_UNAVAILABLE
            else:
                if self.cached_unless_result:
                    io.debug(_(
                        "'unless' for {item} on {node} succeeded, not fixing"
                    ).format(item=self.id, node=self.node.name))
                    status_code = self.STATUS_SKIPPED
                    details = self.SKIP_REASON_UNLESS
                elif status_before.correct:
                    status_code = self.STATUS_OK
                elif show_diff or interactive:
                    if status_before.must_be_created:
                        expected_state = copy(status_before.expected_state)
                        expected_state.update(self.when_creating)
                        details = self.display_on_create(expected_state)
                    elif status_before.must_be_deleted:
                        details = self.display_on_delete(copy(status_before.actual_state))
                    else:
                        details = self.display_on_fix(
                            copy(status_before.expected_state),
                            copy(status_before.actual_state),
                            copy(status_before.keys_to_fix),
                        )

        if status_code is None:  # item not skipped or OK
            if not interactive:
                with io.job(_("{node}  {bundle}  {item}").format(
                    bundle=bold(self.bundle.name),
                    item=self.id,
                    node=bold(self.node.name),
                )):
                    self.fix(status_before)
            else:
                if status_before.must_be_created:
                    question_text = dict_to_text(details, value_color=green)
                    prompt = _("Create {}?").format(bold(self.id))
                elif status_before.must_be_deleted:
                    question_text = dict_to_text(details, value_color=red)
                    prompt = _("Delete {}?").format(bold(self.id))
                else:
                    display_expected_state, display_actual_state, display_keys_to_fix = details
                    question_text = diff_dict(
                        display_actual_state,
                        display_expected_state,
                        skip_missing_in_target=True,
                    )
                    prompt = _("Fix {}?").format(bold(self.id))
                if self.comment:
                    question_text += format_comment(self.comment)
                question = wrap_question(
                    self.id,
                    question_text,
                    prompt,
                    prefix="{x} {node} ".format(
                        node=bold(self.node.name),
                        x=blue("?"),
                    ),
                )
                answer = io.ask(
                    question,
                    interactive_default,
                    epilogue="{x} {node}".format(
                        node=bold(self.node.name),
                        x=blue("?"),
                    ),
                )
                if answer:
                    with io.job(_("{node}  {bundle}  {item}").format(
                        bundle=bold(self.bundle.name),
                        item=self.id,
                        node=bold(self.node.name),
                    )):
                        self.fix(status_before)
                else:
                    status_code = self.STATUS_SKIPPED
                    details = self.SKIP_REASON_INTERACTIVE

        if status_code is None:  # item not skipped or OK
            status_after = self.get_status(cached=False)
            status_code = self.STATUS_FIXED if status_after.correct else self.STATUS_FAILED

        self.node.repo.hooks.item_apply_end(
            repo=self.node.repo,
            node=self.node,
            item=self,
            duration=datetime.now() - start_time,
            status_code=status_code,
            status_before=status_before,
            status_after=status_after,
        )
        return (
            status_code,
            details,
            status_before.must_be_created if status_before else None,
            status_before.must_be_deleted if status_before else None,
        )

    def run_local(self, command, **kwargs):
        result = run_local(command, **kwargs)
        self._command_results.append({
            'command': command,
            'result': result,
        })
        return result

    def run(self, command, **kwargs):
        result = self.node.run(command, **kwargs)
        self._command_results.append({
            'command': command,
            'result': result,
        })
        return result

    @property
    def expected_state(self):
        """
        Return a dict describing the expected state of this item on the node.
        Returning `None` instead means that the item should not exist.

        MAY be overridden by subclasses.
        """
        return self.attributes

    def covered_by_autoskip_selector(self, autoskip_selector):
        """
        True if this item should be skipped based on the given selector
        (e.g. ("tag:foo", "bundle:bar")).
        """
        components = [c.strip() for c in autoskip_selector]
        if (
            "*" in components or
            self.id in components or
            "bundle:{}".format(self.bundle.name) in components or
            "{}:".format(self.ITEM_TYPE_NAME) in components
        ):
            return True
        for tag in self.tags:
            if "tag:{}".format(tag) in components:
                return True
        return False

    def covered_by_autoonly_selector(self, autoonly_selector, check_deps=True):
        """
        True if this item should NOT be skipped based on the given selector
        (e.g. ("tag:foo", "bundle:bar")).
        """
        if not autoonly_selector:
            return True
        components = [c.strip() for c in autoonly_selector]
        if (
            self.id in components or
            "bundle:{}".format(self.bundle.name) in components or
            "{}:".format(self.ITEM_TYPE_NAME) in components
        ):
            return True
        for tag in self.tags:
            if "tag:{}".format(tag) in components:
                return True
        if check_deps:
            for depending_item in self._incoming_needs:
                if (
                    depending_item.id in components or
                    "bundle:{}".format(depending_item.bundle.name) in components or
                    "{}:".format(depending_item.ITEM_TYPE_NAME) in components
                ):
                    return True
                for tag in depending_item.tags:
                    if "tag:{}".format(tag) in components:
                        return True
        return False

    def fix(self, status):
        """
        This is supposed to actually implement stuff on the target node.

        MUST be overridden by subclasses.
        """
        raise NotImplementedError()

    def get_auto_attrs(self, items):
        """
        Return a dict with any number of attributes. The respective
        sets will be merged with the user-supplied values. For example:

            return {
                'needs': {
                    'file:/foo',
                },
            }

        Note that only attributes from ALLOWED_ITEM_AUTO_ATTRIBUTES are
        allowed.
        """
        return {}

    def get_canned_actions(self):
        """
        Return a dictionary of action definitions (mapping action names
        to dicts of action attributes, as in bundles).

        MAY be overridden by subclasses.
        """
        return {}

    def get_status(self, cached=True):
        """
        Returns an ItemStatus instance describing the current status of
        the item on the actual node.
        """
        with io.job(_("{node}  {bundle}  {item}").format(
            bundle=bold(self.bundle.name),
            item=self.id,
            node=bold(self.node.name),
        )):
            if not cached:
                del self._cache['cached_actual_state']
            return ItemStatus(self.cached_expected_state, self.cached_actual_state)

    def hash(self):
        return hash_state_dict(self.cached_expected_state)

    @property
    def id(self):
        if self.ITEM_TYPE_NAME == 'action' and ":" in self.name:
            # canned actions don't have an "action:" prefix
            return self.name
        return "{}:{}".format(self.ITEM_TYPE_NAME, self.name)

    def verify(
        self,
        autoskip_selector=(),
        autoonly_selector=(),
    ):
        if not self.covered_by_autoonly_selector(autoonly_selector, check_deps=False):
            io.debug(_(
                "autoonly does not match {item} on {node}"
            ).format(item=self.id, node=self.node.name))
            raise ItemSkipped

        if self.covered_by_autoskip_selector(autoskip_selector):
            io.debug(_(
                "autoskip matches {item} on {node}"
            ).format(item=self.id, node=self.node.name))
            raise ItemSkipped

        if self.cached_status.must_be_created:
            display = self.display_on_create(copy(self.cached_status.expected_state))
        elif self.cached_status.must_be_deleted:
            display = self.display_on_delete(copy(self.cached_status.actual_state))
        else:
            display = self.display_on_fix(
                copy(self.cached_status.expected_state),
                copy(self.cached_status.actual_state),
                copy(self.cached_status.keys_to_fix),
            )
        return self.cached_unless_result, self.cached_status, display

    def display_on_create(self, expected_state):
        """
        Given an expected_state dict as implemented above, modify it to
        better suit interactive presentation when an item is created. If
        there are any when_creating attributes, they will be added to
        the expected_state before it is passed to this method.

        MAY be overridden by subclasses.
        """
        return expected_state

    def display_on_fix(self, expected_state, actual_state, keys):
        """
        Given expected_state and actual_state as implemented above, modify them to
        better suit interactive presentation. The keys parameter is a
        set of keys whose values differ between expected_state and actual_state.

        MAY be overridden by subclasses.
        """
        return (expected_state, actual_state, keys)

    def display_on_delete(self, actual_state):
        """
        Given an actual_state dict as implemented above, modify it to
        better suit interactive presentation when an item is deleted.

        MAY be overridden by subclasses.
        """
        return actual_state

    def patch_attributes(self, attributes):
        """
        Allows an item to preprocess the attributes it is initialized
        with. Returns the modified attributes dictionary.

        MAY be overridden by subclasses.
        """
        return attributes

    def preview(self):
        """
        Can return a preview of this item as a Unicode string.
        BundleWrap will NOT add a trailing newline.

        MAY be overridden by subclasses.
        """
        raise NotImplementedError()

    @property
    def actual_state(self):
        """
        Return a dict describing the actual state of this item on the node.
        Returning `None` instead means that the item does not exist on the node.

        For the item to validate as correct, the values for all keys in
        self.expected_state have to match this actual_state.

        MUST be overridden by subclasses.
        """
        raise NotImplementedError()

    def test(self):
        """
        Used by `bw repo test`. Should do as much as possible to detect
        what would become a runtime error during a `bw apply`. Files
        will attempt to render their templates for example.

        SHOULD be overridden by subclasses
        """
        pass

    @classmethod
    def validate_attributes(cls, bundle, item_id, attributes):
        """
        Raises BundleError if something is amiss with the user-specified
        attributes.

        SHOULD be overridden by subclasses.
        """
        pass

    @classmethod
    def validate_name(cls, bundle, name):
        """
        Raise BundleError if the given name is not valid (e.g. contains
        invalid characters for this kind of item.

        MAY be overridden by subclasses.
        """
        pass
