import ast
import json
import logging
from platform import python_version_tuple
from textwrap import dedent
from typing import Callable, Dict, List, Union

logger = logging.getLogger(__name__)
JsonDict = Dict[Union[str, int], "JsonValue"]
JsonList = List["JsonValue"]
JsonValue = Union[bool, int, str, JsonDict, JsonList]


class NodeLoader:
    from .execjs import ExecJS

    jsonStringify = ExecJS(js="").bind("JSON.stringify", new=False)

    @classmethod
    async def json_loads(
        cls, js: str, try_load_first: bool = True, parser: Callable[[str], JsonValue] = json.loads
    ) -> JsonValue:
        """
        The json_loads function converts a string representation of JS/JSON data into a Python object.
        It may use :node:meth:`JSON.stringify` to convert js to json.

        :param js: Used to Pass in the json string.
        :param try_load_first: Used to Specify whether to try loading the json string with the `parser` first.
        :param parser: Used to Specify the function that will be used to parse the string.
        :return: A python object that represents the same content as the js/json string.
        """
        if try_load_first:
            try:
                return parser(js)
            except json.JSONDecodeError:
                pass

        json_str = await cls.jsonStringify(js, asis=True)
        try:
            return parser(json_str)
        except json.JSONDecodeError as e:
            logger.exception("Failed to decode json input!")
            logger.debug("json_str=%s", json_str)
            raise e


class AstLoader:
    class RewriteUndef(ast.NodeTransformer):
        def __init__(self) -> None:
            if int(python_version_tuple()[1]) < 8:
                # NOTE: visit_Constant not available on py37
                self.visit_Str = lambda node: ast.Str(s=node.s.replace("\\/", "/"))

        const = {
            "undefined": ast.Constant(value=None),
            "null": ast.Constant(value=None),
            "true": ast.Constant(value=True),
            "false": ast.Constant(value=False),
        }

        def visit_Name(self, node: ast.Name):
            if node.id in self.const:
                return self.const[node.id]
            return ast.Str(s=node.id)

        def visit_Constant(self, node: ast.Constant) -> Union[ast.Constant, ast.Str]:
            if not isinstance(node.value, str):
                return node
            return ast.Str(s=node.value.replace("\\/", "/"))

    @classmethod
    def json_loads(cls, js: str, filename: str = "stdin") -> JsonValue:
        """
        The json_loads function loads a JSON object from a js/json string. It uses standard
        :mod:`ast` module to parse the js/json.

        :param js: Used to Pass the js/json string to be parsed.
        :param filename: Used to Specify the name of the file that is being read. This is only for debug use.
        :rtype: `dict[str | int, Any]`
        :return: A jsonvalue object.
        """

        node = ast.parse(dedent(js), mode="eval")
        node = ast.fix_missing_locations(cls.RewriteUndef().visit(node))
        code = compile(node, filename, mode="eval")
        return eval(code)


def json_loads(js: str) -> JsonValue:
    """The json_loads function converts a string representation of JS/JSON data into a Python object.
    Current implementation is using :mod:`ast`.

    If you need more parameters or another implementation, call `xxxLoader.json_loads` instead.

    .. seealso:: :meth:`.AstLoader.json_loads`

    :param js: Used to Pass the JS/JSON string.
    :rtype: `dict[str | int, Any]`
    :return: A jsonvalue object.
    """
    return AstLoader.json_loads(js)
