# flake8: noqa
from typing import Any, Callable, Dict, Optional, Tuple, Union
import json

JSON_TYPE = Optional[Union[str, int, float, bool, dict, list]]


class AsyncJSONKey:
    """Represents an key in the async database that holds a JSON value.
    db.jsonkey() will initialize an instance for you,
    you don't have to do it manually.
    """

    __slots__ = ("db", "key", "dtype", "get_default", "discard_bad_data", "do_raise")

    def __init__(
        self,
        db: Any,
        key: str,
        dtype: JSON_TYPE,
        get_default: Callable[[], JSON_TYPE] = None,
        discard_bad_data: bool = False,
        do_raise: bool = False,
    ) -> None:
        """Initialize the key.
        Args:
            db (Any): An instance of ReplitDb
            key (str): The key to read
            dtype (JSON_TYPE): The datatype the key should be, can be typing.Any.
            get_default (Callable): A function that returns the default
                value if the key is not set. If it is None (the default) the dtype
                argument is used.
            discard_bad_data (bool): Don't prompt if bad data is read, overwrite it
                with the default. Defaults to False.
            do_raise (bool): Whether to raise exceptions when errors are encountered.
        """
        self.db = db
        self.key = key
        self.dtype = dtype
        self.get_default = get_default
        self.discard_bad_data = discard_bad_data
        self.do_raise = do_raise

    def _default(self) -> JSON_TYPE:
        if self.get_default:
            return self.get_default()
        return self.dtype

    def _is_valid_type(self, data: JSON_TYPE) -> bool:
        return self.dtype is Any or isinstance(data, self.dtype)

    def _type_mismatch_msg(self, data: Any) -> str:
        return (
            f"Type mismatch: Got type {type(data).__name__},"
            f"expected {self.dtype.__name__}"
        )

    async def get(self) -> JSON_TYPE:
        """Get the value of the key.
        If an invalid JSON value is read or the type does not match, it will show a
            prompt asking the user what to do unless discard_bad_data is set.
        Raises:
            KeyError: If do_raise is true and the key does not exist.
            json.JSONDecodeError: If do_raise is true and invalid JSON data is read
                from the key.
        Returns:
            JSON_TYPE: The value read from the database
        """
        try:
            read = await self.db.get(self.key)
        except KeyError:
            if self.do_raise:
                raise
            print(f"Database key {self.key} not set, setting it to default value")
            default = self._default()
            await self.db.set(self.key, default)
            return default

        try:
            data = json.loads(read)
        except json.JSONDecodeError:
            if self.do_raise:
                raise
            return await self._error("Invalid JSON data read", read)

        if not self._is_valid_type(data):
            return await self._error(
                self._type_mismatch_msg(data),
                read,
            )
        return data

    async def _error(self, error: str, read: str) -> JSON_TYPE:
        if self.do_raise:
            raise ValueError(error)

        print(f"Error reading key {self.key!r}: {error}", file=stderr)
        if self.discard_bad_data:
            val = self._default()
            await self.db.set(self.key, json.dumps(val))
            print(f"Wrote default to key {self.key!r}")
            return val
        return await self._should_discard_prompt(error, read)

    async def _should_discard_prompt(self, error: str, read: str) -> bool:
        while True:
            choice = input(
                "d to use default, v to view the invalid data, c to insert custom "
                "value, ^C to exit: "
            )
            if choice.startswith("d"):
                print("Writing default...")
                val = self._default()
                await self.db.set(self.key, val)
                return val
            elif choice.startswith("v"):
                print(f"Data read from key: {read!r}")
            elif choice.startswith("c"):
                toset = input(
                    f"Enter data to write, should be of type {self.dtype.__name__!r}"
                    " (leave blank to return to menu): "
                )
                if not toset:
                    continue
                try:
                    data = json.loads(toset)
                except json.JSONDecodeError:
                    print("Invalid JSON data!")
                else:
                    if not self._is_valid_type(data):
                        print(self._type_mismatch_msg(data))
                        continue

                    await self.db.set(self.key, toset)
                    print("Wrote data to key")
                    return data

    async def set(self, data: JSON_TYPE) -> None:
        """Set the value of the jsonkey.
        Args:
            data (JSON_TYPE): The value to set it to.
        Raises:
            TypeError: The type of the value set does not match the datatype.
        """
        if not self._is_valid_type(data):
            raise TypeError(self._type_mismatch_msg(data))

        await self.db.set(self.key, json.dumps(data))


class JSONKey(AsyncJSONKey):
    """Represents a key in the database that holds a JSON value.

    db.jsonkey() will initialize an instance for you,
    you don't have to do it manually.
    """

    __slots__ = ("db", "key", "dtype", "get_default", "discard_bad_data", "do_raise")

    def __init__(
        self,
        db: Any,
        key: str,
        dtype: JSON_TYPE,
        get_default: Callable = None,
        discard_bad_data: bool = False,
        do_raise: bool = False,
    ) -> None:
        """Initialize the key.

        Args:
            db (Any): An instance of ReplitDb
            key (str): The key to read
            dtype (JSON_TYPE): The datatype the key should be, can be typing.Any.
            get_default (Callable): A function that returns the default
                value if the key is not set. If it is None (the default) the dtype
                argument is used.
            discard_bad_data (bool): Don't prompt if bad data is read, overwrite it
                with the default. Defaults to False.
            do_raise (bool): Whether to raise exceptions when errors are encountered.
        """
        self.db = db
        self.key = key
        self.dtype = dtype
        self.get_default = get_default
        self.discard_bad_data = discard_bad_data
        self.do_raise = do_raise

    def get(self) -> JSON_TYPE:
        """Get the value of the key.

        If an invalid JSON value is read or the type does not match, it will show a
            prompt asking the user what to do unless discard_bad_data is set.

        Returns:
            JSON_TYPE: The value read from the database
        """
        try:
            read = self.db[self.key]
        except KeyError:
            print(f"Database key {self.key} not set, setting it to default value")
            default = self._default()
            self.db[self.key] = default
            return default

        if isinstance(self.db, ReplitDb):
            try:
                data = json.loads(read)
            except json.JSONDecodeError:
                return self._error("Invalid JSON data read", read)
        else:
            data = read

        if not self._is_valid_type(data):
            return self._error(
                self._type_mismatch_msg(data),
                read,
            )
        return data

    def _error(self, error: str, read: str) -> JSON_TYPE:
        print(f"Error reading key {self.key!r}: {error}", file=stderr)
        if self.discard_bad_data:
            val = self._default()
            self.db[self.key] = json.dumps(val)
            print(f"Wrote default to key {self.key!r}")
            return val
        return self._should_discard_prompt(error, read)

    def _should_discard_prompt(self, error: str, read: str) -> bool:
        while True:
            choice = input(
                "d to use default, v to view the invalid data, c to insert custom "
                "value, ^C to exit: "
            )
            if choice.startswith("d"):
                print("Writing default...")
                val = self._default()
                self.db[self.key] = val
                return val
            elif choice.startswith("v"):
                print(f"Data read from key: {read!r}")
            elif choice.startswith("c"):
                toset = input(
                    f"Enter data to write, should be of type {self.dtype.__name__!r}"
                    " (leave blank to return to menu): "
                )
                if not toset:
                    continue
                try:
                    data = json.loads(toset)
                except json.JSONDecodeError:
                    print("Invalid JSON data!")
                else:
                    if not self._is_valid_type(data):
                        print(self._type_mismatch_msg(data))
                        continue

                    self.db[self.key] = toset
                    print("Wrote data to key")
                    return data

    def set(self, data: JSON_TYPE) -> None:
        """Set the value of the jsonkey.

        Args:
            data (JSON_TYPE): The value to set it to.

        Raises:
            TypeError: The type of the value set does not match the datatype.
        """
        if not self._is_valid_type(data):
            raise TypeError(self._type_mismatch_msg(data))
        if isinstance(self.db, ReplitDb):
            data = json.dumps(data)
        self.db[self.key] = data

    def read(self, key: str, default: Any = None) -> Any:
        """Shorthand for self.get().get(name, default) if datatype is dict.

        Args:
            key (str): The name to get.
            default (Any): The default if the key doesn't exist. Defaults to None.

        Returns:
            Any: The value read or the default.
        """
        data = self.get()
        if not isinstance(data, dict):
            raise TypeError
        return data.get(key, default)

    def keys(self, *keys: str) -> Any:
        """Reads multiple keys from the key's value and allows setting.

        Args:
            *keys (str): The keys to read from the data.

        Returns:
            Any: The value accessed from self.get()[k1][k2][kn]
        """
        data = self
        for key in keys[:-1]:
            data = type(self)(db=data, key=key, dtype=Any)
        check = data[keys[-1]]
        if type(check) is dict:
            return type(self)(db=data, key=keys[-1], dtype=dict)
        else:
            return check

    def __getitem__(self, name: str) -> JSON_TYPE:
        """Retrieve a key from the JSONKey's value if it is a dict.

        Args:
            name (str): The name to retrieve.

        Returns:
            JSON_TYPE: The value of the key.
        """
        return self.keys(name)

    def __setitem__(self, name: str, value: JSON_TYPE) -> None:
        """Sets a key inside the JSONKey's value if it is a dict.

        Args:
            name (str): The key to set.
            value (JSON_TYPE): The value to set it to.
        """
        data = self.get()
        if not isinstance(data, dict):
            raise TypeError
        data[name] = value
        self.set(data)

    def append(self, item: JSON_TYPE) -> None:
        """Append to the JSONKey's value if it is a list.

        Args:
            item (JSON_TYPE): The item to append.
        """
        data = self.get()
        if not isinstance(data, list):
            raise TypeError
        self.set(data + [item])

    def __add__(self, item: Any) -> Any:
        """Add to the JSONKey's value and return the result.

        Args:
            item (Any): The item to add.

        Returns:
            Any: The result of adding.
        """
        return self.get() + item

    def __iadd__(self, item: Any) -> Any:
        """Add to the JSONKey's value and set the result.

        Args:
            item (Any): The item to add.

        Returns:
            Any: self
        """
        r = self.get() + item
        self.set(r)
        return self
