import sys
import base64
import binascii
import pathlib
import webbrowser
import logging
import inspect
import io
import itertools
import subprocess
from configparser import ConfigParser
from urllib.parse import urljoin
from pprint import pformat
from typing import Any, Tuple, List, Union

import pyperclip
import ujson
import jsonpickle
import regex as re
from decorator import decorator

from .modules.internal.colors import yellow, cyan, green, magenta, blue, red


class ChepyDecorators(object):
    """A class to house all the decorators for Chepy
    """

    @staticmethod
    @decorator
    def call_stack(func, *args, **kwargs):
        """This decorator is used to get the method name and 
        arguments and save it to self.stack. The data from 
        self.stack is predominantly used to save recepies. 
        """
        func_sig = dict()
        func_self = args[0]
        func_sig["function"] = func.__name__

        bound_args = inspect.signature(func).bind(*args, **kwargs)
        bound_args.apply_defaults()

        func_arguments = dict(bound_args.arguments)
        del func_arguments["self"]
        func_sig["args"] = func_arguments
        func_self._stack.append(func_sig)

        return func(*args, **kwargs)


class ChepyCore(object):
    """The `ChepyCore` class for Chepy is primarily used as an interface 
    for all the current modules/classes in Chepy, or for plugin development. 
    The `ChepyCore` class is what provides the various attributes like **states**, 
    **buffers**, etc and is required to use and extend Chepy.
    
    Args:
        \*data (tuple): The core class takes arbitrary number of arguments as \*args.

    Attributes:
        states (dict): Contains all the current states. Each arg passed to 
            the ChepyCore class will be considered a state.
        buffers (dict): Contains all the current buffers if a buffer is saved. 
        state (Any): The data in the current state. The state changes each time a 
            Chepy method is called. 
    
    Returns:
        Chepy: The Chepy object. 
    """

    def __init__(self, *data):
        self.states = dict(list(enumerate(data)))
        #: Holder for the initial state
        self.__initial_states = dict(list(enumerate(data)))
        #: Value of the initial state
        self._current_index = 0
        self.buffers = dict()
        #: Alias for `write_to_file`
        self.write = self.write_to_file
        #: Alias for `out`
        self.bake = self.out
        #: Alias for `web`
        self.cyberchef = self.web
        #: Alias for `load_file`
        self.read_file = self.load_file
        #: Holds all the methods that are called/chanined and their args
        self._stack = list()

        #: Log level
        self.log_level = logging.INFO
        #: Log format message
        self.log_format = "%(levelname)-2s - %(message)s"
        logging.getLogger().setLevel(self.log_level)
        logging.basicConfig(format=self.log_format)

    @property
    def state(self):
        return self.states[self._current_index]

    @state.setter
    def state(self, val):
        self.states[self._current_index] = val

    def __str__(self):
        try:
            if isinstance(self.state, bytearray):
                return re.sub(rb"[^\x00-\x7f]", b".", self.state).decode()
            else:
                return self._convert_to_str()
        except UnicodeDecodeError:  # pragma: no cover
            return "Could not convert to str, but the data exists in the states. Use o, output or out() to access the values"
        except:  # pragma: no cover
            logging.exception(
                "\n\nCannot print current state. Either chain with "
                "another method, or use one of the output methods "
                "Example: .o, .output, .state or .out()\n\n"
            )
            return ""

    def _pickle_class(self, obj: Any) -> Any:
        """This method takes another object as an argument and 
        pickels that into a json object using jsonpickel. The 
        return value is a dictionary
        
        Args:
            obj (Any): Any object
        
        Returns:
            Any: unpickeled JSON as a python object. 
        """
        return ujson.loads(jsonpickle.encode(obj, unpicklable=True))

    def _load_as_file(self) -> object:
        """This method is used when a function or a method expects 
        a file path to load a file. Instead of passing a file path, 
        this method allows passing an io.BytesIO object instead.
        
        Returns:
            object: io.BytesIO object
        """
        return io.BytesIO(self._convert_to_bytes())

    def _abs_path(self, path: str):
        """Returns the absolute path by expanding home dir
        
        Args:
            path (str): Path to expand
        
        Returns:
            object: Path object
        """
        return pathlib.Path(path).expanduser().absolute()

    def _info_logger(self, data: str) -> None:
        """Just a binding for logger.info
        
        Args:
            data (str): Message to log
        
        Returns:
            Chepy: The Chepy object. 
        """
        logging.info(blue(data))
        return None

    def _warning_logger(self, data: str) -> None:  # pragma: no cover
        """Just a binding for logger.warning
        
        Args:
            data (str): Message to log
        
        Returns:
            Chepy: The Chepy object. 
        """
        logging.warning(yellow(data))
        return None

    def _error_logger(self, data: str) -> None:  # pragma: no cover
        """Just a binding for logger.error
        
        Args:
            data (str): Message to log
        
        Returns:
            Chepy: The Chepy object. 
        """
        logging.error(red(data))
        return None

    def fork(self, methods: List[Tuple[Union[str, object], dict]]):
        """Run multiple methods on all available states
        
        Method names in a list of tuples. If using in the cli, 
        this should not contain any spaces.

        Args:
            methods (List[Tuple[Union[str, object], dict]]): Required. 
                List of tuples
        
        Returns:
            Chepy: The Chepy object. 

        Examples:
            This method takes an array of method names and their args as an list of 
            tuples; the first value of the tuple is the method name as either a string, 
            or as an object, and the second value is a ditionary of arguments. The keys of 
            in the dictionary are method argument names, while the values are argument 
            values.

            >>> from chepy import Chepy
            >>> c = Chepy("some", "data")
            >>> c.fork([("to_hex",), ("hmac_hash", {"secret_key": "key"})])
            >>> # this is how to use fork methods with a string
            >>> c.fork([(c.to_hex,), (c.hmac_hash, {"secret_key": "key"})])
            >>> # This is how to use fork using methods
            >>> print(c.states)
            {0: 'e46dfcf050c0a0d135b73856ab8e3298f9cc4105', 1: '1863d1542629590e3838543cbe3bf6a4f7c706ff'}
        """
        for i in self.states:
            self.change_state(i)
            for method in methods:
                if type(method[0]).__name__ == "method":
                    method_name = method[0].__name__  # type: ignore
                elif isinstance(method[0], str):
                    method_name = method[0]
                if len(method) > 1:
                    self.states[i] = getattr(self, method_name)(**method[1]).o
                else:
                    self.states[i] = getattr(self, method_name)().o
        return self

    @ChepyDecorators.call_stack
    def set_state(self, data: Any):
        """Set any arbitrary values in the current state

        This method is simply changing the value of the instantiated 
        state with an arbitrary value. 
        
        Args:
            data (Any): Any data type
        
        Returns:
            Chepy: The Chepy object. 

        Examples:
            >>> c = Chepy("some data")
            >>> print(c.state)
            some data
            >>> c.set_state("New data")
            >>> print(c.state)
            New data
        """
        self.state = data
        return self

    @ChepyDecorators.call_stack
    def create_state(self):
        """Create a new empty state
        
        Returns:
            Chepy: The Chepy object. 
        """
        self.states[len(self.states)] = {}
        return self

    @ChepyDecorators.call_stack
    def copy_state(self, index: int):
        """Copy the current state to a new state
        
        Args:
            index (int): Index of new state
        
        Returns:
            Chepy: The Chepy object. 
        """
        self.states[index] = self.states.get(self._current_index)
        return self

    @ChepyDecorators.call_stack
    def change_state(self, index: int):
        """Change current state by index

        Same behaviour as switch_state
        
        Args:
            index (int): Index of new state
        
        Raises:
            TypeError: If specified index does not exist
        
        Returns:
            Chepy: The Chepy object.
        """
        if index > len(self.states):  # pragma: no cover
            raise TypeError("Specified index does not exist")
        self._current_index = index
        return self

    @ChepyDecorators.call_stack
    def switch_state(self, index: int):  # pragma: no cover
        """Switch current state by index

        Same behaviour as change_state
        
        Args:
            index (int): Index of new state
        
        Raises:
            TypeError: If specified index does not exist
        
        Returns:
            Chepy: The Chepy object.
        """
        if index > len(self.states):  # pragma: no cover
            raise TypeError("Specified index does not exist")
        self._current_index = index
        return self

    @ChepyDecorators.call_stack
    def delete_state(self, index: int):
        """Delete a state specified by the index
        
        Args:
            index (int): Index of state
        
        Returns:
            Chepy: The Chepy object. 
        """
        try:
            del self.states[index]
        except KeyError:  # pragma: no cover
            logging.warning("{} does not exist".format(index))
        return self

    @ChepyDecorators.call_stack
    def get_state(self, index: int) -> Any:
        """Returns the value of the specified state. 

        This method does not chain with other methods of Chepy
        
        Args:
            index (int): The index of the state
        
        Returns:
            Any: Any value that is in the specified state
        """
        return self.states.get(index)

    @ChepyDecorators.call_stack
    def save_buffer(self, index: int = None):
        """Save current state in a buffer 

        Buffers are temporary holding areas for anything that is in the state. 
        The state can change, but the buffer does not. Can be chained with other 
        methods. Use in conjunction with `load_buffer` to load buffer back into 
        the state. 
        
        Args:
            index (int, optional): The index to save the state in, defaults to next index if None
        
        Returns:
            Chepy: The Chepy object. 
        """
        if index is not None:
            self.buffers[index] = self.state
        else:
            self.buffers[len(self.buffers)] = self.state
        return self

    @ChepyDecorators.call_stack
    def load_buffer(self, index: int):
        """Load the specified buffer into state
        
        Args:
            index (int): Index key of an existing buffer
        
        Returns:
            Chepy: The Chepy object. 

        Examples:
            >>> c = Chepy("A").save_buffer()
            >>> # this saves the current value of state to a new buffer
            >>> c.to_hex()
            >>> # operate on a state, in this case, convert to hex.
            >>> c.state
            "41"
            >>> c.buffers
            {0: "A"}
            >>> c.load_buffer(0)
            >>> # loads the content of the buffer back into the current state. 
            >>> c.state
            "A"
        """
        self.state = self.buffers[index]
        return self

    @ChepyDecorators.call_stack
    def delete_buffer(self, index: int):
        """Delete a buffer item
        
        Args:
            index (int): Key of buffer item
        
        Returns:
            Chepy: The Chepy object. 
        """
        try:
            del self.buffers[index]
        except KeyError:  # pragma: no cover
            logging.warning("{} does not exist".format(index))
        return self

    @ChepyDecorators.call_stack
    def substring(self, pattern: Union[str, bytes], group: int = 0):
        """Choose a substring from current state as string 

        The preceeding methods will only run on the substring and 
        not the original state. Group capture is supported. 
        
        Args:
            pattern (Union[str, bytes]): Pattern to match.
            group (int, optional): Group to match. Defaults to 0.
        
        Returns:
            Chepy: The Chepy object. 
        """
        self.state = re.search(pattern, self._convert_to_str()).group(group)
        return self

    def _convert_to_bytes(self) -> bytes:
        """This method is used to coerce the curret object in 
        the state variable into a string. The method should be 
        called inside any method that operates on a string object 
        instead of calling `self.state` directly to avoid errors. 
        
        Raises:
            NotImplementedError: If type coercian isnt available 
                for the current state type.
        """
        if isinstance(self.state, bytes):
            return self.state
        elif isinstance(self.state, str):
            return self.state.encode()
        elif isinstance(self.state, int):
            return str(self.state).encode()
        elif isinstance(self.state, dict):
            return str(self.state).encode()
        elif isinstance(self.state, list):
            return str(self.state).encode()
        elif isinstance(self.state, bool):  # pragma: no cover
            return str(self.state).encode()
        elif isinstance(self.state, bytearray):
            return bytes(self.state)
        else:  # pragma: no cover
            # todo check more types here
            raise NotImplementedError

    def _convert_to_bytearray(self) -> bytearray:
        """Attempts to coerce the current state into a 
        `bytesarray` object
        """
        return bytearray(self._convert_to_bytes())

    def _convert_to_str(self) -> str:
        """This method is used to coerce the curret object in 
        the state variable into bytes. The method should be 
        called inside any method that operates on a bytes object 
        instead of calling `self.state` directly to avoid errors. 
        
        Raises:
            NotImplementedError: If type coercian isnt available 
                for the current state type.
        """
        if isinstance(self.state, bytes):
            return self.state.decode()
        elif isinstance(self.state, str):
            return self.state
        elif isinstance(self.state, int):
            return str(self.state)
        elif isinstance(self.state, dict):
            return str(self.state)
        elif isinstance(self.state, list):
            return str(self.state)
        elif isinstance(self.state, bool):  # pragma: no cover
            return str(self.state)
        elif isinstance(self.state, bytearray):
            return bytearray(self.state).decode()
        else:  # pragma: no cover
            # todo check more types here
            raise NotImplementedError

    def _convert_to_int(self) -> int:
        """This method is used to coerce the curret object in 
        the state variable into an int. The method should be 
        called inside any method that operates on a int types 
        instead of calling `self.state` directly to avoid errors. 
        
        Raises:
            NotImplementedError: If type coercian isnt available 
                for the current state type.
        """
        if isinstance(self.state, int):
            return self.state
        elif isinstance(self.state, str) or isinstance(self.state, bytes):
            return int(self.state)
        else:  # pragma: no cover
            raise NotImplementedError

    @property
    def o(self):
        """Get the final output
        
        Returns:
            Any: Final output
        """
        return self.state

    @property
    def output(self):
        """Get the final output
        
        Returns:
            Any: Final output
        """
        return self.state

    @ChepyDecorators.call_stack
    def out(self) -> Any:
        """Get the final output
        
        Returns:
            Any: Final output
        """
        return self.state

    @ChepyDecorators.call_stack
    def out_as_str(self) -> str:
        """Get current value as str
        
        Returns:
            str: Current value as a string
        """
        return self._convert_to_str()

    @ChepyDecorators.call_stack
    def out_as_bytes(self) -> bytes:
        """Get current value as bytes
        
        Returns:
            bytes: Current value as bytes
        """
        return self._convert_to_bytes()

    @ChepyDecorators.call_stack
    def get_by_index(self, index: int):
        """Get an item by specifying an index
        
        Args:
            index (int): Index number to get
        
        Returns:
            Chepy: The Chepy object.
        """
        self.state = self.state[index]
        return self

    @ChepyDecorators.call_stack
    def get_by_key(self, key: str):
        """Get an object from a dict by key
        
        Args:
            key (str): A valid key
        
        Returns:
            Chepy: The Chepy object.
        """
        if isinstance(self.state, dict):
            self.state = self.state.get(key)
            return self
        else:  # pragma: no cover
            raise TypeError("State is not a dictionary")

    @ChepyDecorators.call_stack
    def copy_to_clipboard(self) -> None:  # pragma: no cover
        """Copy to clipboard
        
        Copy the final output to the clipboard. If an 
        error is raised, refer to the documentation on the error.
        
        Returns:
            None: Copies final output to the clipboard
        """
        pyperclip.copy(self._convert_to_str())
        return None

    @ChepyDecorators.call_stack
    def copy(self) -> None:  # pragma: no cover
        """Copy to clipboard
        
        Copy the final output to the clipboard. If an 
        error is raised, refer to the documentation on the error.
        
        Returns:
            None: Copies final output to the clipboard
        """
        self.copy_to_clipboard()
        return None

    @ChepyDecorators.call_stack
    def web(
        self,
        magic: bool = False,
        cyberchef_url: str = "https://gchq.github.io/CyberChef/",
    ) -> None:  # pragma: no cover
        """Opens the current string in CyberChef on the browser as hex

        Args:
            magic (bool, optional): Start with the magic method in CyberChef
            cyberchef_url (string, optional): Base url for Cyberchef
        
        Returns:
            None: Opens the current data in CyberChef
        """
        data = re.sub(
            b"=+$", "", base64.b64encode(binascii.hexlify(self._convert_to_bytes()))
        )
        if magic:
            url = urljoin(
                cyberchef_url,
                "#recipe=From_Hex('None')Magic(3,false,false,'')&input={}".format(
                    data.decode()
                ),
            )
        else:
            url = urljoin(
                cyberchef_url,
                "#recipe=From_Hex('None')&input={}".format(data.decode()),
            )
        webbrowser.open_new_tab(url)
        return None

    @ChepyDecorators.call_stack
    def http_request(
        self,
        method: str = "GET",
        params: dict = {},
        json: dict = None,
        headers: dict = {},
        cookies: dict = {},
    ):
        """Make a http/s request

        Make a HTTP/S request and work with the data in Chepy. Most common http 
        methods are supported; but some methods may not provide a response body. 
        
        Args:
            method (str, optional): Request method. Defaults to 'GET'.
            params (dict, optional): Query Args. Defaults to {}.
            json (dict, optional): Request payload. Defaults to None.
            headers (dict, optional): Headers for request. Defaults to {}.
            cookies (dict, optional): Cookies for request. Defaults to {}.
        
        Raises:
            NotImplementedError: If state is not a string or dictionary
            requests.RequestException: If response status code is not 200
        
        Returns:
            Chepy: A dictionary containing body, status and headers. The Chepy object.

        Examples:
            By default, this methed with make a GET request, But supports most 
            common methods. 
            
                >>> c = Chepy("http://example.com").http_request()
                >>> c.get_by_key("headers")

            This method can also be used to make more complex requests by specifying 
            headers, cookies, body data etc.

                >>> c = Chepy("https://en4qpftrmznwq.x.pipedream.net")
                >>> c.http_request(
                >>>    method="POST", 
                >>>    headers={"My-header": "some header"}, 
                >>>    json={"some": "data"}
                >>> )
                >>> print(c.get_by_key("body"))
                {"success": true}
        """

        def json2str(obj):  # pragma: no cover
            if isinstance(obj, dict):
                return obj
            elif isinstance(obj, str):
                return json.loads(obj)
            else:
                raise NotImplementedError

        try:
            from requests import request
        except ImportError:  # pragma: no cover
            self._error_logger("Could not import requests. pip install requests")
            return self

        params = json2str(params)
        headers = json2str(headers)
        cookies = json2str(cookies)
        res = request(
            method=method,
            url=self.state,
            params=params,
            json=json,
            headers=headers,
            cookies=cookies,
        )
        self.state = {
            "body": res.text,
            "status": res.status_code,
            "headers": dict(res.headers),
        }
        return self

    @ChepyDecorators.call_stack
    def load_from_url(
        self,
        method: str = "GET",
        params: dict = {},
        json: dict = None,
        headers: dict = {},
        cookies: dict = {},
    ):
        """Load binary content from a url

        Most common http methods are supported; but some methods may not provide a response body. 
        
        Args:
            method (str, optional): Request method. Defaults to 'GET'.
            params (dict, optional): Query Args. Defaults to {}.
            json (dict, optional): Request payload. Defaults to None.
            headers (dict, optional): Headers for request. Defaults to {}.
            cookies (dict, optional): Cookies for request. Defaults to {}.
        
        Raises:
            NotImplementedError: If state is not a string or dictionary
            requests.RequestException: If response status code is not 200
        
        Returns:
            Chepy: A bytearray of the response content. The Chepy object.

        Examples:
            By default, this methed with make a GET request, But supports most 
            common methods. 
            
                >>> c = Chepy("http://example.com/file.png").load_from_url()
                >>> b'\\x89PNG...'
        """

        def json2str(obj):  # pragma: no cover
            if isinstance(obj, dict):
                return obj
            elif isinstance(obj, str):
                return json.loads(obj)
            else:
                raise NotImplementedError

        try:
            from requests import request
        except ImportError:  # pragma: no cover
            self._error_logger("Could not import requests. pip install requests")
            return self

        params = json2str(params)
        headers = json2str(headers)
        cookies = json2str(cookies)
        res = request(
            method=method,
            url=self.state,
            params=params,
            json=json,
            headers=headers,
            cookies=cookies,
        )
        self.state = io.BytesIO(res.content).read()
        return self

    @ChepyDecorators.call_stack
    def load_dir(self, pattern: str = "*"):
        """Load all file paths in a directory
        
        Args:
            pattern (str, optional): File pattern to match. Defaults to "*".
        
        Returns:
            Chepy: The Chepy object. 
        """
        files = [x for x in pathlib.Path(self.state).glob(pattern) if x.is_file()]
        self.states = {x[0]: str(x[1]) for x in enumerate(files) if x[1].is_file()}
        return self

    @ChepyDecorators.call_stack
    def load_file(self, binary_mode: bool = False):
        """If a path is provided, load the file
        
        Args:
            binary_mode (bool, optional): Force load in binary mode.
        
        Returns:
            Chepy: The Chepy object. 
        
        Examples:
            >>> c = Chepy("/path/to/file")
            >>> # at the moment, the state only contains the string "/path/to/file"
            >>> c.load_file() # this will load the file content into the state
        """
        path = pathlib.Path(str(self.state)).expanduser().absolute()
        if binary_mode:
            with open(path, "rb") as f:
                self.states[self._current_index] = bytearray(f.read())
        else:
            try:
                with open(path, "r") as f:  # type: ignore
                    self.states[self._current_index] = f.read()
            except UnicodeDecodeError:
                with open(path, "rb") as f:
                    self.states[self._current_index] = bytearray(f.read())
        return self

    def write_to_file(self, path: str) -> None:
        """Save the state to disk. Return None.
        
        Args:
            path (str): The file path to save in.
        
        Returns:
            None: Returns None

        Examples:
            >>> c = Chepy("some data").write_to_file('/some/path/file', as_binary=True)
        """
        if isinstance(path, bytes):  # pragma: no cover
            path = path.decode()
        with open(str(self._abs_path(path)), "w+") as f:
            f.write(self._convert_to_str())
        self._info_logger("File written to {}".format(self._abs_path(path)))
        return None

    def write_binary(self, path: str) -> None:  # pragma: no cover
        """Save the state to disk. Return None.
        
        Args:
            path (str): The file path to save in.
        
        Returns:
            None: Returns None

        Examples:
            >>> c = Chepy("some data").write_binary('/some/path/file')
        """
        if isinstance(path, bytes):  # pragma: no cover
            path = path.decode()
        with open(str(self._abs_path(path)), "wb+") as f:
            f.write(self.state)
        self._info_logger("File written to {}".format(self._abs_path(path)))
        return None

    def save_recipe(self, path: str):
        """Save the current recipe

        A recipe will be all the previous methdos called on the 
        chepy instance along with their args
        
        Args:
            path (str): The path to save the recipe
        
        Returns:
            Chepy: The Chepy object. 

        Examples:
            >>> c = Chepy("some data").to_hex().base64_encode()
            >>> c.save_recipe("/path/to/recipe)
            >>> c.out()
            NzM2ZjZkNjUyMDY0NjE3NDYx
        """
        with self._abs_path(path) as f:
            f.write_text(ujson.dumps(self._stack))
        self._info_logger("Saved recipe to {}".format(str(path)))
        return self

    def load_recipe(self, path: str):
        """Load and run a recipe
        
        Args:
            path (str): Path to recipe file
        
        Returns:
            Chepy: The Chepy object.

        Examples:
            >>> c = Chepy("some data").load_recipe("/path/to/recipe").out()
            NzM2ZjZkNjUyMDY0NjE3NDYx
        """
        with self._abs_path(path) as f:
            recipes = ujson.loads(f.read_text())
            for recipe in recipes:
                function = recipe["function"]
                args = recipe["args"]
                if len(args) > 0:
                    getattr(self, function)(**args)
                else:
                    getattr(self, function)()
        return self

    @ChepyDecorators.call_stack
    def loop(self, iterations: int, callback: str, args: dict = {}):
        """Loop and apply callback n times
        
        Args:
            iterations (int): Number of iterations to loop
            callback (str): The Chepy method to loop over
            args (dict, optional): Optional arguments for the callback. Defaults to {}.
        
        Returns:
            Chepy: The Chepy object. 

        Examples:
            >>> c = Chepy("VmpGb2QxTXhXWGxTYmxKV1lrZDRWVmx0ZEV0alZsSllaVWRHYWxWVU1Eaz0=")
            >>> c.loop(iterations=6, callback='hmac_hash', args={'key': 'secret'})
            securisec
        """
        assert isinstance(callback, str), "Callback must be a string"
        assert isinstance(iterations, int), "Iterations must be an intiger"
        assert isinstance(args, dict), "Args must be a dick"

        stack_loop_index = next(
            itertools.dropwhile(
                lambda x: self._stack[x]["function"] != "loop",
                reversed(range(len(self._stack))),
            )
        )

        for _ in range(int(iterations)):
            d = getattr(self, callback)(**args)

        self._stack = self._stack[: stack_loop_index + 1]
        return self

    @ChepyDecorators.call_stack
    def loop_list(self, callback: str, args: dict = {}):
        """Loop over an array and run a Chepy method on it
        
        Args:
            callback (str): Chepy method as string
            args (dict, optional): Dictionary of args. If in cli, dont use spaces. Defaults to {}.
        
        Returns:
            Chepy: The Chepy object

        Examples:
            This method is capable of running a callable from either 
            a string, or a chepy method.

            >>> c = Chepy(["an", "array"])
            >>> c.loop_list('to_hex').loop_list('hmac_hash', {'key': 'secret'})
            ['5cbe6ca2a66b380aec1449d4ebb0d40ac5e1b92e', '30d75bf34740e8781cd4ec7b122e3efd8448e270']
        """
        assert isinstance(self.state, list), "State is not a list"
        assert isinstance(callback, str), "Callback must be a string"
        hold = []
        current_state = self.state
        # find the last index that this method was run
        stack_loop_index = next(
            itertools.dropwhile(
                lambda x: self._stack[x]["function"] != "loop_list",
                reversed(range(len(self._stack))),
            )
        )
        if isinstance(args, str):  # pragma: no cover
            args = ujson.loads(args)
        try:
            for index, data in enumerate(current_state):
                self.state = current_state[index]
                if args:
                    hold.append(getattr(self, callback)(**args).o)
                else:
                    hold.append(getattr(self, callback)().o)
            self._stack = self._stack[: stack_loop_index + 1]
            self.state = hold
            return self
        except:  # pragma: no cover
            self.state = current_state
            raise

    @ChepyDecorators.call_stack
    def loop_dict(self, keys: list, callback: str, args: dict = {}):
        """
        Loop over a dictionary and apply the callback to the value
        
        Args:
            keys (list): List of keys to match. If in cli, dont use spaces.
            callback (str): Chepy method as string
            args (dict, optional): Dictionary of args. If in cli, dont use spaces. Defaults to {}.
        
        Returns:
            Chepy: The Chepy object. 

        Examples:
            >>> c = Chepy({'some': 'hahahaha', 'lol': 'aahahah'})
            >>> c.loop_dict(['some'], 'hmac_hash', {'key': 'secret'}).o
            {'some': '99f77ec06a3c69a4a95371a7888245ba57f47f55', 'lol': 'aahahah'}
            
            We can combine `loop_list` and `loop_dict` to loop over a list of dictionaries.

            >>> data = [{"some": "val"}, {"some": "another"}, {"lol": "lol"}, {"another": "aaaa"}]
            >>> c = Chepy(data)
            >>> c.loop_list("loop_dict", {"keys": ["some", "lol"], "callback": "to_upper_case"})
            [
                {"some": "VAL"},
                {"some": "ANOTHER"},
                {"lol": "LOL"},
                {"another": "aaaa"},
            ]
        """
        assert isinstance(callback, str), "Callback must be a string"
        hold = {}
        current_state = self.state
        # find the last index that this method was run
        stack_loop_index = next(
            itertools.dropwhile(
                lambda x: self._stack[x]["function"] != "loop_dict",
                reversed(range(len(self._stack))),
            )
        )

        if isinstance(keys, str):  # pragma: no cover
            keys = ujson.loads(keys)

        if isinstance(args, str):  # pragma: no cover
            args = ujson.loads(args)
        try:
            dict_keys = current_state.keys()
            for key in keys:
                if current_state.get(key) is not None:
                    self.state = current_state.get(key)
                    if args:
                        hold[key] = getattr(self, callback)(**args).o
                    else:
                        hold[key] = getattr(self, callback)().o
            for unmatched_key in list(set(dict_keys) - set(keys)):
                hold[unmatched_key] = current_state[unmatched_key]
            self._stack = self._stack[: stack_loop_index + 1]
            self.state = hold
            return self
        except:  # pragma: no cover
            self.state = current_state
            raise

    @ChepyDecorators.call_stack
    def debug(self, verbose: bool = False):
        """Debug the current instance of Chepy
        
        This method does not change the state. 

        Args:
            verbose (bool, optional): Show verbose info. Defaults to False.
        
        Returns:
            Chepy: The Chepy object. 
        """
        print(cyan("Current state:"), yellow(str(self._current_index)))
        print(cyan("Current states:"), yellow(str(len(self.states))))
        print(
            cyan("Current state types:"),
            yellow(str({k: type(v).__name__ for k, v in self.states.items()})),
        )
        print(cyan("Current buffers:"), yellow(str(len(self.buffers))))
        print(
            cyan("Current buffer types:"),
            yellow(str({k: type(v).__name__ for k, v in self.buffers.items()})),
        )
        if verbose:
            print(magenta("States:"), self.states)
            print(magenta("Buffers:"), self.buffers)
        return self

    @ChepyDecorators.call_stack
    def reset(self):
        """Reset states back to their initial values
        
        Returns:
            Chepy: The Chepy object. 
        """
        self.states = self.__initial_states
        return self

    @ChepyDecorators.call_stack
    def shell_output(self):  # pragma: no cover
        """Run the command in state and get the output
        
        Returns:
            Chepy: The Chepy object. 

        Examples:
            This method can be used to interace with the shell and Chepy 
            directly by ingesting a commands output in Chepy. 

            >>> c = Chepy("ls -l").shell_output().o
            test.html
            ...
            test.py
        """
        self.state = subprocess.getoutput(self.state)
        return self

    @ChepyDecorators.call_stack
    def pretty(self, indent: int = 2):  # pragma: no cover
        """Prettify the state. 
        
        Args:
            indent (int, optional): Indent level. Defaults to 2.
        
        Returns:
            Chepy: The Chepy object. 
        """
        self.state = pformat(self.state, indent=int(indent))
        return self

    def plugins(self, enable: str) -> None:  # pragma: no cover
        """Use this method to enable or disable Chepy plugins. 

        Valid options are `true` or `false`. Once this method completes, 
        it does call sys.exit(). 

        Args:
            enable (str): Set to `true` or `false`

        Returns:
            None
        """
        assert enable in ["true", "false"], "Valid values are true and false"
        conf_path = pathlib.Path().home() / ".chepy" / "chepy.conf"
        c = ConfigParser()
        c.read(conf_path)
        c.set("Plugins", "enableplugins", enable)
        with open(conf_path, "w") as f:
            c.write(f)
        if enable:
            self._info_logger(
                green(
                    "Plugins have been enabled. Restart Chepy for effects to take place."
                )
            )
        else:
            self._info_logger(
                green(
                    "Plugins have been disabled. Restart Chepy for effects to take place."
                )
            )
        sys.exit()
        return None

    def set_plugin_path(self, path: str) -> None:  # pragma: no cover
        """Use this method to set the path for Chepy plugins. 

        Args:
            path (str): Path to plugins directory

        Returns:
            None
        """
        expand_path = self._abs_path(path)
        if expand_path.exists():
            conf_path = pathlib.Path().home() / ".chepy" / "chepy.conf"
            c = ConfigParser()
            c.read(conf_path)
            c.set("Plugins", "pluginpath", str(expand_path))
            with open(conf_path, "w") as f:
                c.write(f)
            self._info_logger(green("Plugin path has been set. Restart for changes."))
            sys.exit()
            return None
        else:
            raise AttributeError("The path does not exist")
