"""
Transformers
------------

Transformers defines the mutations that can be applied. The ``CATEGORIES`` dictionary lists all
valid category codes that are valid filters. The primary classes are:

1. ``LocIndex``
2. ``MutateAST``

The ``LocIndex`` is a location index within a given Abstract Syntax Tree (AST) that can be mutated.
The ``MutateAST`` class walks the AST of a given source file to identify all of the locations,
and optionally create the mutation at that node. These are implemented in the ``Genome`` object.

``MutateAST`` is constructed from ``MutateBase`` and the appropriate mixin class - either
``ConstantMixin`` for Python 3.8, or ``NameConstantMixin`` for Python 3.7.
"""
import ast
import logging
import sys

from pathlib import Path

####################################################################################################
# AST TRANSFORMERS
####################################################################################################
from typing import Any, Dict, List, NamedTuple, Optional, Set, Type, Union


try:
    # Python 3.8
    from typing import Protocol
except ImportError:
    # Python 3.7
    from typing_extensions import Protocol  # type: ignore


LOGGER = logging.getLogger(__name__)

CATEGORIES = {
    "AugAssign": "aa",
    "BinOp": "bn",
    "BinOpBC": "bc",
    "BinOpBS": "bs",
    "BoolOp": "bl",
    "Compare": "cp",
    "CompareIn": "cn",
    "CompareIs": "cs",
    "If": "if",
    "Index": "ix",
    "NameConstant": "nc",
    "SliceUS": "su",
}

####################################################################################################
# CORE TYPES
####################################################################################################


class LocIndex(NamedTuple):
    """Location index within AST to mark mutation targets.

    The ``end_lineno`` and ``end_col_offset`` properties are set to ``None`` by default as they
    are only used distinctly in Python 3.8.
    """

    ast_class: str
    lineno: int
    col_offset: int
    op_type: Any  # varies based on the visit_Node definition in MutateAST

    # New in Python 3.8 AST: https://docs.python.org/3/whatsnew/3.8.html#improved-modules
    # These values are always set to None if running Python 3.7.
    # The NodeSpan class is used to manage setting the values
    end_lineno: Optional[int] = None
    end_col_offset: Optional[int] = None


class MutationOpSet(NamedTuple):
    """Container for compatible mutation operations. Also used in the CLI display."""

    name: str
    desc: str
    operations: Set[Any]
    category: str


class LocIndexNode(Protocol):
    """Type protocol for AST Nodes that include lineno and col_offset properties."""

    lineno: int
    col_offset: int


class NodeSpan(NamedTuple):
    """Node span to support Py3.7 and 3.8 compatibility for locations.
    This is used to generate the set the values in the LocIndex as a general class.
    """

    node: LocIndexNode

    @property
    def lineno(self) -> int:
        """Line number for the node."""
        return self.node.lineno

    @property
    def col_offset(self) -> int:
        """Col offset for the node."""
        return self.node.col_offset

    @property
    def end_lineno(self) -> Optional[int]:
        """End line no: Python 3.8 will have this defined, in Python 3.7 it will be None."""
        eline: Optional[int] = getattr(self.node, "end_lineno", None)
        return eline

    @property
    def end_col_offset(self) -> Optional[int]:
        """End col offset: Python 3.8 will have this defined, in Python 3.7 it will be None."""
        ecol: Optional[int] = getattr(self.node, "end_col_offset", None)
        return ecol


####################################################################################################
# MUTATE AST Definitions
# Includes MutateBase and Mixins for 3.7 and 3.8 AST support
# MutateAST is constructed from Base + Mixins depending on sys.version_info
####################################################################################################


class MutateBase(ast.NodeTransformer):
    """AST NodeTransformer to replace nodes with mutations by visits."""

    def __init__(
        self,
        target_idx: Optional[LocIndex] = None,
        mutation: Optional[Any] = None,
        readonly: bool = False,
        src_file: Optional[Union[Path, str]] = None,
    ) -> None:
        """Create the AST node transformer for mutations.

        If readonly is set to True then no transformations are applied;
        however, the locs attribute is updated with the locations of nodes that could
        be transformed. This allows the class to function both as an inspection method
        and as a mutation transformer.

        Note that different nodes handle the ``LocIndex`` differently based on the context. For
        example, ``visit_BinOp`` uses direct AST types, while ``visit_NameConstant`` uses values,
        and ``visit_AugAssign`` uses custom strings in a dictionary mapping.

        All ``visit_`` methods take the ``node`` as an argument and rely on the class properties.

        This MutateBase class is designed to be implemented with the appropriate Mixin Class
        for supporting either Python 3.7 or Python 3.8 ASTs. If the base class is used
        directly certain operations - like ``visit_If`` and ``visit_NameConstant`` will not
        work as intended..

        Args:
            target_idx: Location index for the mutatest in the AST
            mutation: the mutatest to apply, may be a type or a value
            readonly: flag for read-only operations, used to visit nodes instead of transform
            src_file: Source file name, used for logging purposes
        """
        self.locs: Set[LocIndex] = set()

        # managed via @property
        self._target_idx = target_idx
        self._mutation = mutation
        self._readonly = readonly
        self._src_file = src_file

    @property
    def target_idx(self) -> Optional[LocIndex]:
        """Location index for the mutation in the AST"""
        return self._target_idx

    @property
    def mutation(self) -> Optional[Any]:
        """The mutation to apply, may be a type or a value"""
        return self._mutation

    @property
    def readonly(self) -> bool:
        """A flag for read-only operations, used to visit nodes instead of transform"""
        return self._readonly

    @property
    def src_file(self) -> Optional[Union[Path, str]]:
        """Source file name, used for logging purposes"""
        return self._src_file

    @property
    def constant_type(self) -> Union[Type[ast.NameConstant], Type[ast.Constant]]:
        """Overridden using the MixinClasses for NameConstant(3.7) vs. Constant(3.8)."""
        raise NotImplementedError

    def visit_AugAssign(self, node: ast.AugAssign) -> ast.AST:
        """AugAssign is ``-=, +=, /=, *=`` for augmented assignment."""
        self.generic_visit(node)
        log_header = f"visit_AugAssign: {self.src_file}:"

        # custom mapping of string keys to ast operations that can be used
        # in the nodes since these overlap with BinOp types
        aug_mappings = {
            "AugAssign_Add": ast.Add,
            "AugAssign_Sub": ast.Sub,
            "AugAssign_Mult": ast.Mult,
            "AugAssign_Div": ast.Div,
        }

        rev_mappings = {v: k for k, v in aug_mappings.items()}
        idx_op = rev_mappings.get(type(node.op), None)

        # edge case protection in case the mapping isn't known for substitution
        # in that instance, return the node and take no action
        if not idx_op:
            LOGGER.debug(
                "%s (%s, %s): unknown aug_assignment: %s",
                log_header,
                node.lineno,
                node.col_offset,
                type(node.op),
            )
            return node

        node_span = NodeSpan(node)
        idx = LocIndex(
            ast_class="AugAssign",
            lineno=node_span.lineno,
            col_offset=node_span.col_offset,
            op_type=idx_op,
            end_lineno=node_span.end_lineno,
            end_col_offset=node_span.end_col_offset,
        )

        self.locs.add(idx)

        if idx == self.target_idx and self.mutation in aug_mappings and not self.readonly:
            LOGGER.debug("%s mutating idx: %s with %s", log_header, self.target_idx, self.mutation)
            return ast.copy_location(
                ast.AugAssign(
                    target=node.target,
                    op=aug_mappings[self.mutation](),  # awkward syntax to call type from mapping
                    value=node.value,
                ),
                node,
            )

        LOGGER.debug("%s (%s, %s): no mutations applied.", log_header, node.lineno, node.col_offset)
        return node

    def visit_BinOp(self, node: ast.BinOp) -> ast.AST:
        """BinOp nodes are bit-shifts and general operators like add, divide, etc."""
        self.generic_visit(node)
        log_header = f"visit_BinOp: {self.src_file}:"

        # default case for this node, can be BinOpBC or BinOpBS
        ast_class = "BinOp"
        op_type = type(node.op)

        # binop_bit_cmp_types: Set[type] = {ast.BitAnd, ast.BitOr, ast.BitXor}
        if op_type in {ast.BitAnd, ast.BitOr, ast.BitXor}:
            ast_class = "BinOpBC"

        # binop_bit_shift_types: Set[type] = {ast.LShift, ast.RShift}
        if op_type in {ast.LShift, ast.RShift}:
            ast_class = "BinOpBS"

        node_span = NodeSpan(node)
        idx = LocIndex(
            ast_class=ast_class,
            lineno=node_span.lineno,
            col_offset=node_span.col_offset,
            op_type=op_type,
            end_lineno=node_span.end_lineno,
            end_col_offset=node_span.end_col_offset,
        )

        self.locs.add(idx)

        if idx == self.target_idx and self.mutation and not self.readonly:
            LOGGER.debug("%s mutating idx: %s with %s", log_header, self.target_idx, self.mutation)
            return ast.copy_location(
                ast.BinOp(left=node.left, op=self.mutation(), right=node.right), node
            )

        LOGGER.debug("%s (%s, %s): no mutations applied.", log_header, node.lineno, node.col_offset)
        return node

    def visit_BoolOp(self, node: ast.BoolOp) -> ast.AST:
        """Boolean operations, AND/OR."""
        self.generic_visit(node)
        log_header = f"visit_BoolOp: {self.src_file}:"

        node_span = NodeSpan(node)
        idx = LocIndex(
            ast_class="BoolOp",
            lineno=node_span.lineno,
            col_offset=node_span.col_offset,
            op_type=type(node.op),
            end_lineno=node_span.end_lineno,
            end_col_offset=node_span.end_col_offset,
        )
        self.locs.add(idx)

        if idx == self.target_idx and self.mutation and not self.readonly:
            LOGGER.debug("%s mutating idx: %s with %s", log_header, self.target_idx, self.mutation)
            return ast.copy_location(ast.BoolOp(op=self.mutation(), values=node.values), node)

        LOGGER.debug("%s (%s, %s): no mutations applied.", log_header, node.lineno, node.col_offset)
        return node

    def visit_Compare(self, node: ast.Compare) -> ast.AST:
        """Compare nodes are ``==, >=, is, in`` etc. There are multiple Compare categories."""
        self.generic_visit(node)
        log_header = f"visit_Compare: {self.src_file}:"

        # taking only the first operation in the compare node
        # in basic testing, things like (a==b)==1 still end up with lists of 1,
        # but since the AST docs specify a list of operations this seems safer.
        # idx = LocIndex("CompareIs", node.lineno, node.col_offset, type(node.ops[0]))

        cmpop_is_types: Set[type] = {ast.Is, ast.IsNot}
        cmpop_in_types: Set[type] = {ast.In, ast.NotIn}
        op_type = type(node.ops[0])
        node_span = NodeSpan(node)

        locidx_kwargs = {
            "lineno": node_span.lineno,
            "col_offset": node_span.col_offset,
            "op_type": op_type,
            "end_lineno": node_span.end_lineno,
            "end_col_offset": node_span.end_col_offset,
        }

        if op_type in cmpop_is_types:
            idx = LocIndex(ast_class="CompareIs", **locidx_kwargs)  # type: ignore
        elif op_type in cmpop_in_types:
            idx = LocIndex(ast_class="CompareIn", **locidx_kwargs)  # type: ignore
        else:
            idx = LocIndex(ast_class="Compare", **locidx_kwargs)  # type: ignore

        self.locs.add(idx)

        if idx == self.target_idx and self.mutation and not self.readonly:
            LOGGER.debug("%s mutating idx: %s with %s", log_header, self.target_idx, self.mutation)

            # TODO: Determine when/how this case would actually be called
            if len(node.ops) > 1:
                # unlikely test case where the comparison has multiple values
                LOGGER.debug("%s multiple compare ops in node, len: %s", log_header, len(node.ops))
                existing_ops = [i for i in node.ops]
                mutation_ops = [self.mutation()] + existing_ops[1:]

                return ast.copy_location(
                    ast.Compare(left=node.left, ops=mutation_ops, comparators=node.comparators),
                    node,
                )

            else:
                # typical comparison case, will also catch (a==b)==1 as an example.
                LOGGER.debug("%s single comparison node operation", log_header)
                return ast.copy_location(
                    ast.Compare(
                        left=node.left, ops=[self.mutation()], comparators=node.comparators
                    ),
                    node,
                )

        LOGGER.debug("%s (%s, %s): no mutations applied.", log_header, node.lineno, node.col_offset)
        return node

    def visit_If(self, node: ast.If) -> ast.AST:
        """If statements e.g. If ``x == y`` is transformed to ``if True`` and ``if False``.

        This visit method only works when the appropriate Mixin is used.
        """
        self.generic_visit(node)
        log_header = f"visit_If: {self.src_file}:"

        # default for a comparison is "If_Statement" which will be changed to True/False
        # If_Statement is not set as a mutation target, controlled in get_mutations function
        if_type = "If_Statement"

        # Py 3.7 vs 3.8 - 3.7 uses NameConstant, 3.8 uses Constant
        if_mutations = {
            "If_True": self.constant_type(value=True),
            "If_False": self.constant_type(value=False),
        }
        if type(node.test) == self.constant_type:
            if_type: str = f"If_{bool(node.test.value)}"  # type: ignore

        node_span = NodeSpan(node)
        idx = LocIndex(
            ast_class="If",
            lineno=node_span.lineno,
            col_offset=node_span.col_offset,
            op_type=if_type,
            end_lineno=node_span.end_lineno,
            end_col_offset=node_span.end_col_offset,
        )
        self.locs.add(idx)

        if idx == self.target_idx and self.mutation and not self.readonly:
            LOGGER.debug("%s mutating idx: %s with %s", log_header, self.target_idx, self.mutation)
            return ast.fix_missing_locations(
                ast.copy_location(
                    ast.If(test=if_mutations[self.mutation], body=node.body, orelse=node.orelse),
                    node,
                )
            )

        LOGGER.debug("%s (%s, %s): no mutations applied.", log_header, node.lineno, node.col_offset)
        return node

    def visit_Index(self, node: ast.Index) -> ast.AST:
        """Index visit e.g. ``i[0], i[0][1]``."""
        self.generic_visit(node)
        log_header = f"visit_Index: {self.src_file}:"

        # Index Node has a value attribute that can be either Num node or UnaryOp node
        # depending on whether the value is positive or negative.
        n_value = node.value
        idx = None

        index_mutations = {
            "Index_NumZero": ast.Num(n=0),
            "Index_NumPos": ast.Num(n=1),
            "Index_NumNeg": ast.UnaryOp(op=ast.USub(), operand=ast.Num(n=1)),
        }

        node_span = NodeSpan(n_value)
        locidx_kwargs = {
            "ast_class": "Index",
            "lineno": node_span.lineno,
            "col_offset": node_span.col_offset,
            "end_lineno": node_span.end_lineno,
            "end_col_offset": node_span.end_col_offset,
        }

        # index is a non-negative number e.g. i[0], i[1]
        if isinstance(n_value, ast.Num):
            # positive integer case
            if n_value.n != 0:
                idx = LocIndex(op_type="Index_NumPos", **locidx_kwargs)  # type: ignore
                self.locs.add(idx)

            # zero value case
            else:
                idx = LocIndex(op_type="Index_NumZero", **locidx_kwargs)  # type: ignore
                self.locs.add(idx)

        # index is a negative number e.g. i[-1]
        if isinstance(n_value, ast.UnaryOp):
            idx = LocIndex(op_type="Index_NumNeg", **locidx_kwargs)  # type: ignore
            self.locs.add(idx)

        if idx == self.target_idx and self.mutation and not self.readonly:
            LOGGER.debug("%s mutating idx: %s with %s", log_header, self.target_idx, self.mutation)
            mutation = index_mutations[self.mutation]

            # uses AST.fix_missing_locations since the values of ast.Num and  ast.UnaryOp also need
            # lineno and col-offset values. This is a recursive fix.
            return ast.fix_missing_locations(ast.copy_location(ast.Index(value=mutation), node))

        LOGGER.debug(
            "%s (%s, %s): no mutations applied.", log_header, n_value.lineno, n_value.col_offset
        )
        return node

    def mixin_NameConstant(self, node: Union[ast.NameConstant, ast.Constant]) -> ast.AST:
        """Constants: ``True, False, None``.

        This method is called by using the Mixin classes for handling the difference of
        ast.NameConstant (Py 3.7) an ast.Constant (Py 3.8).
        """
        self.generic_visit(node)
        log_header = f"visit_NameConstant: {self.src_file}:"

        node_span = NodeSpan(node)
        idx = LocIndex(
            ast_class="NameConstant",
            lineno=node_span.lineno,
            col_offset=node_span.col_offset,
            op_type=node.value,
            end_lineno=node_span.end_lineno,
            end_col_offset=node_span.end_col_offset,
        )
        self.locs.add(idx)

        if idx == self.target_idx and not self.readonly:
            LOGGER.debug("%s mutating idx: %s with %s", log_header, self.target_idx, self.mutation)
            return ast.copy_location(self.constant_type(value=self.mutation), node)

        LOGGER.debug("%s (%s, %s): no mutations applied.", log_header, node.lineno, node.col_offset)
        return node

    def visit_Subscript(self, node: ast.Subscript) -> ast.AST:
        """Subscript slice operations e.g., ``x[1:]`` or ``y[::2]``."""
        self.generic_visit(node)
        log_header = f"visit_Subscript: {self.src_file}:"
        idx = None

        # Subscripts have slice properties with col/lineno, slice itself does not have line/col
        # Index is also a valid Subscript slice property
        slice = node.slice
        if not isinstance(slice, ast.Slice):
            LOGGER.debug("%s (%s, %s): not a slice node.", log_header, node.lineno, node.col_offset)
            return node

        # Built "on the fly" based on the various conditions for operation types
        # The RangeChange options are added in the later if/else cases
        slice_mutations: Dict[str, ast.Slice] = {
            "Slice_UnboundUpper": ast.Slice(lower=slice.upper, upper=None, step=slice.step),
            "Slice_UnboundLower": ast.Slice(lower=None, upper=slice.lower, step=slice.step),
            "Slice_Unbounded": ast.Slice(lower=None, upper=None, step=slice.step),
        }

        node_span = NodeSpan(node)
        locidx_kwargs = {
            "lineno": node_span.lineno,
            "col_offset": node_span.col_offset,
            "end_lineno": node_span.end_lineno,
            "end_col_offset": node_span.end_col_offset,
        }

        # Unbounded Swap Operation
        # upper slice range e.g. x[:2] will become x[2:]
        if slice.lower is None and slice.upper is not None:
            idx = LocIndex(
                ast_class="SliceUS", op_type="Slice_UnboundLower", **locidx_kwargs  # type: ignore
            )
            self.locs.add(idx)

        # lower slice range e.g. x[1:] will become x[:1]
        if slice.upper is None and slice.lower is not None:
            idx = LocIndex(
                ast_class="SliceUS", op_type="Slice_UnboundUpper", **locidx_kwargs  # type: ignore
            )
            self.locs.add(idx)

        # Apply Mutation
        if idx == self.target_idx and not self.readonly:
            LOGGER.debug("%s mutating idx: %s with %s", log_header, self.target_idx, self.mutation)

            mutation = slice_mutations[str(self.mutation)]
            # uses AST.fix_missing_locations since the values of ast.Num and  ast.UnaryOp also need
            # lineno and col-offset values. This is a recursive fix.
            return ast.fix_missing_locations(
                ast.copy_location(
                    ast.Subscript(value=node.value, slice=mutation, ctx=node.ctx), node
                )
            )

        LOGGER.debug("%s (%s, %s): no mutations applied.", log_header, node.lineno, node.col_offset)
        return node


class NameConstantMixin:
    """Mixin for Python 3.7 AST applied to MutateBase."""

    @property
    def constant_type(self) -> Type[ast.NameConstant]:
        return ast.NameConstant

    def visit_NameConstant(self, node: ast.NameConstant) -> ast.AST:
        """NameConstants: ``True, False, None``."""
        return self.mixin_NameConstant(node)  # type: ignore


class ConstantMixin:
    """Mixin for Python 3.8 AST applied to MutateBase."""

    @property
    def constant_type(self) -> Type[ast.Constant]:
        return ast.Constant

    def visit_Constant(self, node: ast.Constant) -> ast.AST:
        """Constants: https://bugs.python.org/issue32892
            NameConstant: ``True, False, None``.
            Num: isinstance(int, float)
            Str: isinstance(str)
        """
        # NameConstant behavior consistent with Python 3.7
        if isinstance(node.value, bool) or node.value is None:
            return self.mixin_NameConstant(node)  # type: ignore

        return node


# PYTHON 3.7
if sys.version_info < (3, 8):

    class MutateAST(NameConstantMixin, MutateBase):
        """Implementation of the MutateAST class based on running environment."""

        pass


# PYTHON 3.8
else:

    class MutateAST(ConstantMixin, MutateBase):
        """Implementation of the MutateAST class based on running environment."""

        pass


####################################################################################################
# TRANSFORMER FUNCTIONS
####################################################################################################


def get_compatible_operation_sets() -> List[MutationOpSet]:
    """Utility function to return a list of compatible AST mutations with names.

    All of the mutation transformation sets that are supported by mutatest are defined here.
    See: https://docs.python.org/3/library/ast.html#abstract-grammar

    This is used to create the search space in finding mutations for a target, and
    also to list the support operations in the CLI help function.

    Returns:
        List of ``MutationOpSets`` that have substitutable operations
    """

    # AST operations that are sensible mutations for each other
    binop_types: Set[type] = {ast.Add, ast.Sub, ast.Div, ast.Mult, ast.Pow, ast.Mod, ast.FloorDiv}
    binop_bit_cmp_types: Set[type] = {ast.BitAnd, ast.BitOr, ast.BitXor}
    binop_bit_shift_types: Set[type] = {ast.LShift, ast.RShift}
    cmpop_types: Set[type] = {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE}
    cmpop_is_types: Set[type] = {ast.Is, ast.IsNot}
    cmpop_in_types: Set[type] = {ast.In, ast.NotIn}
    boolop_types: Set[type] = {ast.And, ast.Or}

    # Python built-in constants (singletons) that can be used with NameConstant AST node
    named_const_singletons: Set[Union[bool, None]] = {True, False, None}

    # Custom augmentation ops to differentiate from bin_op types
    # these are defined for substitution within the visit_AugAssign node and need to match
    aug_assigns: Set[str] = {"AugAssign_Add", "AugAssign_Sub", "AugAssign_Mult", "AugAssign_Div"}

    # Custom references for substitutions of zero, positive, and negative iterable indicies
    index_types: Set[str] = {"Index_NumPos", "Index_NumNeg", "Index_NumZero"}

    # Custom references for If statement substitutions
    # only If_True and If_False will be applied as mutations
    if_types: Set[str] = {"If_True", "If_False", "If_Statement"}

    # Custom references for subscript substitutions for slice mutations
    slice_bounded_types: Set[str] = {"Slice_UnboundUpper", "Slice_UnboundLower", "Slice_Unbounded"}

    return [
        MutationOpSet(
            name="AugAssign",
            desc="Augmented assignment e.g. += -= /= *=",
            operations=aug_assigns,
            category=CATEGORIES["AugAssign"],
        ),
        MutationOpSet(
            name="BinOp",
            desc="Binary operations e.g. + - * / %",
            operations=binop_types,
            category=CATEGORIES["BinOp"],
        ),
        MutationOpSet(
            name="BinOp Bit Comparison",
            desc="Bitwise comparison operations e.g. x & y, x | y, x ^ y",
            operations=binop_bit_cmp_types,
            category=CATEGORIES["BinOpBC"],
        ),
        MutationOpSet(
            name="BinOp Bit Shifts",
            desc="Bitwise shift operations e.g. << >>",
            operations=binop_bit_shift_types,
            category=CATEGORIES["BinOpBS"],
        ),
        MutationOpSet(
            name="BoolOp",
            desc="Boolean operations e.g. and or",
            operations=boolop_types,
            category=CATEGORIES["BoolOp"],
        ),
        MutationOpSet(
            name="Compare",
            desc="Comparison operations e.g. == >= <= > <",
            operations=cmpop_types,
            category=CATEGORIES["Compare"],
        ),
        MutationOpSet(
            name="Compare In",
            desc="Compare membership e.g. in, not in",
            operations=cmpop_in_types,
            category=CATEGORIES["CompareIn"],
        ),
        MutationOpSet(
            name="Compare Is",
            desc="Comapre identity e.g. is, is not",
            operations=cmpop_is_types,
            category=CATEGORIES["CompareIs"],
        ),
        MutationOpSet(
            name="If",
            desc="If statement tests e.g. original statement, True, False",
            operations=if_types,
            category=CATEGORIES["If"],
        ),
        MutationOpSet(
            name="Index",
            desc="Index values for iterables e.g. i[-1], i[0], i[0][1]",
            operations=index_types,
            category=CATEGORIES["Index"],
        ),
        MutationOpSet(
            name="NameConstant",
            desc="Named constant mutations e.g. True, False, None",
            operations=named_const_singletons,
            category=CATEGORIES["NameConstant"],
        ),
        MutationOpSet(
            name="Slice Unbounded Swap",
            desc=(
                "Slice mutations to swap lower/upper values, x[2:] (unbound upper) to x[:2],"
                " (unbound lower). Steps are not changed."
            ),
            operations=slice_bounded_types,
            category=CATEGORIES["SliceUS"],
        ),
    ]


def get_mutations_for_target(target: LocIndex) -> Set[Any]:
    """Given a target, find all the mutations that could apply from the AST definitions.

    Args:
        target: the location index target

    Returns:
        Set of types that can mutated into the target location.
    """
    search_space: List[Set[Any]] = [m.operations for m in get_compatible_operation_sets()]
    mutation_ops: Set[Any] = set()

    for potential_ops in search_space:
        if target.op_type in potential_ops:
            LOGGER.debug("Potential mutatest operations found for target: %s", target.op_type)
            mutation_ops = potential_ops.copy()
            mutation_ops.remove(target.op_type)

            # Special case for If_Statement since that is a default to transform to True or False
            # but not a validation mutation target by itself
            if "If_Statement" in mutation_ops:
                mutation_ops.remove("If_Statement")

            break

    return mutation_ops