from __future__ import annotations

import collections
import contextlib
import dataclasses
import enum
import functools
import types
import typing as t

from collections import abc as c

from ansible._internal import _display_utils
from ansible._internal._errors import _attribute_unavailable, _captured, _error_factory, _error_utils
from ansible._internal._templating import _engine
from ansible._internal._templating._chain_templar import ChainTemplar
from ansible.errors import AnsibleError, AnsibleTemplateError
from ansible.module_utils._internal._ambient_context import AmbientContextBase
from ansible.module_utils._internal import _dataclass_validation, _event_utils, _messages, _traceback
from ansible.module_utils.datatag import native_type_name, deprecator_from_collection_name, deprecate_value
from ansible.parsing import vault as _vault
from ansible.utils.display import Display
from ansible.utils import vars as _vars
from ansible.vars.clean import module_response_deepcopy, strip_internal_keys

if t.TYPE_CHECKING:
    from ansible.playbook.task import Task
    from ansible.inventory.host import Host
    from ansible.executor.task_executor import TaskExecutor

display = Display()

IGNORE = frozenset(
    {
        'failed',
        'skipped',
    }
)

PRESERVE = frozenset(
    {
        '_ansible_no_log',
        'attempts',
        'changed',
        'deprecations',
        'exception',
        'retries',
        'warnings',
    }
)

# stuff callbacks need
CLEAN_EXCEPTIONS = frozenset(
    {
        '_ansible_delegated_vars',  # filtered elsewhere, used for detection/reporting of effective delegation
        '_ansible_item_label',  # to know actual 'item' variable
        '_ansible_no_log',  # jic we didn't clean up well enough, DON'T LOG
        '_ansible_verbose_always',  # for debug and other actions, to always expand data (pretty jsonification)
        '_ansible_verbose_override',  # controls display of ansible_facts, gathering would be very noise with -v otherwise
    }
)


class NoRecordedResultError(AnsibleError):
    """Error raised when requesting recorded results prior to any being recorded."""

    _default_message = "No task result has been recorded."


class NotALoopError(AnsibleError):
    """Error raised when attempting to access loop state when not in a loop."""

    _default_message = "The current task is not a loop."


@dataclasses.dataclass(kw_only=True, slots=True)
class CurrentTask:
    """
    Instances of this type are exposed to Jinja during register projections and playbook conditional expression eval.

    CAUTION: The shape and behavior of this type is effectively public API.
    """

    @property
    def result(self) -> c.Mapping[str, object]:
        try:
            return TaskContext.current().latest_result
        except NoRecordedResultError as ex:
            # the template engine will turn this into an UndefinedMarker
            raise _attribute_unavailable.AttributeUnavailableError("The _task.result property is unavailable.") from ex

    @property
    def loop_result(self) -> c.Mapping[str, object]:
        try:
            # in-flight projections bypass the skipped conversions by setting finalize to False
            # RPFIX-3: may need to fix Jinja's getattr/item AttributeError handling- if any code invoked beneath one of those raises AttributeError, Jinja
            #  blindly creates a new UndefinedMarker with "CurrentTask has no attr loop_result" and loses the original AttributeError detail. Repro this by
            #  tacking any bogus attr off the end of as_result_dict() here.
            return TaskContext.current().build_loop_result(preview=True).as_result_dict()
        except (NotALoopError, NoRecordedResultError) as ex:
            raise _attribute_unavailable.AttributeUnavailableError("The _task.loop_result property is unavailable.") from ex


def create_current_task_variable_layer() -> dict[str, object]:
    return dict(_task=CurrentTask())


@dataclasses.dataclass
class TaskContext(AmbientContextBase):
    """Ambient context that wraps task execution on workers. It provides access to the currently executing task."""

    task: Task
    task_vars: dict[str, t.Any]

    @property
    def is_loop(self) -> bool:
        return self._loop_items is not None

    @property
    def has_loop_exited(self) -> bool:
        return self._has_loop_exited

    @property
    def current_item(self) -> object:
        return self._item

    @property
    def current_item_label(self) -> object:
        return self.task.loop_control.label or self.task_templar.variable_name_as_template(self._loop_var)

    _raw_loop_results: list[UnifiedTaskResult] = dataclasses.field(default_factory=list)
    _loop_items: list[object] | None = None
    _has_loop_exited: bool = False
    _loop_var: str | None = None
    _item: object | None = None
    _item_index: int | None = None
    _index_var: str | None = None
    _templar: _engine.TemplateEngine | None = None
    _break_when_triggered: bool = False

    @contextlib.contextmanager
    def loop_item_context(self, te: TaskExecutor) -> t.Generator[None]:
        original_task = te._task
        original_play_context = te._play_context

        loop_item_task = te._task.copy(exclude_parent=True, exclude_tasks=True)
        loop_item_task._parent = te._task._parent
        loop_item_play_context = te._play_context.copy()

        self.task = te._task = loop_item_task
        te._play_context = loop_item_play_context

        try:
            yield
        finally:
            self.task = te._task = original_task
            te._play_context = original_play_context

    def start_loop(self) -> t.Generator[tuple[int, object]]:
        self._loop_var = loop_var = t.cast(str, self.task.loop_control.loop_var)
        self._index_var = index_var = t.cast(str, self.task.loop_control.index_var)

        extended = t.cast(bool, self.task.loop_control.extended)
        extended_allitems = t.cast(bool, self.task.loop_control.extended_allitems)

        items = self._loop_items
        items_len = len(items)

        for item_index, item in enumerate(self._loop_items):
            self._templar = None  # we're changing the values used to calculate the templar, null it out so the next requestor re-creates it

            self._item = item
            self._item_index = item_index

            loop_vars: dict[str, object] = dict(
                ansible_loop_var=loop_var,
            )

            loop_vars[loop_var] = item  # RPFIX-5: this can clobber any variable set before it (or be clobbered by any set after)

            if index_var:
                loop_vars['ansible_index_var'] = index_var
                loop_vars[index_var] = item_index  # RPFIX-5: this can clobber any variable set before it (or be clobbered by any set after)

            if extended:
                ansible_loop: dict[str, object] = {
                    'index': item_index + 1,
                    'index0': item_index,
                    'first': item_index == 0,
                    'last': item_index + 1 == items_len,
                    'length': items_len,
                    'revindex': items_len - item_index,
                    'revindex0': items_len - item_index - 1,
                }

                if extended_allitems:
                    ansible_loop['allitems'] = items

                try:
                    ansible_loop['nextitem'] = items[item_index + 1]
                except IndexError:
                    pass

                if item_index - 1 >= 0:
                    ansible_loop['previtem'] = items[item_index - 1]

                loop_vars['ansible_loop'] = ansible_loop

            self.task_vars.update(loop_vars)

            yield item_index, item

            if self._break_when_triggered:
                raise RuntimeError("Cannot continue loop evaluation after break_when is triggered.")

        self._has_loop_exited = True

    @property
    def task_templar(self) -> _engine.TemplateEngine:
        if not self._templar:
            self._templar = _engine.TemplateEngine(loader=self.task._loader, variables=self.task_vars)

        return self._templar

    @property
    def latest_result(self) -> c.MutableMapping[str, object]:
        if not self._raw_loop_results:
            raise NoRecordedResultError()

        return self._raw_loop_results[-1].as_result_dict()

    @staticmethod
    def _project(task: Task, templar: _engine.TemplateEngine, utr: UnifiedTaskResult) -> c.Mapping[str, t.Any]:
        if isinstance(task.register, str):
            return {task.register: utr.as_result_dict()}

        # bolt current_task into _task (or whatever) into an extended templar available variables
        results_layer: dict[str, object] = {}
        register_projection_results = collections.ChainMap(results_layer, create_current_task_variable_layer(), templar.available_variables)
        register_templar = templar.extend(variables=register_projection_results)

        for var_name, expression in task.register.items():
            # have augmented templar with _task injected evaluate the expression and store its value under var_name
            # register:
            #   foo: _task.result
            #   bar: foo.stdout
            try:
                register_projection_results[var_name] = register_templar.evaluate_expression(expression)
            except Exception as ex:
                # RPFIX-3: file a separate issue: this warning shows up twice- getting captured by the deferredwarningcontext and sent to callbacks -
                #          any warning raised this way under a DWC will likely show up multiple times.
                display.error_as_warning(msg="An error occured in a register expression.", exception=ex, obj=expression)
                # RPFIX-3: make this an unrecoverable error. If none of the above occur, there could be a stale projection value in vars from a previous
                # successful projection or loop.

        return results_layer

    def _record_result(self, utr: UnifiedTaskResult, allow_replace: bool = False) -> None:
        # can we get away with only returning ignore_errors on task_fields, then? I think so...

        if isinstance(self.task.ignore_errors, bool):
            # HACK: avoid setting to True due to template failures -- this should go away once field attribute templating is fixed
            utr.ignore_errors = self.task.ignore_errors

        if isinstance(self.task.ignore_unreachable, bool):
            # HACK: avoid setting to True due to template failures -- this should go away once field attribute templating is fixed
            utr.ignore_unreachable = self.task.ignore_unreachable

        if not TaskContext.current().is_loop:
            if self._raw_loop_results:
                self._raw_loop_results.clear()
                # RPFIX-5: we should be able to show the existing task exception (if present) here, before discarding it

                if not allow_replace:
                    # RPFIX-3: this should probably be an error
                    display.warning('Replacing existing task result.', obj=self.task.get_ds())

            self._raw_loop_results.append(utr)

            return

        if TaskContext.current().has_loop_exited:
            return

        # now update the result with the item info, and append the result
        # to the list of results
        utr.loop_item = self._item
        utr.loop_var = self._loop_var

        if self._index_var:
            utr.loop_index = self._item_index
            utr.loop_index_var = self._index_var

        if self.task.loop_control.extended:
            utr.loop_extended = self.task_vars['ansible_loop']

        # update the local copy of vars with the registered value, if specified,
        # or any facts which may have been generated by the module execution
        if self.task.register:
            self.task_vars.update(TaskContext._project(self.task, self.task_templar, utr))

        # gets templated here unlike rest of loop_control fields, depends on loop_var above
        item_label = ...

        if self.task.loop_control.label:
            try:
                item_label = self.task_templar.template(self.task.loop_control.label)
            except AnsibleTemplateError as ex:
                display.error_as_warning('Failed to template loop_control label.', ex, obj=self.task.loop_control.label)

        if item_label is ...:
            item_label = self.task_templar.resolve_variable_expression(self._loop_var)

        utr.loop_item_label = item_label

        item_index = self._item_index
        result_count = len(self._raw_loop_results)

        if item_index == result_count:
            self._raw_loop_results.append(utr)  # add new result
        elif item_index + 1 == result_count:
            if not allow_replace:
                # RPFIX-3: this should probably be an error
                display.warning('Replacing existing task result.', obj=self.task)

            self._raw_loop_results[item_index] = utr  # replace existing result
        else:
            raise RuntimeError(f'Item index {item_index} does not match {result_count}.')

    def record_break_when(self) -> None:
        self._break_when_triggered = True
        self._has_loop_exited = True

    def build_loop_result(self, preview: bool = False) -> UnifiedTaskResult:
        if not self.is_loop:
            raise NotALoopError()

        if not preview and (self._item_index is None or len(self._raw_loop_results) not in (self._item_index, self._item_index + 1)):
            # RPFIX-3: can we ditch preview while retaining this safety check?
            # Loop results can be queried before or after loop results are recorded, so we need to accept a range of results.
            raise RuntimeError(f"Mismatch between item index {self._item_index} and loop result count {len(self._raw_loop_results)}.")

        # create the overall result item
        utr = UnifiedTaskResult.from_action_result_dict()
        utr.loop_results = self._raw_loop_results

        # RPFIX-3: all the fields set in this loop could be converted to properties
        for item in self._raw_loop_results:
            if item.registered_values is not None:
                utr.registered_values = item.registered_values

            if item.no_log:
                utr.no_log = True  # ensure no_log processing recognizes at least one item needs to be censored

            utr._extend_warnings(item.warnings)
            utr._extend_deprecations(item.deprecations)

        if all(item.skipped for item in self._raw_loop_results):
            utr.set_skipped()  # RPFIX-5: previously skipped=False was included in the result, add a changelog entry to document the change in behavior
            utr.msg = 'All items skipped'
        elif utr.failed:
            utr.msg = 'One or more items failed'
        else:
            utr.msg = 'All items completed'

        return utr


TaskArgsFinalizerCallback = t.Callable[[str, t.Any, _engine.TemplateEngine, t.Any], t.Any]
"""Type alias for the shape of the `ActionBase.finalize_task_arg` method."""


class TaskArgsChainTemplar(ChainTemplar):
    """
    A ChainTemplar that carries a user-provided context object, optionally provided by `ActionBase.get_finalize_task_args_context`.
    TaskArgsFinalizer provides the context to each `ActionBase.finalize_task_arg` call to allow for more complex/stateful customization.
    """

    def __init__(self, *sources: c.Mapping, templar: _engine.TemplateEngine, callback: TaskArgsFinalizerCallback, context: t.Any) -> None:
        super().__init__(*sources, templar=templar)

        self.callback = callback
        self.context = context

    def template(self, key: t.Any, value: t.Any) -> t.Any:
        return self.callback(key, value, self.templar, self.context)


class TaskArgsFinalizer:
    """Invoked during task args finalization; allows actions to override default arg processing (e.g., templating)."""

    def __init__(self, *args: c.Mapping[str, t.Any] | str | None, templar: _engine.TemplateEngine) -> None:
        self._args_layers = [arg for arg in args if arg is not None]
        self._templar = templar

    def finalize(self, callback: TaskArgsFinalizerCallback, context: t.Any) -> dict[str, t.Any]:
        from ansible import constants

        resolved_layers: list[c.Mapping[str, t.Any]] = []

        for layer in self._args_layers:
            if isinstance(layer, (str, _vault.EncryptedString)):  # EncryptedString can hide a template
                if constants.config.get_config_value('INJECT_FACTS_AS_VARS'):
                    display.warning(
                        "Using a template for task args is unsafe in some situations "
                        "(see https://docs.ansible.com/ansible/devel/reference_appendices/faq.html#argsplat-unsafe).",
                        obj=layer,
                    )

                resolved_layer = self._templar.resolve_to_container(layer, options=_engine.TemplateOptions(value_for_omit={}))
            else:
                resolved_layer = layer

            if not isinstance(resolved_layer, dict):
                raise AnsibleError(f'Task args must resolve to a {native_type_name(dict)!r} not {native_type_name(resolved_layer)!r}.', obj=layer)

            resolved_layers.append(resolved_layer)

        ct = TaskArgsChainTemplar(*reversed(resolved_layers), templar=self._templar, callback=callback, context=context)

        return ct.as_dict()


class Source(enum.Enum):
    ACTION = enum.auto()
    ANY = enum.auto()


@dataclasses.dataclass(kw_only=True, frozen=True, slots=True)
class FieldSettings:
    key: str | None = None
    source: Source | None = None
    exportable: bool = False
    conversion_func: t.Callable[[object], object] | None = None


_type = type


@dataclasses.dataclass(kw_only=True, frozen=True, slots=True)
class ResolvedField:
    name: str
    type: type
    optional: bool
    field: dataclasses.Field | None
    metadata: FieldSettings

    @classmethod
    def from_field(cls, type_hints: dict[str, t.Any], metadata: FieldSettings, dc_field: dataclasses.Field) -> t.Self:
        resolved_type = type_hints[dc_field.name]

        if isinstance(resolved_type, types.UnionType):
            args = resolved_type.__args__

            if len(args) == 2 and args[0] is types.NoneType:
                resolved_type = args[1]
                optional = True
            elif len(args) == 2 and args[1] is types.NoneType:
                resolved_type = args[0]
                optional = True
            else:
                raise NotImplementedError(f"Unexpected union type args: {args}")
        else:
            optional = False

        if isinstance(resolved_type, types.GenericAlias):
            resolved_type = t.get_origin(resolved_type)
        elif (orig_bases := types.get_original_bases(resolved_type)) and orig_bases[0] is t.TypedDict:
            resolved_type = dict

        return cls(
            name=dc_field.name,
            type=resolved_type,
            optional=optional,
            field=dc_field,
            metadata=metadata,
        )

    @property
    def result_key(self) -> str:
        return self.metadata.key or self.name


def export_only(
    key: str | None = None,
    *,
    conversion_func: t.Callable[[object], object] | None = None,
) -> dict[str, t.Any]:
    return field(
        key,
        source=None,
        exportable=True,
        conversion_func=conversion_func,
    )


def import_export(
    key: str | None = None,
    *,
    conversion_func: t.Callable[[object], object] | None = None,
) -> dict[str, t.Any]:
    return field(
        key,
        source=Source.ANY,
        exportable=True,
        conversion_func=conversion_func,
    )


def field(
    key: str | None = None,
    *,
    source: Source | None = None,
    exportable: bool = False,
    conversion_func: t.Callable[[object], object] | None = None,
) -> dict[str, t.Any]:
    return dict(
        ansible=FieldSettings(
            key=key,
            source=source,
            exportable=exportable,
            conversion_func=conversion_func,
        )
    )


_DEFAULT_FIELD_SETTINGS = FieldSettings()


def _convert_no_log(value: object) -> bool:
    if not isinstance(value, bool):
        # RPFIX-3: do we want to keep any kind of custom warning regarding this value always being True when not a bool?
        # display.warning(f'Invalid _ansible_no_log value of type {type(value).__name__!r} in task result, output will be masked.')
        value = True

    return value


@dataclasses.dataclass(kw_only=True, frozen=True, slots=True)
class AddHost:
    host_name: str
    host_vars: dict[str, object] | None = None
    parent_group_names: list[str] | None = None

    def __post_init__(self): ...

    @classmethod
    def from_dict(cls, value: object) -> AddHost | None:
        if isinstance(value, dict):
            if groups := value.pop('groups'):
                value['parent_group_names'] = groups

            try:
                return cls(**value)
            except Exception as ex:
                display.error_as_warning('Ignoring invalid add_host value in task result.', exception=ex)

        return None


_dataclass_validation.inject_post_init_validation(AddHost, allow_subclasses=True)


@dataclasses.dataclass(kw_only=True, frozen=True, slots=True)
class AddGroup:
    group_name: str
    group_vars: dict[str, object] | None = None
    parent_group_names: list[str]

    def __post_init__(self): ...


_dataclass_validation.inject_post_init_validation(AddGroup, allow_subclasses=True)


class StatsDict(t.TypedDict):
    data: dict[str, object]
    per_host: t.NotRequired[object | None]  # really bool, but no validation
    aggregate: t.NotRequired[object | None]  # really bool, but no validation


def _convert_stats(value: t.Any) -> StatsDict | None:
    if not isinstance(value, dict) or not isinstance(value.get('data'), dict):
        display.warning('Ignoring invalid ansible_stats value in task result.')
        value = None

    return value


def _convert_warnings(value: object) -> list[_messages.WarningSummary] | None:
    if not value:
        return None

    if not isinstance(value, list):
        display.warning(f"Task result `warnings` was {native_type_name(value)} instead of {native_type_name(list)} and has been discarded.")
        return None

    warnings: list[_messages.WarningSummary] = []

    for warning in value:
        if not isinstance(warning, _messages.WarningSummary):
            # translate non-WarningMessageDetail messages
            warning = _messages.WarningSummary(
                event=_messages.Event(
                    msg=str(warning),
                ),
            )

        if warning_ctx := _display_utils.DeferredWarningContext.current(optional=True):
            warning_ctx.capture(warning)
        else:
            warnings.append(warning)

    return warnings


def _convert_deprecations(value: object) -> list[_messages.DeprecationSummary] | None:
    if not value:
        return None

    if not isinstance(value, list):
        display.warning(f"Task result `deprecations` was {native_type_name(value)} instead of {native_type_name(list)} and has been discarded.")
        return None

    deprecations: list[_messages.DeprecationSummary] = []

    for deprecation in value:
        if not isinstance(deprecation, _messages.DeprecationSummary):
            # translate non-DeprecationSummary message dicts
            try:
                if (collection_name := deprecation.pop('collection_name', ...)) is not ...:
                    # deprecated: description='enable the deprecation message for collection_name' core_version='2.23'
                    # CAUTION: This deprecation cannot be enabled until the replacement (deprecator) has been documented, and the schema finalized.
                    # self.deprecated('The `collection_name` key in the `deprecations` dictionary is deprecated.', version='2.27')
                    deprecation.update(deprecator=deprecator_from_collection_name(collection_name))

                deprecation = _messages.DeprecationSummary(
                    event=_messages.Event(
                        msg=deprecation.pop('msg'),
                    ),
                    **deprecation,
                )
            except Exception as ex:
                display.error_as_warning("Task result `deprecations` contained an invalid item.", exception=ex)

        if warning_ctx := _display_utils.DeferredWarningContext.current(optional=True):
            warning_ctx.capture(deprecation)
        else:
            deprecations.append(deprecation)

    return deprecations


# RPFIX-3: this is currently on things that are marked as needing to use a dedicated API (likely obsolete, nothing sets Source.ACTION anymore, kill it all?),
DROP_AND_WARN = ResolvedField(name='__drop__', type=str, optional=True, field=None, metadata=FieldSettings())


class VariableLayer(enum.Enum):
    """Variable layer at which variables are registered."""

    # RPFIX-1: this needs to be part of the public API, unless we're not allowing collections to register at non-default layers initially
    CACHEABLE_FACT = enum.auto()
    EPHEMERAL_FACT = enum.auto()
    INCLUDE_VARS = enum.auto()
    REGISTER_VARS = enum.auto()  # RPFIX-3: this will be used when UnifiedTaskResult.registered_values is killed off


@dataclasses.dataclass(kw_only=True, slots=True)
class UnifiedTaskResult:
    _changed: bool | None = dataclasses.field(default=None, metadata=import_export("changed"))

    @property
    def changed(self) -> bool:
        if self._changed is not None or self.loop_results is None:
            return bool(self._changed)

        return any(loop_result.changed for loop_result in self.loop_results)

    @changed.setter
    def changed(self, value: bool) -> None:
        self._changed = value

    _failed: bool | None = dataclasses.field(default=None, metadata=import_export("failed"))

    @property
    def failed(self) -> bool:
        if self._failed is None and self.rc is not None and self.rc != 0:
            return True

        if self.failed_when_result is not None:
            return bool(self.failed_when_result)

        # RPFIX-5: this bothers nitz, scratch that itch
        if self.loop_results and any(loop_result.failed_when_result is not None for loop_result in self.loop_results):
            return any(loop_result.failed_when_result for loop_result in self.loop_results)

        if self._failed is not None or self.loop_results is None:
            return bool(self._failed)

        return any(loop_result.failed for loop_result in self.loop_results)

    @failed.setter
    def failed(self, value: bool) -> None:
        self._failed = value

    _unreachable: bool | None = dataclasses.field(default=None, metadata=import_export("unreachable"))

    @property
    def unreachable(self) -> bool | None:
        if self._unreachable is not None or self.loop_results is None:
            result = bool(self._unreachable)
        else:
            result = any(loop_result.unreachable for loop_result in self.loop_results)

        return result or None

    @unreachable.setter
    def unreachable(self, value: bool) -> None:
        self._unreachable = value

    _skipped: bool | None = dataclasses.field(default=None, metadata=import_export("skipped"))

    @property
    def skipped(self) -> bool | None:
        if self._skipped is not None or self.loop_results is None:
            result = bool(self._skipped)
        else:
            # Loop tasks are only considered skipped if all items were skipped.
            result = all(loop_result.skipped for loop_result in self.loop_results)

        return result or None

    @skipped.setter
    def skipped(self, value: bool) -> None:
        self._skipped = value

    _ignore_errors: bool = False  # formerly _ansible_ignore_errors

    @property
    def ignore_errors(self) -> bool:
        return self._ignore_errors if self.loop_results is None else any(loop_result._ignore_errors for loop_result in self.loop_results)

    @ignore_errors.setter
    def ignore_errors(self, value: bool) -> None:
        self._ignore_errors = value

    _ignore_unreachable: bool = False  # formerly _ansible_ignore_unreachable

    @property
    def ignore_unreachable(self) -> bool:
        return self._ignore_unreachable if self.loop_results is None else any(loop_result._ignore_unreachable for loop_result in self.loop_results)

    @ignore_unreachable.setter
    def ignore_unreachable(self, value: bool) -> None:
        self._ignore_unreachable = value

    failed_when_result: object | None = dataclasses.field(default=None, metadata=export_only())

    # RPFIX-1: this one is still borked for post-result additions- need an explicit setter method or ? (may not be an issue, but verify that)
    ansible_facts: dict[str, t.Any] | None = dataclasses.field(default=None, metadata=import_export())
    async_result: dict[str, t.Any] | None = dataclasses.field(default=None, metadata=export_only())
    ansible_parsed: bool | None = None  # formerly _ansible_parsed
    exception: _messages.ErrorSummary | None = dataclasses.field(default=None, metadata=import_export())
    finished: bool | None = dataclasses.field(default=None, metadata=import_export())
    msg: object | None = dataclasses.field(default=None, metadata=import_export())
    no_log: bool | None = dataclasses.field(default=None, metadata=import_export("_ansible_no_log", conversion_func=_convert_no_log))
    rc: int | None = dataclasses.field(default=None, metadata=import_export())
    suppress_tmpdir_delete: bool | None = dataclasses.field(default=None, metadata=field("_ansible_suppress_tmpdir_delete", source=Source.ANY))
    module_stderr: str | None = dataclasses.field(default=None, metadata=export_only())
    module_stdout: str | None = dataclasses.field(default=None, metadata=export_only())
    warnings: list[_messages.WarningSummary] | None = dataclasses.field(default=None, metadata=import_export(conversion_func=_convert_warnings))
    deprecations: list[_messages.DeprecationSummary] | None = dataclasses.field(default=None, metadata=import_export(conversion_func=_convert_deprecations))
    result_data: dict[str, object] = dataclasses.field(default_factory=dict)
    is_module: bool
    attempts: int | None = dataclasses.field(default=None, metadata=export_only())
    retries: int | None = dataclasses.field(default=None, metadata=export_only())
    notify: list[str] = dataclasses.field(default_factory=list)
    delegated_host: str | None = None
    # RPFIX-3: rename delegated_vars to something like `callback_delegated_var_subset_of_crap`
    delegated_vars: dict[str, object] | None = dataclasses.field(default=None, metadata=export_only("_ansible_delegated_vars"))
    diff: object | None = dataclasses.field(default=None, metadata=import_export())  # RPFIX-5: validation with custom conversion func?
    pending_add_hosts: list[AddHost] = dataclasses.field(default_factory=list)  # RPFIX-5: rename to something less confusing?
    pending_add_groups: list[AddGroup] = dataclasses.field(default_factory=list)  # RPFIX-5: rename to something less confusing?
    pending_register_host_variables: dict[VariableLayer, dict[str, object]] = dataclasses.field(default_factory=dict)
    stats: StatsDict | None = dataclasses.field(default=None, metadata=import_export('ansible_stats', conversion_func=_convert_stats))
    async_job_id: str | None = dataclasses.field(default=None, metadata=import_export('ansible_job_id'))
    include_file: str | None = None
    include_args: dict[str, object] | None = None  # FUTURE: make this a dataclass
    registered_values: c.Mapping[str, object] | None = None  # RPFIX-3: this is redundant-ish with pending_register_host_variables and can probably be collapsed

    skip_reason: str | None = dataclasses.field(default=None, metadata=export_only())
    skipped_reason: str | None = dataclasses.field(default=None, metadata=export_only())
    """The `skipped_reason` field is deprecated. Use `skip_reason` instead."""

    false_condition: object | None = dataclasses.field(default=None, metadata=export_only())
    changed_when_result: str | None = dataclasses.field(default=None, metadata=export_only())
    failed_when_suppressed_exception: _messages.ErrorSummary | None = dataclasses.field(default=None, metadata=export_only())

    loop_var: str | None = dataclasses.field(default=None, metadata=export_only('ansible_loop_var'))
    loop_index_var: str | None = dataclasses.field(default=None, metadata=export_only('ansible_index_var'))
    loop_item_label: object | None = dataclasses.field(default=None, metadata=export_only('_ansible_item_label'))
    loop_item: object | None = dataclasses.field(default=None, metadata=export_only())  # exported as self.loop_var value
    loop_index: object | None = dataclasses.field(default=None, metadata=export_only())  # exported as self.loop_index_var value
    loop_extended: dict[str, object] | None = dataclasses.field(default=None, metadata=export_only('ansible_loop'))
    loop_results: list[t.Self] | None = None

    @classmethod
    @functools.cache
    def get_result_key_to_resolved_field_mapping(cls, source_is_module: bool) -> dict[str, ResolvedField]:
        mapping = {}
        type_hints = t.get_type_hints(cls)

        for dc_field in dataclasses.fields(cls):
            metadata = t.cast(FieldSettings, dc_field.metadata.get('ansible', _DEFAULT_FIELD_SETTINGS))
            resolved_field = ResolvedField.from_field(type_hints, metadata, dc_field)

            if metadata.source is Source.ACTION and source_is_module:
                mapping[resolved_field.result_key] = DROP_AND_WARN
            elif metadata.source is Source.ACTION or metadata.source is Source.ANY:
                mapping[resolved_field.result_key] = resolved_field

        return mapping

    @classmethod
    @functools.cache
    def _get_field_name_to_result_key_mapping(cls) -> dict[str, str]:
        mapping = {}

        for dc_field in dataclasses.fields(cls):
            metadata = t.cast(FieldSettings, dc_field.metadata.get('ansible', _DEFAULT_FIELD_SETTINGS))

            if metadata.exportable:
                key = metadata.key or dc_field.name
                field_name = key if hasattr(cls, key) else dc_field.name  # properties matching the exported key take precedence over the field during export

                mapping[field_name] = key

        return mapping

    @classmethod
    def from_action_result_dict(cls, result: dict[str, object] | None = None) -> t.Self:
        return cls._from_result_dict(result or {}, source_is_module=False)

    @classmethod
    def _from_result_dict(cls, result: dict[str, object], source_is_module: bool) -> t.Self:
        if not isinstance(result, dict):
            raise TypeError(f'Malformed result. Received {type(result)} instead of {dict}.')

        fields = cls.get_result_key_to_resolved_field_mapping(source_is_module=source_is_module)
        result_data: dict[str, object] = {}
        kwargs: dict[str, t.Any] = dict(
            result_data=result_data,
        )

        for key, value in result.items():
            if resolved_field := fields.get(key):
                if resolved_field is DROP_AND_WARN:
                    display.warning(f"Removed reserved key {key!r} from module result.", obj=value)
                else:
                    kwargs[resolved_field.name] = cls.convert_field(resolved_field, value)
            elif source_is_module and key.startswith('_ansible_'):
                display.warning(f"Removed reserved key {key!r} from module result.", obj=value)
            else:
                result_data[key] = value

        return cls(is_module=source_is_module, **kwargs)

    def _result_key_magic(self, key: str) -> str:
        match key:
            case "loop_item":
                return self.loop_var or 'item'  # RPFIX-3: centralize the default logic

            case "loop_index":
                return self.loop_index_var

            case _:
                return key

    def as_result_dict(self, for_callback: bool = False, censor_callback_result: bool = False) -> dict[str, object]:
        result: dict[str, t.Any] = {
            self._result_key_magic(result_key): value
            for field_name, result_key in self._get_field_name_to_result_key_mapping().items()
            if (value := getattr(self, field_name)) is not None
        }

        result.update(self.result_data)

        # RPFIX-3: is this where we want stdout/stderr handling?
        # pre-split stdout/stderr into lines if needed

        if 'stdout' in result and 'stdout_lines' not in result:
            # if the value is 'False', a default won't catch it.
            txt = result.get('stdout', None) or ''
            result.update(stdout_lines=txt.splitlines())

        if 'stderr' in result and 'stderr_lines' not in result:
            # if the value is 'False', a default won't catch it.
            txt = result.get('stderr', None) or ''
            result.update(stderr_lines=txt.splitlines())

        if for_callback:
            if censor_callback_result:
                result = {key: value for key in PRESERVE if (value := result.get(key, ...)) is not ...}
                result.update(censored="the output has been hidden due to the fact that 'no_log: true' was specified for this result")

            # always drop failed/skipped from callback dict
            result = {k: v for k, v in module_response_deepcopy(result).items() if k not in IGNORE}

            # remove most internal keys not relevant to callbacks
            strip_internal_keys(result, exceptions=CLEAN_EXCEPTIONS)

        if self.loop_results is not None:
            # loop results need to be added after censor_result on the outer result since it's currently naive about whether it's looking at a loop item or not
            result.update(
                results=[
                    loop_result.as_result_dict(for_callback=for_callback, censor_callback_result=censor_callback_result or loop_result.no_log)
                    for loop_result in self.loop_results
                ]
            )

        if for_callback:
            result = _vars.transform_to_native_types(result)

        return result

    def _extend_warnings(self, warnings: c.Iterable[_messages.WarningSummary] | None) -> None:
        if not warnings:
            return

        if not self.warnings:
            self.warnings = []

        self.warnings.extend(warnings)

    def _extend_deprecations(self, deprecations: c.Iterable[_messages.DeprecationSummary]) -> None:
        if not deprecations:
            return

        if not self.deprecations:
            self.deprecations = []

        self.deprecations.extend(deprecations)

    @classmethod
    def convert_field(cls, resolved_field: ResolvedField, value: object) -> object:
        if value is None:
            return None if resolved_field.optional else resolved_field.field.default

        if resolved_field.type is _messages.ErrorSummary:
            return value  # RPFIX-3: defer conversion to __post_init__

        # RPFIX-3: this doesn't handle special types like exception which require extra conversion

        if not resolved_field.metadata.conversion_func and isinstance(value, resolved_field.type):
            return value

        # RPFIX-3: this type checking doesn't validate types within containers (mapping, dict, etc.)

        help_text = f"Values for result key {resolved_field.result_key!r} must be of type {native_type_name(resolved_field.type)}."

        conversion_func = resolved_field.metadata.conversion_func or resolved_field.type

        try:
            result = conversion_func(value)
        except Exception:
            result = resolved_field.field.default
            display.warning(
                f'Value for result key {resolved_field.result_key!r} of type {native_type_name(value)} was replaced with {result}.',
                obj=value,
                help_text=help_text,
            )
        else:
            if not isinstance(value, resolved_field.type):
                # RPFIX-3: this will still give duplicate warnings -- probably just need to be explicit and have conversion funcs do warnings
                display.warning(
                    msg=f'Value for result key {resolved_field.result_key!r} of type {native_type_name(value)} '
                    f'was converted to {native_type_name(resolved_field.type)}.',
                    obj=value,
                    help_text=help_text,
                )

        return result

    def __post_init__(self) -> None:
        # Normalize the result `exception`, if any, to be a `CapturedErrorSummary` instance.
        # If a new `CapturedErrorSummary` was created, the `error_type` will be `cls`.
        # The `exception` key will be removed if falsey.
        # A `CapturedErrorSummary` instance will be returned if `failed` is truthy.
        if isinstance(self.exception, _captured.CapturedErrorSummary):
            self.is_module = self.exception.is_module
        elif isinstance(self.exception, _messages.ErrorSummary):
            self.exception = _captured.CapturedErrorSummary(
                event=self.exception.event,
                error_context=self._captured_error_context,
                error_message=self._captured_error_message,
                is_module=self.is_module,
            )
        elif self.failed or self.exception:
            # RPFIX-3: warn/deprecate if exception is present without failed being True (and do what with failed?)

            # translate non-ErrorSummary errors
            self.exception = _captured.CapturedErrorSummary(
                event=_messages.Event(
                    msg=str(self.msg or 'Unknown error.'),
                    formatted_traceback=_normalize_traceback(self.exception),
                ),
                error_context=self._captured_error_context,
                error_message=self._captured_error_message,
                is_module=self.is_module,
            )
        else:
            self.exception = None

    @classmethod
    def from_module_result_dict(cls, result: dict[str, object]) -> t.Self:
        # NOTE: dnf returns results ... but that made it 'compatible' with squashing, so we allow mappings, for now
        # RPFIX-3: evaluate whether we need to treat this specially going forward- ideally just adjust the dnf module to inject a deprecated copy of the value
        # and completely remove this, since `results` is no longer used as a special signaling key on individual task results
        if (results := result.get('results', ...)) is not ... and not isinstance(results, list) and not isinstance(results, str):
            result['ansible_module_results'] = results
            del result['results']
            display.warning("Found internal 'results' key in module return, renamed to 'ansible_module_results'.")

        return cls._from_result_dict(result, source_is_module=True)

    @staticmethod
    def create_from_module_exception(exception: BaseException, accept_result_contribution: bool = False) -> UnifiedTaskResult:
        """Return a failed task result dict from the given exception."""
        return UnifiedTaskResult._create_from_exception(exception, accept_result_contribution=accept_result_contribution, source_is_module=True)

    @staticmethod
    def _create_from_exception(exception: BaseException, *, accept_result_contribution: bool = False, source_is_module: bool) -> UnifiedTaskResult:
        """Return a failed task result dict from the given exception."""
        event = _error_factory.ControllerEventFactory.from_exception(exception, _traceback.is_traceback_enabled(_traceback.TracebackEvent.ERROR))

        omit_failed_key = False
        omit_exception_key = False
        utr = UnifiedTaskResult(is_module=source_is_module)

        if accept_result_contribution:
            while exception:
                if isinstance(exception, _error_utils.ContributesToTaskResult):
                    utr = exception.as_task_result(utr)
                    omit_failed_key = exception.omit_failed_key
                    omit_exception_key = exception.omit_exception_key
                    break

                exception = exception.__cause__

        if omit_failed_key:
            utr.failed = None
        else:
            utr.failed = True

        if omit_exception_key:
            utr.exception = None
        else:
            utr.exception = _messages.ErrorSummary(event=event)

        if not utr.msg:
            # if nothing contributed `msg`, generate one from the exception messages
            utr.msg = _event_utils.format_event_brief_message(event)

        return utr

    def finalize_warnings(self, warning_ctx: _display_utils.DeferredWarningContext) -> None:
        if warnings := warning_ctx.get_warnings():
            self.warnings = warnings

        if deprecation_warnings := warning_ctx.get_deprecation_warnings():
            self.deprecations = deprecation_warnings

    @property
    def _captured_error_message(self) -> str:
        return 'Module failed.' if self.is_module else 'Action failed.'

    @property
    def _captured_error_context(self) -> str:
        return 'target' if self.is_module else 'action'

    def set_fact(self, key: str, value: object) -> None:
        if self.ansible_facts is None:
            self.ansible_facts = {}

        self.ansible_facts[key] = value

    def remove_internal_keys(self):
        # cleanse fact values that are allowed from actions but not modules
        if self.ansible_facts:
            for key in list(self.ansible_facts):
                # RPFIX-3: move this into the control plane and inject into the results as needed
                if key.startswith('discovered_interpreter_') or key.startswith('ansible_discovered_interpreter_'):
                    self.ansible_facts.pop(key)

    @staticmethod
    def create_from_action_exception(exception: BaseException, accept_result_contribution: bool = False) -> UnifiedTaskResult:
        """Return a failed task result dict from the given exception."""
        utr = UnifiedTaskResult._create_from_exception(exception, accept_result_contribution=accept_result_contribution, source_is_module=False)

        TaskContext.current()._record_result(utr, allow_replace=True)

        return utr

    @classmethod
    @contextlib.contextmanager
    def create_and_record(cls, result: dict[str, t.Any] | None = None, allow_replace: bool = False) -> t.Generator[t.Self]:
        utr = cls.from_action_result_dict(result or {})
        yield utr
        TaskContext.current()._record_result(utr, allow_replace)

    def maybe_raise_on_result(self) -> None:
        """Raise an exception if the result indicated failure."""
        if self.exception and self.failed:  # even though error detail was normalized, only raise it if the result indicated failure
            raise _captured.AnsibleResultCapturedError(self.exception.event, self)

    def set_skipped(self, reason: str | None = None, include_skipped_reason: bool = False) -> None:
        self.changed = False
        self.skipped = True

        if reason:
            self.skip_reason = reason

        if include_skipped_reason:
            self.skipped_reason = deprecate_value(
                value=reason,
                msg="The 'skipped_reason' value is deprecated.",
                help_text="Use 'skip_reason' instead.",
                version="2.24",
            )

    @classmethod
    def record_conditional_false(cls, conditional_expression: object) -> t.Self:
        with cls.create_and_record() as utr:
            utr.set_skipped('Conditional result was False')
            utr.false_condition = conditional_expression

        return utr

    def set_changed_when_result_on_failure(self, value: Exception) -> None:
        # RPFIX-3: this is probably needed when resolving the error handling below
        # if self.exception:
        #     self.changed_when_suppressed_exception = self.exception
        #     self.exception = None

        self.failed = True
        self.changed_when_result = str(value)
        # RPFIX-3: this needs the same treatment as failed_when to preserve any exception detail from the task itself, add the right error message
        # and point at the failing conditional expression.

    def set_failed_when_result(self, value: Exception | bool) -> None:
        # RPFIX-3: document exception suppression logic in changelog et al; decide which parts should apply to changed_when
        #  The use of any failed_when expression will suppress the existing value of `exception` and store it in `failed_when_suppressed_exception`,
        #  regardless if the expression result is True, False, or an exception. For True and exception, the `exception` value in the task result becomes
        #  one generated (or caused) by failed_when.

        if self.exception:
            self.failed_when_suppressed_exception = self.exception
            self.exception = None

        result: object

        if isinstance(value, bool):
            result = value

            self.failed = value

            if value:
                event = _messages.Event(
                    msg="A 'failed_when' expression evaluated to 'True'.",
                    # RPFIX-5: We should be able to show more useful information here than just a static message,
                    #  including any shadowed msg we may have had and the (origin-tagged) failed_when expression itself.
                    # chain=None if self.msg is None else _messages.EventChain(
                    #     msg_reason='FIXME',
                    #     traceback_reason='FIXME',
                    #     event=_messages.Event(
                    #         msg=str(self.msg),
                    #         help_text="This was the original 'msg' before 'failed_when' was evaluated.",
                    #     ),
                    # ),
                )

                self.exception = _messages.ErrorSummary(
                    event=event,
                )
        else:
            result = str(value)

            self.failed = True
            self.exception = _messages.ErrorSummary(
                event=_error_factory.ControllerEventFactory.from_exception(value, _traceback.is_traceback_enabled(_traceback.TracebackEvent.ERROR)),
            )

        self.failed_when_result = result


def _normalize_traceback(value: object | None) -> str | None:
    """Normalize the provided traceback value, returning None if it is falsey."""
    if not value:
        return None

    value = str(value).rstrip()

    if not value:
        return None

    return value + '\n'


@t.final
@dataclasses.dataclass(frozen=True, kw_only=True, slots=True)
class WireTaskResult:
    host_name: str
    task_uuid: str
    task_fields: c.Mapping[str, object]
    utr: UnifiedTaskResult

    @classmethod
    def create(cls, host: Host, task: Task, utr: UnifiedTaskResult) -> t.Self:
        return cls(
            host_name=host.name,
            task_uuid=task._uuid,
            utr=utr,
            task_fields=task.dump_attrs(),
        )


@t.final
@dataclasses.dataclass(frozen=True, kw_only=True, slots=True)
class HostTaskResult:
    host: Host
    task: Task
    utr: UnifiedTaskResult
