"""Strace classes.

Classes implemented here represent values parsed from an strace file.
"""


# Imports
from __future__ import annotations
from collections import defaultdict
from contextlib import (
    AbstractContextManager, ContextDecorator, contextmanager, ExitStack,
)
from copy import copy
from dataclasses import dataclass
from pathlib import Path
from json import JSONEncoder
from typing import (
    runtime_checkable, Any, Generator, List, Optional, Protocol, Tuple, Union
)
import json
import hashlib
import pickle


from lib import logger
from lib.strace import util, parser

# Types
ParameterMapping = List[Tuple[Tuple[str, ...], Tuple[str, ...]]]


@dataclass(eq=True, frozen=True)
class MigrationResult:
    """The result of creating a migration of an strace.

    Attributes
    ----------
    source : Strace
        The source strace that is being migrated.
    target : Strace
        The target strace that was used as the definition for migration.
    mapping : ParameterMapping
        A parameter mapping between the source and target straces.
    migration : Strace
        The migration produced from ``strace`` based on ``target`` and
        ``mapping``. This differs from target in that the arguments for the
        migrated strace have been overwritten with the source strace based on
        the provided mapping.
    """

    source: Strace
    target: Strace
    mapping: ParameterMapping
    migration: Strace


def from_object(obj: Any) -> Strace:
    """Create an Strace from an object.

    This is really a convenience method to help with deserializing straces
    from sqlalchemy query results.

    Parameters
    ----------
    obj : Any
        Object used as the source for a new strace. This object must have one
        of the following attributes.
        - ``obj.pickle`` containing a pickled strace.
        - ``obj.json`` containing a JSON serialized strace.
        - ``obj.strace`` containing the raw text of an strace. In this case,
          the object may also provide each of the other strace attributes.

    Returns
    -------
    Strace
        Loaded strace instance.
    """
    # Construct strace object
    if hasattr(obj, 'pickle') and isinstance(obj.pickle, bytes):
        return pickle.loads(obj.pickle)
    elif hasattr(obj, 'json') and isinstance(obj.json, str):
        return json.loads(obj.json, object_hook=from_dict)
    elif hasattr(obj, 'strace') and isinstance(obj.strace, str):
        return parser.parse_string(
            obj.strace,
            system=getattr(obj, 'system', None),
            executable=getattr(obj, 'executable', None),
            arguments=getattr(obj, 'arguments', None),
            collector=getattr(obj, 'collector', None),
            collector_assigned_id=getattr(obj, 'collector_assigned_id', None),
            strace_file=getattr(obj, 'strace_file', None),
            metadata=getattr(obj, 'metadata', None),
            normalized=getattr(obj, 'normalized', False),
            truncated=getattr(obj, 'truncated', False),
        )
    else:
        raise ValueError('Object does not provide an strace.')


def from_dict(d: dict) -> Union[Strace, TraceLine, OmittedArguments, Literal,
                                LiteralValue, dict]:
    """Deserialize Strace objects from dict.

    The dict representation must be that generated by StraceJSONEncoder. If it
    is not, the input value is returned.

    Parameters
    ----------
    d : dict
        Dict to deserialize.

    Returns
    -------
    Strace, TraceLine, OmittedArguments, SyscallArgument, Value, dict
        Deserialized object, or dict if the object could not be deserialized.
    """
    if ('type' in d and 'value' in d and len(d) == 2
            and d['type'] in globals()
            and issubclass(
                globals()[d['type']],
                (Strace, TraceLine, OmittedArguments, Literal, LiteralValue)
            )):

        # Create a new object of the declared type without calling __init__
        o = object.__new__(globals()[d['type']])

        # Overwrite the object's writable attributes.
        o.__dict__ = d['value']

        # Return deserialized object
        return o

    else:

        return d


class StraceJSONEncoder(JSONEncoder):
    """Encoder for Strace and sub-objects."""

    def default(self, o: Any) -> Any:
        """Serialize a custom trace object, or defer to default.

        o : object
            Python object to transform to a JSON serializable format.
        """
        if isinstance(o, (Strace, TraceLine, OmittedArguments, Literal,
                          LiteralValue)):
            return {'type': type(o).__name__, 'value': o.__dict__}
        elif isinstance(o, Path):
            return str(o)
        else:
            return super().default(o)


class DictRepr:
    """Mixin providing __str__ and __repr__ based on __dict__,"""

    def __str__(self) -> str:
        """Return self as a string. This method delegates to __repr__.

        Returns
        -------
        str
            String representation of self.
        """
        return self.__repr__()

    def __repr__(self) -> str:
        """Return a representation of self.

        Representation will be in the format
        ``<self.__class__.__name__ self.__dict__>``.

        Returns
        -------
        str
            String representation of self.
        """
        return f'<{self.__class__.__name__} {self.__dict__}>'


class RestorableHelperMixin:
    """A helper mixin for common private methods of restorable classes."""

    # Dictionary of attributes pushed by checkpoint contexts.
    _methods = defaultdict(list)

    @classmethod
    def _replace(cls, name: str, value: Any):
        """Replace an attribute with a new value by name.

        If the class currently has an attribute ``name``, the current value
        is preserved in ``_methods[name]`` before the new value is set.

        Parameters
        ----------
        name : str
            Attribute name.
        value : Any
            New attribute value.
        """
        try:
            cls._methods[name].append(getattr(cls, name))
        except AttributeError:
            pass
        finally:
            setattr(cls, name, value)

    @classmethod
    def _restore(cls, name: str):
        """Restore the previous value of an attribute by name.

        If the class does not have a previous value for the attribute `name``,
        the attribute will be deleted.

        Parameters
        ----------
        name : str
            Attribute name.
        """
        try:
            setattr(cls, name, cls._methods[name].pop())
        except (KeyError, IndexError):
            try:
                delattr(cls, name)
            except AttributeError:
                pass


class RestorableAttributes(RestorableHelperMixin):
    """Instance attributes can be restored to a checkpoint."""

    @classmethod
    @contextmanager
    def checkpoint(cls) -> Generator[None, None, None]:
        """Crate a new restore checkpoint.

        When exiting the checkpoint, the state of all instances will be reset
        to their state at the start of the checkpoint.
        """
        # Create checkpoint
        checkpoint = []

        # Define custom methods
        def __setattr__(self, name, value):
            if hasattr(self, name):
                checkpoint.append((self, 'set', name, getattr(self, name)))
            else:
                checkpoint.append((self, 'add', name, None))
            return super(RestorableAttributes, self).__setattr__(name, value)

        def __delattr__(self, name):
            if hasattr(self, name):
                checkpoint.append((self, 'del', name, getattr(self, name)))
            return super(RestorableAttributes, self).__delattr__(name)

        # Replace methods
        RestorableAttributes._replace('__setattr__', __setattr__)
        RestorableAttributes._replace('__delattr__', __delattr__)

        # Yield and restore
        try:

            yield

        finally:

            # Restore methods
            RestorableAttributes._restore('__setattr__')
            RestorableAttributes._restore('__delattr__')

            # Process applied actions in reverse order
            for obj, action, name, value in reversed(checkpoint):

                # Get super proxy
                proxy = super(RestorableAttributes, obj)

                # Undo applied action
                if action == 'set':
                    proxy.__setattr__(name, value)
                elif action == 'add':
                    proxy.__delattr__(name)
                elif action == 'del':
                    proxy.__setattr__(name, value)


class RestorableMutableSequence(RestorableHelperMixin):
    """Sequence items can be restored to a checkpoint."""

    @classmethod
    @contextmanager
    def checkpoint(cls):
        """Crate a new restore checkpoint.

        When exiting the checkpoint, the state of all instances will be reset
        to their state at the start of the checkpoint.

        See collections.abc for documentation on collection methods.
        https://docs.python.org/3/library/collections.abc.html#module-collections.abc.
        """
        # Create checkpoint
        checkpoint = []

        # Define custom methods
        def __setitem__(self, name, value):
            try:
                checkpoint.append((self, 'set', name, self[name]))
            except (IndexError, KeyError):
                checkpoint.append((self, 'add', name, None))
            return super(RestorableMutableSequence, self).__setitem__(
                name, value
            )

        def __delitem__(self, name):
            try:
                checkpoint.append((self, 'del', name, self[name]))
                super(RestorableMutableSequence, self).__delitem__(name)
            except (IndexError, KeyError):
                pass

        def insert(self, name, value):
            checkpoint.append((self, 'insert', name, value))
            return super(RestorableMutableSequence, self).insert(name, value)

        def append(self, value):
            checkpoint.append((self, 'append', len(self), value))
            return super(RestorableMutableSequence, self).append(value)

        def reverse(self):
            checkpoint.append((self, 'reverse', None, None))
            return super(RestorableMutableSequence, self).reverse()

        def extend(self, value):
            checkpoint.append((self, 'extend', len(value), value))
            return super(RestorableMutableSequence, self).extend(value)

        def pop(self, name = None):
            if name is None:
                value = super(RestorableMutableSequence, self).pop()
            else:
                value = super(RestorableMutableSequence, self).pop(name)
            checkpoint.append((self, 'pop', name, value))
            return value

        def remove(self, value):
            name = None
            for idx, item in enumerate(self):
                if value == item:
                    name = idx
            res = super(RestorableMutableSequence, self).remove(value)
            checkpoint.append((self, 'remove', name, value))
            return res

        # Replace methods
        RestorableMutableSequence._replace('__setitem__', __setitem__)
        RestorableMutableSequence._replace('__delitem__', __delitem__)
        RestorableMutableSequence._replace('insert', insert)
        RestorableMutableSequence._replace('append', append)
        RestorableMutableSequence._replace('reverse', reverse)
        RestorableMutableSequence._replace('extend', extend)
        RestorableMutableSequence._replace('pop', pop)
        RestorableMutableSequence._replace('remove', remove)
        RestorableMutableSequence._replace('__iadd__', extend)

        # Yield and restore
        try:

            yield

        finally:

            # Restore old methods
            RestorableMutableSequence._restore('__setitem__')
            RestorableMutableSequence._restore('__delitem__')
            RestorableMutableSequence._restore('insert')
            RestorableMutableSequence._restore('append')
            RestorableMutableSequence._restore('reverse')
            RestorableMutableSequence._restore('extend')
            RestorableMutableSequence._restore('pop')
            RestorableMutableSequence._restore('remove')
            RestorableMutableSequence._restore('__iadd__')

            # Process each applied action in reverse
            for obj, action, name, value in reversed(checkpoint):

                # Get super proxy
                proxy = super(RestorableMutableSequence, obj)

                # Undo applied action
                if action == 'set':
                    proxy.__setitem__(name, value)
                elif action == 'add':
                    proxy.__delitem__(name)
                elif action == 'del':
                    proxy.insert(name, value)
                elif action == 'insert':
                    proxy.pop(name)
                elif action == 'append':
                    proxy.pop()
                elif action == 'reverse':
                    proxy.reverse()
                elif action == 'extend':
                    for i in range(len(value)):
                        proxy.pop()
                elif action == 'pop':
                    if name is None:
                        proxy.append(value)
                    else:
                        proxy.insert(name, value)
                elif action == 'remove':
                    proxy.insert(name, value)


class RestorableList(RestorableAttributes, RestorableMutableSequence, list):
    """A restorable list class."""
    pass


class RestoreCheckpoint(ContextDecorator, AbstractContextManager):
    """Create a context manager for all restorable types."""

    def __init__(self):
        """Create a new checkpoint."""
        self.exit_stack = ExitStack()

    def __enter__(self):
        """Enter context."""
        self.exit_stack.__enter__()
        self.exit_stack.enter_context(RestorableAttributes.checkpoint())
        self.exit_stack.enter_context(RestorableMutableSequence.checkpoint())

    def __exit__(self, *args):
        """Exit context."""
        return self.exit_stack.__exit__(*args)


class Strace(RestorableAttributes, DictRepr):
    """Strace object.

    A top-level strace object corresponds to an strace output file and contains
    all parsed trace lines. If normalized, the strace object combines system
    calls that are split into unfinished and resumed portions.
    """

    def __init__(self,
                 trace_lines: List[TraceLine],
                 system: Optional[str] = None,
                 executable: Optional[str] = None,
                 arguments: Optional[Union[list, dict]] = None,
                 collector: Optional[str] = None,
                 collector_assigned_id: Optional[str] = None,
                 strace_file: Optional[Path] = None,
                 metadata: dict = {},
                 normalized: bool = False,
                 truncated: bool = False):
        """Create a new strace.

        Parameters
        ----------
        trace_lines : list[TraceLine]
            All trace lines contained within the strace.
        system : str
            Name of the system that the traced executable comes from (linux,
            ansible, etc.).
        executable : str
            Name of the traced executable.
        arguments : Union[list, dict]
            Arguments passed to the traced executable.
        collector : str
            Name of the collector responsible for generating strace.
        collector_assigned_id : str
            Collector specific identifier for the strace.
        strace_file : Optional[Path]
            Path to the parsed strace file.
        metadata : dict
            Any other metadata about the executable or tracing process that
            the collector can provide.
        normalized : bool
            Whether or not the trace lines (and by extension the strace) have
            been normalized. Defaults to False.
        truncated : bool
            Whether or not the strace has been truncated.
        """
        self.trace_lines = RestorableList(trace_lines)
        self.system = system
        self.executable = executable
        self.arguments = arguments
        self.collector = collector
        self.collector_assigned_id = collector_assigned_id
        self.strace_file = strace_file
        self.metadata = metadata
        self.normalized = normalized
        self.truncated = truncated

    @property
    def executable_repr(self) -> str:
        """Representation of strace executable and arguments."""
        return f'{self.executable} {self.arguments}'

    @property
    def executable_key(self):
        """Unique key for the executable."""
        return (
            self.system,
            self.executable,
            str(util.hashable_arguments_representation(self.arguments))
        )

    @property
    def full_key(self):
        """Full identifying key for the strace."""
        return (
            self.collector, *self.executable_key, self.collector_assigned_id
        )

    @property
    def arguments_hash(self) -> bytes:
        """SHA1 hash of the strace executable arguments.

        Returns
        -------
        bytes
            Hash bytes.
        """
        return (
            hashlib
            .sha1(repr(self.arguments).encode('utf-8'))
            .digest()
        )

    def normalize(self) -> Strace:
        """Normalize the strace.

        An strace is normalized if all of its unfinished and resumed syscalls
        have been recombined into a single traceline.

        Returns
        -------
        Strace
            Self for chaining.
        """
        # If already normalized, just return self.
        if self.normalized:
            return self

        # Search through the tracelines and merge all matching unfinished and
        # resumed syscalls
        num_lines = len(self.trace_lines)
        i = 0
        while i < num_lines:

            # Get current line
            line = self.trace_lines[i]

            # Skip if not a syscall
            if not isinstance(line,  Syscall):
                i += 1
                continue

            # Error on unmatched resume
            if line.resumed:
                raise Exception(
                    'Encountered resumed syscall before unfinished syscall'
                )

            # Skip if not unfinished
            if not line.unfinished:
                i += 1
                continue

            # We've found an unfinished syscall. Search the remaining
            # tracelines for a matching finished syscall and merge the two.
            merged = False
            for j in range(i + 1, num_lines):
                other_line = self.trace_lines[j]
                if (isinstance(other_line, Syscall)
                        and other_line.resumed
                        and other_line.pid == line.pid
                        and other_line.name == line.name):
                    self.trace_lines[i] = line.merge_left(other_line)
                    del self.trace_lines[j]
                    num_lines -= 1
                    i += 1
                    merged = True
                    break

            # No resumed syscall was found.
            if not merged:

                # If the strace is not truncated, this must be an error.
                # If the strace is truncated, assume that the resume is missing
                # because it got truncated. Drop the unfinished syscall, since
                # an incomplete definition cannot be used for some forms of
                # syscall equality comparison.
                if not self.truncated:
                    raise Exception(
                        'Encountered unfinished syscall, but did not find a '
                        'matching resumed syscall.'
                    )
                else:
                    del self.trace_lines[i]
                    num_lines -= 1

        # Set normalized and return self for chaining
        self.normalized = True
        return self

    def __hash__(self) -> int:
        """Hash an Strace.

        An Strace hash is computed by converting the normalized tracelines
        to a tuple, then using the default tuple hashing algorithm.

        Returns
        -------
        int
            Strace hash.
        """
        return hash(tuple(self.normalize().trace_lines))

    def __eq__(self, other: Any) -> bool:
        """Determine equality between self and another object.

        True if the other object is also an Strace with the same normalized
        tracelines.

        Parameters
        ----------
        other : Any
            Object to compare with.

        Returns
        -------
        bool
            True if other is an equivalent Strace.

        """
        if not isinstance(other, Strace):
            return NotImplemented
        return self.normalize().trace_lines == other.normalize().trace_lines

    def get_argument(self, keys: Tuple[str, ...]) -> Any:
        """Get an executable argument value by key.

        Parameters
        ----------
        keys : Tuple[str, ...]
            One or more nested keys that resolve to a final argument value.

        Returns
        -------
        Any
            Resolved argument value.
        """
        arguments = self.arguments
        for key in keys:
            arguments = arguments[key]
        return arguments

    def set_argument(self, keys: Tuple[str, ...], value: Any):
        """Set an executable argument value by key.

        Parameters
        ----------
        keys : Tuple[str, ...]
            One or more nested keys that resolve to a final argument value.
        value : Any
            New argument value.
        """
        arguments = self.arguments
        *keys, final = keys
        for key in keys:
            arguments = arguments[key]
        arguments[final] = value

    # TODO Separate executable and strace concerns?
    def migrate(self, s: Strace, mapping: ParameterMapping) -> MigrationResult:
        """Migrate the strace executable based on another definition.

        This method will only migrate ``system``, ``executable``, and
        ``arguments``. No other fields are set.

        Parameters
        ----------
        s : Strace
            Second strace used as the definition to migrate to.
        mapping : ParameterMapping
            Mapping determining how to translate parameters.

        Returns
        -------
        MigrationResult
            A migration result containing a new Strace object based on ``s``,
            but with parameters migrated from ``self`` based on the provided
            parameter mapping.
        """
        migration = Strace(
            system=s.system,
            executable=s.executable,
            arguments=copy(s.arguments),
            trace_lines=[]
        )
        for self_keys, s_keys in mapping:
            migration.set_argument(s_keys, self.get_argument(self_keys))
        return MigrationResult(
            source=self,
            target=s,
            mapping=mapping,
            migration=migration
        )


class TraceLine(RestorableAttributes, DictRepr):
    """An strace traceline object."""

    def __init__(self, *args, pid: Optional[int] = None, **kwargs):
        """Create a new traceline.

        Parameters
        ----------
        pid: int
            Process identifier.
        """
        self.pid = pid

    def __hash__(self):
        """Hash a traceline.

        Raises
        ------
        NotImplementedError
            This method must be implemented by a subclass.
        """
        raise NotImplementedError(
            f'Method must be implemented by subclass: '
            f'{self.__class__.__name__}'
        )

    def __eq__(self, other: Any):
        """Determine trace line equality.

        Parameters
        ----------
        other : Any
            Object to compare with.

        Raises
        ------
        NotImplementedError
            This method must be implemented by a subclass.
        """
        raise NotImplementedError(
            f'Method must be implemented by subclass: '
            f'{self.__class__.__name__}'
        )


class Syscall(TraceLine):
    """An strace syscall object."""

    def __init__(self,
                 name: str,
                 arguments: List[Union[OmittedArguments, Literal]],
                 *args,
                 resumed: bool = False,
                 unfinished: bool = False,
                 pid: Optional[int] = None,
                 exit_code: Optional[int] = None,
                 exit_notes: Optional[str] = None,
                 **kwargs):
        """Initialize a new syscall.

        Parameters
        ----------
        name : str
            Syscall name.
        arguments : list[SyscallArgument]
            Syscall arguments.
        resumed : bool
            Whether or not the syscall is resumed.
        unfinished : bool
            Whether or not the syscall is unfinished.
        pid : int
            Process identifier.
        exit_code : int
            Syscall exit code.
        exit_notes : str
            Any additional exit notes.
        """
        super().__init__(*args, pid=pid, **kwargs)

        self.name = name
        self.arguments = RestorableList(arguments)
        self.unfinished = unfinished
        self.resumed = resumed

        self.exit_code = exit_code
        self.exit_notes = exit_notes

    def merge_left(self, other: Syscall) -> Syscall:
        """Merge a resumed syscall into self.

        This must be a matching unfinished syscall made by the same process
        in order to merge. The resulting syscall with have the same pid and
        name, combined arguments, and the resumed syscall exit code and notes.

        Parameters
        ----------
        other: Syscall
            Resumed syscall to be merged.
        """
        if (not self.unfinished
                or not other.resumed
                or self.pid != other.pid
                or self.name != other.name):
            raise Exception('Cannot merge syscalls.')

        # Get arguments
        arguments = self.arguments + other.arguments
        if other.arguments and isinstance(other.arguments[0], Mapping):
            if not self.arguments:
                raise Exception(
                    'Incomplete mapping found on resume, but no arguments '
                    'exist in the unfinished syscall.'
                )
            mapping = arguments[len(self.arguments)]
            source = arguments.pop(len(self.arguments) - 1)
            mapping.source = source

        return Syscall(
            pid=self.pid,
            name=self.name,
            arguments=self.arguments + other.arguments,
            exit_code=other.exit_code,
            exit_notes=other.exit_notes,
        )

    def strict_equals_hash(self) -> int:
        """Hash a syscall.

        The strict equality hash is computed by converting to a tuple of name
        and arguments, then using the default tuple hashing algorithm. It is
        implemented such that any syscalls that are strictly equal hash the
        same.

        Returns
        -------
        int
            Syscall hash.
        """
        return hash((self.name, *self.arguments))

    def __hash__(self) -> int:
        """Hash a syscall.

        The default Syscall hash is to use self.strict_equals_hash.

        Returns
        -------
        int
            Syscall hash.
        """
        return self.strict_equals_hash()

    def strict_equals(self, other: Any) -> bool:
        """Determine equality between self and another object.

        True if the other object is also a syscall with the same name and
        arguments.

        Parameters
        ----------
        other : Any
            Object to compare with.

        Returns
        -------
        bool
            True if other is an equivalent syscall.
        """
        # Make no determinations if the other object is not also a syscall.
        # Returning NotImplemented lets the interpreter try the reflected
        # operation, if available. Maybe the other thing can figure it out,
        # who knows.
        if not isinstance(other, Syscall):
            return NotImplemented

        if self.name != other.name:
            return False

        # TODO Will have to handle omitted arguments somehow?
        return self.arguments == other.arguments

    def __eq__(self, other) -> bool:
        """Determine equality between self and another object.

        The default Syscall equality is strict equality.

        Parameters
        ----------
        other : Any
            Object to compare with.

        Returns
        -------
        bool
            True if other is an equivalent syscall.
        """
        return self.strict_equals(other)


class OmittedArguments(RestorableAttributes, DictRepr):
    """Represents missing or omitted arguments in a syscall."""

    def __hash__(self) -> int:
        """Hash an argument.

        Computed as the hash of the class.

        Returns
        -------
        int
            Argument hash.
        """
        return hash(OmittedArguments)

    def __eq__(self, other: Any) -> bool:
        """Determine equality between self and another object.

        Parameters
        ----------
        other : Any
            Object to compare with.

        Returns
        -------
        bool
            True if other is also an OmittedArguments object.
        """
        return isinstance(other, OmittedArguments)


class Literal(RestorableAttributes, DictRepr):
    """A literal with a value and optional identifier."""

    def __init__(self,
                 value: LiteralValue,
                 identifier: Optional[str] = None):
        """Initialize a new literal argument.

        Parameters
        ----------
        value : LiteralValue
            Argument value.
        identifier : str
            Optional argument identifier.
        """
        self.identifier = identifier
        self.value = value

    def _tuple(self) -> Tuple[str, LiteralValue]:
        """Return self as a tuple (identifier, value).

        Returns
        -------
        tuple[str, LiteralValue]
            Tuple representation of self.
        """
        return self.identifier, self.value

    def __hash__(self) -> int:
        """Hash a literal.

        Computed by converting to a tuple and hashing using the default tuple
        hashing algorithm.

        Returns
        -------
        int
            Hash value.
        """
        return hash(self._tuple())

    def __eq__(self, other: Any) -> bool:
        """Determine equality between self and another object.

        Parameters
        ----------
        other : Any
            Object to compare with.

        Returns
        -------
        bool
            True if other is an equivalent argument.
        """
        if not isinstance(other, Literal):
            return NotImplemented
        return self._tuple() == other._tuple()


class LiteralValue(RestorableAttributes, DictRepr):
    """Any sort of value representation."""
    pass


class Hole(LiteralValue):
    """A hole is a blank for literal values that don't matter.

    No literal value should parse as a hole. However, literals whose
    value do not matter to the overall comparison of syscalls may have their
    value replaced with a hole. For example, many syscalls accept memory
    addresses that are dependent only on runtime factors. I.E. two identical
    runs of the same process will result in traces with effectively the same
    syscalls but different memory addresses. These addresses may be replaced
    with a hole.

    All holes are equivalent to all other holes.
    """

    # Common value for all holes.
    value = 'Hole'

    def __hash__(self) -> int:
        """Hash a hole.

        Computed as the hash of the class.

        Returns
        ------
        int
            Argument hash.
        """
        return hash(Hole)

    def __eq__(self, other: Any) -> bool:
        """Determine equality between self and another object.

        Parameters
        ----------
        other : Any
            Object to compare with.

        Returns
        -------
        bool
            True if other is also a hole object.
        """
        return isinstance(other, Hole)


@runtime_checkable
class SyntheticValueTemplate(Protocol):
    """Template class for a synthetic value.

    A class that conforms to the template protocol must have a format method
    that accepts the executable parameter value and returns a formatted
    final value.
    """

    def format(self, value: Union[str, int]) -> Union[str, int]:
        """Format the template with a value.

        Parameters
        ----------
        value : Union[str, int]
            Format value.

        Returns
        -------
        Union[str, int]
            Formatted result.
        """


class SyntheticIntTemplate:
    """Template class for synthetic values replacing ints.

    This template returns an executable parameter value as an int.
    """

    def format(self, value: Union[str, int]) -> int:
        """Format an executable parameter.

        Parameters
        ----------
        value : Union[str, int]
            Executable parameter value. This may be a string (in the case of
            the default value or mapped to a string), or an int (when
            formatting an integer executable parameter).

        Returns
        -------
        int
            Int formatted value.
        """
        if value is ExecutableParameter.default_value:
            return 0
        elif isinstance(value, str):
            return hash(value)
        else:
            return int(value)


class SyntheticValue(LiteralValue):
    """A synthetic value is a generated value that may change.

    Synthetic values wrap executable parameters.

    No syscall argument value should parse as a synthetic value. However,
    arguments whose value matters with respect to the overall comparison of
    syscalls and whose value we may want to change for comparison may have
    their value replaced with a synthetic value.

    For example, some executables take user provided arguments such as
    filenames. Filenames matter in the comparison of syscalls such as read,
    write, etc. But, two runs of the same executable like ``cat <filename>``
    should likely compare similarly. Synthetic values can be used to achieve
    this by mapping one filename to another during comparison.

    The final representation of a synthetic value that is used for comparison
    and hashing is based on the internal executable parameter and optional
    template string. Comparison and hashing of synthetic values is affected by
    the ``compare_by_id`` and ``compare_by_map`` contexts of executable
    parameters.

    By default, all executable parameters, and therefore synthetic values,
    compare and hash as equal. In this way they behave a bit like hole values.
    """

    def __init__(self,
                 executable_parameter: ExecutableParameter,
                 template: SyntheticValueTemplate,
                 original_value: LiteralValue):
        """Initialize a new synthetic value.

        Parameters
        ----------
        executable_parameter : ExecutableParameter
            The executable parameter this synthetic value is expected to wrap.
        template : Optional[str]
            An optional template string that defines how to format the
            ``value`` property. If provided, format string will be formatted
            with the executable parameter value.
        original_value : Union[str, int]
            The original (primitive) value that the synthetic value is
            replacing.
        """
        self.executable_parameter = executable_parameter
        self.template = template
        self.original_value = original_value

    @property
    def value(self):
        """Generate the synthetic value representation.

        Returns
        -------
        str
            The formatted synthetic value representation. If a template string
            was provided, the template string will be formatted by passing the
            executable parameter value. If no template string was provided,
            the synthetic value representation is exactly that of the
            executable parameter.
        """
        try:
            return self.template.format(self.executable_parameter.value)
        except Exception:
            logger.exception(
                f'IndexError on formatting a synthetic value.\n'
                f'Template:\n'
                f'```\n'
                f'{self.template}\n'
                f'```\n'
                f'Executable Parameter: {self.executable_parameter}'
            )
            raise

    def __hash__(self) -> int:
        """Hash a synthetic value.

        Returns
        -------
        int
            Hash of the ``value`` representation.
        """
        return hash(self.value)

    def __eq__(self, other: Any) -> bool:
        """Determine equality between self and other.

        Parameters
        ----------
        other : Any
            Another object to compare to.

        Returns
        -------
        bool
            True if other is a synthetic value and ``self.value`` equals
            ``other.value``.
        """
        if not isinstance(other, SyntheticValue):
            return NotImplemented
        return self.value == other.value


class ExecutableParameter(DictRepr):
    """An executable parameter is a user provided value that can be mapped.

    Note that, by default, all executable parameters compare and hash equal.
    Activating the ``compare_by_id`` or ``compare_by_map`` contexts changes
    the way that parameters compare.
    """

    # Common default value
    default_value = '<ExecutableParameter>'

    @classmethod
    @contextmanager
    def compare_by_id(cls) -> Generator[None, None, None]:
        """Make executable parameters compare by object id.

        This has the effect of making all executable parameters appear unique.
        """
        # Save original methods
        cls_hash = cls.__hash__
        cls_eq = cls.__eq__
        cls_value = cls.value

        @property
        def value(self: ExecutableParameter) -> str:
            return str(id(self))

        # Replace methods
        cls.__hash__ = object.__hash__
        cls.__eq__ = object.__eq__
        cls.value = value

        # Yield and restore
        try:
            yield
        finally:
            cls.__hash__ = cls_hash
            cls.__eq__ = cls_eq
            cls.value = cls_value

    @classmethod
    @contextmanager
    def compare_by_map(cls) -> Generator[None, None, None]:
        """Make mapped executable parameters compare as equal.

        A mapped executable parameter will only compare as equal to its map.
        All executable parameters that do not have a map will continue to
        compare as equal. A mapped executable parameter and an unmapped
        executable parameter will not compare as equal.
        """
        def __hash__(self: ExecutableParameter) -> int:
            # All unmapped executable parameters should compare the same.
            if self._mapped_executable_parameter is None:
                return hash(ExecutableParameter)

            # Hash the minimum id of self or the mapped executable parameter
            # This ensures that both executable parameters hash to the
            # same value.
            return hash(min(id(self), id(self._mapped_executable_parameter)))

        def __eq__(self: ExecutableParameter, other: Any) -> bool:
            # Return not implemented if other is not an executable parameter
            if not isinstance(other, ExecutableParameter):
                return NotImplemented

            # True if neither is mapped
            if (self._mapped_executable_parameter is None
                    and other._mapped_executable_parameter is None):
                return True

            # Return whether or not the compared object is self or the mapped
            # executable parameter object.
            return other is self or other is self._mapped_executable_parameter

        @property
        def value(self: ExecutableParameter) -> str:
            if self._mapped_executable_parameter is None:
                return ExecutableParameter.default_value
            v, _ = sorted((self, self._mapped_executable_parameter), key=id)
            return v.parameter_value

        # Save original methods
        cls_hash = cls.__hash__
        cls_eq = cls.__eq__
        cls_value = cls.value

        # Replace with new methods
        cls.__hash__ = __hash__
        cls.__eq__ = __eq__
        cls.value = value

        # Yield and restore
        try:
            yield
        finally:
            cls.__hash__ = cls_hash
            cls.__eq__ = cls_eq
            cls.value = cls_value

    @classmethod
    @contextmanager
    def compare_equal(cls) -> Generator[None, None, None]:
        """Make all executable parameters compare equal."""
        def __hash__(self) -> int:
            return hash(ExecutableParameter)

        def __eq__(self, other) -> bool:
            if not isinstance(other, ExecutableParameter):
                return NotImplemented
            return True

        @property
        def value(self):
            return ExecutableParameter.default_value

        # Save original methods
        cls_hash = cls.__hash__
        cls_eq = cls.__eq__
        cls_value = cls.value

        # Replace with new methods
        cls.__hash__ = __hash__
        cls.__eq__ = __eq__
        cls.value = value

        # Yield and restore
        try:
            yield
        finally:
            cls.__hash__ = cls_hash
            cls.__eq__ = cls_eq
            cls.value = cls_value

    @classmethod
    def map_values(cls, v1: ExecutableParameter, v2: ExecutableParameter):
        """Map two executable parameters to each other.

        Parameters
        ----------
        v1 : ExecutableParameter
            First executable parameter.
        v2 : ExecutableParameter
            Second executable parameter.
        """
        for v in (v1, v2):
            if v._mapped_executable_parameter:
                cls.unmap_values(v, v._mapped_executable_parameter)
        v1._mapped_executable_parameter = v2
        v2._mapped_executable_parameter = v1

    @classmethod
    def unmap_values(cls, v1: ExecutableParameter, v2: ExecutableParameter):
        """Unmap two executable parameters.

        Parameters
        ----------
        v1 : ExecutableParameter
            First executable parameter.
        v2 : ExecutableParameter
            Second executable parameter.

        Raises
        ------
        Exception
            Raised when v1 and v2 are not mapped.
        """
        if (v1._mapped_executable_parameter is not v2
                or v2._mapped_executable_parameter is not v1):
            raise Exception(
                'Cannot unmap parameters that are not already mapped.'
            )
        v1._mapped_executable_parameter = None
        v2._mapped_executable_parameter = None

    @classmethod
    def get_parameters(cls, s: Strace) -> List[ExecutableParameter]:
        """Get executable parameters for an strace.

        Parameters
        ----------
        s : Strace
            Strace to produce executable parameters for.

        Returns
        -------
        List[ExecutableParameter]
            List of executable parameters.

        Raises
        ------
        ValueError
            Raised if the strace arguments is not a list or dict.
        """
        # Process arguments
        if isinstance(s.arguments, list):
            return cls.get_parameters_list(s)
        elif isinstance(s.arguments, dict):
            return cls.get_parameters_dict(s)

        # Raise exception if arguments is not a supported type
        raise ValueError(
            f'Received unexpected arguments type: {type(s.arguments)}'
        )

    @classmethod
    def get_parameters_list(cls, s: Strace) -> List[ExecutableParameter]:
        """Get executable parameters for an arguments list.

        Parameters
        ----------
        s : Strace
            Strace to produce executable parameters for.

        Returns
        -------
        List[ExecutableParameter]
            List of executable parameters.
        """
        # Start list of parameters
        parameters = []

        # Create executable parameters from the strace executable arguments
        for idx, arg in enumerate(s.arguments):

            # If argument starts with a `-`, treat it as a flag.
            if arg.startswith('-'):

                # Try to split based on `--name=value` syntax first.
                # Then try to split based on `--name value` syntax.
                # If a name and value cannot be extracted, skip this argument.
                # This can happen for flags that don't take values.
                try:
                    name, value = arg.split('=', 1)
                except ValueError:
                    try:
                        name, value = arg.split(' ', 1)
                    except ValueError:
                        continue

            # Treat the argument as a positional argument.
            else:

                name = None
                value = arg

            # Append final executable parameter
            parameters.append(ExecutableParameter(
                key=(idx,),
                parameter_name=name,
                parameter_value=value,
            ))

        # Return all generated executable parameters
        return parameters

    @classmethod
    def get_parameters_dict(cls, s: Strace) -> List[ExecutableParameter]:
        """Get executable parameters for an arguments dict.

        Parameters
        ----------
        s : Strace
            Strace to produce executable parameters for.

        Returns
        -------
        List[ExecutableParameter]
            List of executable parameters.
        """
        # Start list of parameters
        parameters = []

        # Start a dictionary of path/node pairs for lookup in the arguments
        # tree.
        nodes = {(k,): v for k, v in s.arguments.items()}

        # Keep going until everything has been processed
        while nodes:

            # Pop and unpack any of the argument nodes
            path, node = nodes.popitem()

            # If node is a list of items, then create a new node for every
            # index of the list.
            if isinstance(node, list):
                for idx, value in enumerate(node):
                    nodes[path + (idx,)] = value

            # If node is a dict of items, then create a new node for every
            # key/value pair of the dict.
            if isinstance(node, dict):
                for key, value in node.items():
                    nodes[path + (key,)] = value

            # If node is a string value, create an executable parameter
            if isinstance(node, str):
                parameters.append(ExecutableParameter(
                    key=path,
                    parameter_name=None,
                    parameter_value=node,
                ))

        # Return all generated executable parameters
        return parameters

    def __init__(self,
                 key: Tuple[Union[int, str], ...],
                 parameter_value: str,
                 parameter_name: Optional[str] = None):
        """Initialize a new executable parameter.

        Parameters
        ----------
        key : Tuple[Union[int, str], ...]
            Sequence of keys to look up the original parameter. For list valued
            executable arguments, this is a one tuple containing an integer
            index. For JSON/dict valued executable arguments, this is a tuple
            of integer or string valued keys for lookup in that datastructure.
        parameter_value : str
            The parameter value that this executable parameter is replacing.
        parameter_name : Optional[str]
            An optional name for the value being replaced. This is separate
            from the argument key, which is purely for lookup in the arguments
            datastructure. Not all arguments are named. Examples of named
            arguments include Linux command line flags (--option=...).
        """
        self.key = key
        self.parameter_value = parameter_value
        self.parameter_name = parameter_name
        self._mapped_executable_parameter = None

    def __hash__(self) -> int:
        """Hash an executable parameter.

        Computed by hashing the class value.

        Returns
        -------
        int
            Hash value.
        """
        return hash(ExecutableParameter)

    def __eq__(self, other: Any) -> bool:
        """Determine equality between self and another object.

        Parameters
        ----------
        other : Any
            Object to compare to.

        Returns
        -------
        bool
            True if other is an executable parameter.
        """
        if not isinstance(other, ExecutableParameter):
            return NotImplemented

        return True

    @property
    def value(self):
        """Get the literal value."""
        return ExecutableParameter.default_value


class Mapping(LiteralValue):
    """A mapping is a link between two other literal values."""

    def __init__(self, source: Optional[Literal], destination: Literal):
        """Construct a new mapping.

        Parameters
        ----------
        source : Optional[Literal]
            Source literal of the mapping. This is optional and may be none
            if the mapping is split between an unfinished and resumed syscall.
        destination : Literal
            Destination literal of the mapping.
        """
        self.source = source
        self.destination = destination

    def __hash__(self) -> int:
        """Hash a mapping.

        Returns
        -------
        int
            Hash value.
        """
        return hash((self.source, self.destination))

    def __eq__(self, other: Any) -> bool:
        """Determine equality between self and another object.

        Parameters
        ----------
        other : Any
            Object to compare to.

        Returns
        -------
        bool
            True if other is an equivalent mapping.
        """
        if not isinstance(other, Mapping):
            return NotImplemented
        return (self.source == other.source
                and self.destination == other.destination)


class Identifier(LiteralValue):
    """An identifier used as a value."""

    def __init__(self, value: str):
        """Initialize a new identifier.

        Parameters
        ----------
        value : str
            Identifier name.
        """
        self.value = value

    def __hash__(self) -> int:
        """Hash an identifier.

        Computed as the hash of the identifier name.

        Returns
        -------
        int
            Identifier hash.
        """
        return hash(self.value)

    def __eq__(self, other: Any) -> bool:
        """Determine equality between self and another object.

        Parameters
        ----------
        other : Any
            Object to compare to.

        Returns
        -------
        bool
            True if other is an equivalent identifier.
        """
        if not isinstance(other, Identifier):
            return NotImplemented
        return self.value == other.value


class PrimitiveLiteral(LiteralValue):
    """A literal value representing a primitive type."""

    def __init__(self, value: Any):
        """Initialize a new primitive literal.

        Parameters
        ----------
        value: Any
            Primitive value.
        """
        self.value = value

    def __hash__(self) -> int:
        """Hash a primitive literal.

        Hash is computed as the hash of the literal value.

        Returns
        -------
        int
            Literal hash.
        """
        return hash(self.value)

    def __eq__(self, other: Any) -> bool:
        """Determine equality between self and another object.

        Two primitives compare the same if their values are the same.

        Parameters
        ----------
        other: Any to compare to.

        Returns
        -------
        bool
            True if other is an equivalent literal.
        """
        if not isinstance(other, PrimitiveLiteral):
            return NotImplemented
        return self.value == other.value


class StringLiteral(PrimitiveLiteral):
    """A string valued primitive literal."""
    pass


class NullLiteral(PrimitiveLiteral):
    """A null valued primitive literal"""
    pass


class NumberLiteral(PrimitiveLiteral):
    """A number valued primitive literal."""
    pass


class FileDescriptor(LiteralValue):
    """A file descriptor."""

    def __init__(self, number: int):
        """Initialize a new file descriptor.

        Parameters
        ----------
        number : int
            The file descriptor number.
        """
        self.number = number

    def _tuple(self) -> Tuple[int]:
        """Represent self as a tuple.

        Returns
        -------
        Tuple[int]
            Tuple representation of the file descriptor.
        """
        return self.number,

    def __hash__(self) -> int:
        """Hash a file descriptor..

        Returns
        -------
        int
            Literal hash.
        """
        return hash(self._tuple())

    def __eq__(self, other: Any) -> bool:
        """Determine equality between self and another object.

        Parameters
        ----------
        other: Any to compare to.

        Returns
        -------
        bool
            True if other is an equivalent file descriptor..
        """
        if not isinstance(other, FileDescriptor):
            return NotImplemented
        return self._tuple() == other._tuple()

    @property
    def value(self) -> Any:
        """A value representation of the file descriptor.

        Returns
        -------
        str
            File descriptor value.
        """
        # File descriptors 0-2 correspond to stdin, stdout, and stderr.
        # /dev/pts/0 is the first pseudo-terminal (pty). This is the common
        # pty for stdin, stdout, and stderr of programs traced using
        # Dozer collectors.
        #
        # If a file descriptor with a number of 0-2 and a path of /dev/pts/0
        # is used, this is assumed to to be a reference to stdin, stdout, or
        # stderr. We avoid using the path here so that we can distinguish
        # between the three streams.
        #
        # Note that this method isn't perfect. For example, Ansible redirects
        # child process stdout and stderr to a pipe so the parent process can
        # collect the output. This causes the child process file descriptors
        # to be pipes, not /dev/pts/0. It's ambiguous as to whether they should
        # compare equal to file descriptors in the equivalent Linux program.
        # The child is using its standard streams, but overall the process is
        # doing something different.
        #
        # This could be supported in the future with some preprocessing if
        # needed. The requirement would be to only do the transformation when
        # using the inherited stream established before the first call to
        # execve.
        if getattr(self, 'path', None) == '/dev/pts/0':
            if self.number == 0:
                return '<stdin>'
            if self.number == 1:
                return '<stdout>'
            if self.number == 2:
                return '<stderr>'

        # Note about file descriptor paths.
        #
        # Ansible uses an implicit assumption about the current working
        # directory. With the standard ssh connection, cwd is the user home
        # directory. With a local connection, cwd is just the process cwd.
        # This allows playbooks to specify relative paths. File descriptors are
        # always reported as absolute paths by strace. This can cause a
        # mismatch between reported file descriptors and reported relative
        # string file paths. This might primarily affect paths specified as
        # user parameters.
        #
        # If this becomes a problem that adversely impacts comparison, it can
        # be worked around by normalizing all paths to be absolute. This would
        # require knowing the cwd, including the gotcha that cwd may change
        # based on syscalls like chdir in addition to having an implicit value
        # at the start. See pathtrace.c line 166 from the Strace v4.26 source
        # for an implementation that includes getting file paths from all
        # syscalls that take path arguments.

        # Except for the stdin/stdout/stderr case, return the file descriptor
        # tuple value. If the tuple has no items, return None. If it has one
        # item, unpack it to return a bare value. Otherwise return the full
        # tuple.
        val = self._tuple()
        if len(val) == 0:
            return None
        elif len(val) == 1:
            return val[0]
        else:
            return val


class PathFileDescriptor(FileDescriptor):
    """A file descriptor with an associated file path."""

    def __init__(self, number: int, path: str):
        """Initialize a new path file descriptor.

        Parameters
        ----------
        number : int
            The file descriptor number.
        path : str
            The associated file path.
        """
        super().__init__(number)
        self.path = path

    def _tuple(self) -> Tuple[str]:
        """Represent self as a tuple.

        Returns
        -------
        Tuple[str]
            Tuple representation of the file descriptor.
        """
        return self.path,


class DeviceFileDescriptor(PathFileDescriptor):
    """A file descriptor with an associated device."""

    def __init__(self, number: int, path: str, device_type: str, major: int,
                 minor: int):
        """Initialize a new path file descriptor.

        Parameters
        ----------
        number : int
            The file descriptor number.
        path : str
            The associated file path.
        device_type : str
            Device type (block, char).
        major : int
            Major device number.
        minor : int
            Minor device number.
        """
        super().__init__(number, path)
        self.type = device_type
        self.major = major
        self.minor = minor

    def _tuple(self) -> Tuple[str, str, int, int]:
        """Represent self as a tuple.

        Returns
        -------
        Tuple[str, str, int, int]
            Tuple representation of the file descriptor.
        """
        return self.path, self.type, self.major, self.minor


class InodeFileDescriptor(FileDescriptor):
    """A file descriptor with an associated inode."""

    def __init__(self, number: int, protocol: str, inode: int,
                 reference: Optional[int] = None,
                 bind: Optional[str] = None):
        """Initialize a new inode file descriptor.

        number : int
            The file descriptor number.
        protocol : str
            Socket protocol or general type of the file descriptor.
        inode : int
            Associated inode.
        reference : Optional[int]
            Referenced inode.
        bind : Optional[str]
            Bound filename.
        """
        super().__init__(number)
        self.protocol = protocol
        self.inode = inode
        self.reference = reference
        self.bind = bind

    def _tuple(self) -> Union[Tuple[str], Tuple[str, str]]:
        """Represent self as a tuple.

        Returns
        -------
        Union[Tuple[str], Tuple[str, str]]
            Tuple representation of the file descriptor.
        """
        if self.bind:
            return self.protocol, self.bind
        else:
            return self.protocol,


class NetlinkSubprotocolFileDescriptor(FileDescriptor):
    """A file descriptor for a netlink subprotocol."""

    def __init__(self, number: int, protocol: str, subprotocol: str, pid: int):
        """Initialize a new netlink file descriptor.

        number : int
            The file descriptor number.
        protocol : str
            Socket protocol or general type of the file descriptor.
        subprotocol : str
            Subprotocol.
        pid : int
            Process id used for netlink.
        """
        super().__init__(number)
        self.protocol = protocol
        self.subprotocol = subprotocol
        self.pid = pid

    def _tuple(self) -> Tuple[str, str]:
        """Represent self as a tuple.

        Returns
        -------
        Tuple[str, str]
            Tuple representation of the file descriptor.
        """
        return self.protocol, self.subprotocol


class IPFileDescriptor(FileDescriptor):
    """A file descriptor with an associated ip address."""

    def __init__(self, number: int, protocol: str, destination: str,
                 source: Optional[str] = None):
        """Initialize a new ip file descriptor.

        number : int
            The file descriptor number.
        protocol : str
            Socket protocol or general type of the file descriptor.
        destination : str
            Destination ip address.
        source : Optional[str]
            Source ip address.
        """
        super().__init__(number)
        self.protocol = protocol
        self.destination = destination
        self.source = source

    def _tuple(self) -> Tuple[str, str, str]:
        """Represent self as a tuple.

        Returns
        -------
        Tuple[str, str, int]
            Tuple representation of the file descriptor.
        """
        return self.protocol, self.source, self.destination


class BooleanExpression(LiteralValue):
    """A boolean expression."""

    def __init__(self, value: str):
        """Initialize a boolean expression.

        Parameters
        ----------
        value : str
            Boolean expression string.
        """
        self.value = value

    def __hash__(self) -> int:
        """Hash a boolean expression.

        Hash is computed as a hash of the expression string.

        Returns
        -------
        int
            Hash of the boolean expression.
        """
        return hash(self.value)

    def __eq__(self, other: Any) -> bool:
        """Determine equality between self and another object.

        Comparison is performed based on expresion text.

        Parameters
        ----------
        other: Any
            Object to compare to.

        Returns
        -------
        bool
            True if other is an equivalent boolean expression.
        """
        if not isinstance(other, BooleanExpression):
            return NotImplemented
        return self.value == other.value


class NumericExpression(LiteralValue):
    """A numeric expression."""

    def __init__(self, value: int):
        """Initialize a new numeric expression.

        Parameters
        ----------
        value: str
            Expression string.
        """
        self.value = value

    def __hash__(self) -> int:
        """Hash a numeric expression.

        Hash is computed as the hash of the expression string.

        Returns
        -------
        int
            Expression hash.
        """
        return hash(self.value)

    def __eq__(self, other: Any) -> bool:
        """Determine equality between self and another object.

        Comparison is based on expression text.

        Parameters
        ----------
        other:  object
            Object to compare to.

        Returns
        -------
        bool
            True if other is an equivalent numeric expression.
        """
        if not isinstance(other, NumericExpression):
            return NotImplemented
        return self.value == other.value


class Collection(LiteralValue):
    """A sequence of literal values."""

    def __init__(self, items: List[Literal]):
        """Initialize a new collection.

        Parameters
        ----------
        items: list[Literal]
            List items.
        """
        self.items = RestorableList(items)

    def _tuple(self) -> Tuple[Literal, ...]:
        """Express self as a tuple.

        Returns
        -------
        tuple[Literal]
            Returns items as a tuple.
        """
        return tuple(self.items)

    def __hash__(self) -> int:
        """Hash collection.

        Hash is computed by converting to a tuple and using the default
        tuple hashing algorithm.

        Returns
        -------
        int
            Literal hash.
        """
        return hash(self._tuple())

    def __eq__(self, other: Any) -> bool:
        """Determine equality between self and another object.

        Equality is checked by comparing item sequences.

        Parameters
        ----------
        other : Any
            Object for comparison.

        Returns
        -------
        bool
            True if other is equivalent.
        """
        if not isinstance(other, Collection):
            return NotImplemented
        return self._tuple() == other._tuple()


class FunctionCall(LiteralValue):
    """A function call literal."""

    def __init__(self, identifier: str, arguments: List[Literal]):
        """Initialize a new function call literal value.

        Parameters
        ----------
        identifier : str
            Function identifier.
        arguments : List[Literal]
            Function arguments.
        """
        self.identifier = identifier
        self.arguments = arguments

    def _tuple(self):
        """Represent function call as a tuple.

        Returns
        -------
        tuple
            Tuple where the first element is the function identifier and
            the following elements are the function arguments.
        """
        return self.identifier, *self.arguments

    def __hash__(self) -> int:
        """Hash function call.

        Hash is computed by converting to a tuple and using the default
        tuple hashing algorithm.

        Returns
        -------
        int
            Function call hash.
        """
        return hash(self._tuple())

    def __eq__(self, other: Any) -> bool:
        """Determine equality between self and another object.

        Parameters
        ----------
        other : Any
            Object for comparison.

        Returns
        -------
        bool
            True if other is equivalent.
        """
        if not isinstance(other, FunctionCall):
            return NotImplemented
        return self._tuple() == other._tuple()


class Signal(TraceLine):
    """An strace signal object."""

    def __init__(self, identifier: str, info: str, *args, **kwargs):
        """Initialize a new signal.

        Parameters
        ----------
        identifier : str
            Signal identifier.
        info : str
            Signal information.
        """
        super().__init__(*args, **kwargs)
        self.identifier = identifier
        self.info = info

    def __hash__(self) -> int:
        """Hash a signal.

        Hash is computed by converting the identifier and info to a tuple, then
        delegating to the default tuple algorithm.

        Returns
        -------
        int
            Signal hash.
        """
        return hash((self.identifier, self.info))

    def __eq__(self, other: Any) -> bool:
        """Determine equality between self and another object.

        Parameters
        ----------
        other : object
            Object to compare self to.

        Returns
        -------
        bool
            True if self is equivalent to other.
        """
        if not isinstance(other, Signal):
            return NotImplemented
        return self.identifier == other.identifier and self.info == other.info


class ExitStatement(TraceLine):
    """An strace exit statement object."""

    def __init__(self,
                 exit_code: Union[str, int],
                 killed: bool = False,
                 *args,
                 **kwargs):
        """Initialize a new exit statement.

        Parameters
        ----------
        exit_code : int
            Program exit code.
        """
        super().__init__(*args, **kwargs)
        self.killed = killed
        self.exit_code = exit_code

    def __hash__(self) -> int:
        """Hash an exit statement.

        Hash is computed based on the exit code.

        Returns
        -------
        int
            Exit statement hash.
        """
        return hash(self.exit_code)

    def __eq__(self, other: Any) -> bool:
        """Determine equality between self and another object.

        Equality is based on the exit statement exit code.

        Parameters
        ----------
        other : object
            Object to compare self with.

        Returns
        -------
        bool
            True of self is equivalent to other.
        """
        if not isinstance(other, ExitStatement):
            return NotImplemented
        return self.exit_code == other.exit_code
