import inspect
import math
import string
import sys
from collections.abc import Callable
from collections.abc import Iterable
from collections.abc import Mapping
from datetime import timedelta
from typing import Any
from typing import cast
from typing import TypeAlias
from urllib.parse import parse_qsl

from werkzeug.datastructures import MultiDict

if sys.version_info >= (3, 14):
    import annotationlib

    def _signature(f: Callable) -> inspect.Signature:
        return inspect.signature(f, annotation_format=annotationlib.Format.FORWARDREF)
else:
    _signature = inspect.signature

TEMPLATE_FRAGMENT_KEY_TEMPLATE = "_template_fragment_cache_%s%s"
# Used to remove control characters and whitespace from cache keys.
valid_chars = set(string.ascii_letters + string.digits + "_.")
del_chars = "".join(c for c in map(chr, range(256)) if c not in valid_chars)
null_control = str.maketrans({k: None for k in del_chars})

_QueryArgs: TypeAlias = str | Mapping[str, Any] | Iterable[tuple[str, Any]]
_Timeout: TypeAlias = int | timedelta


def normalize_timeout(timeout: _Timeout | str | None) -> int | None:
    """Convert a ``timedelta`` timeout to whole seconds. A subsecond timeout
    (i.e. miliseconds) is rounded up to a whole second.
    A subsecond timeout does not turn into ``0``!
    """
    if isinstance(timeout, str):
        timeout = int(timeout)

    if isinstance(timeout, timedelta):
        return math.ceil(timeout.total_seconds())
    return timeout


def wants_args(f: Callable[..., Any]) -> bool:
    """Check if the function wants any positional, *args, or **kwargs arguments."""
    return any(
        p.kind != inspect.Parameter.KEYWORD_ONLY for p in get_function_parameters(f)
    )


def wants_extra_args(f: Callable[..., Any]) -> bool:
    """Check if the function wants an additional argument beside
    its first positional one.
    """
    return (
        sum(
            p.kind != inspect.Parameter.KEYWORD_ONLY for p in get_function_parameters(f)
        )
        > 1
    )


def get_function_parameters(f: Callable[..., Any]) -> list[inspect.Parameter]:
    """Get function parameters
    :param f
    :return: Parameter list of function
    """
    return list(_signature(f).parameters.values())


def get_arg_names(f: Callable[..., Any]) -> list[str]:
    """Return arguments of function
    :param f:
    :return: String list of arguments
    """
    return [
        parameter.name
        for parameter in get_function_parameters(f)
        if parameter.kind == parameter.POSITIONAL_OR_KEYWORD
    ]


def get_arg_default(f: Callable[..., Any], position: int) -> Any:
    arg = get_function_parameters(f)[position]
    arg_def = arg.default
    return arg_def if arg_def != inspect.Parameter.empty else None


def query_args_as_pairs(query_args: _QueryArgs) -> list[tuple[str, str]]:
    """Convert the accepted query args to (key, value) pairs."""
    if isinstance(query_args, str):
        return parse_qsl(query_args.lstrip("?"), keep_blank_values=True)
    if isinstance(query_args, MultiDict):
        items: Iterable[tuple[str, Any]] = cast(
            "MultiDict[str, Any]", query_args
        ).items(multi=True)
    elif isinstance(query_args, Mapping):
        items = cast("Mapping[str, Any]", query_args).items()
    else:
        items = query_args

    return [(key, str(value)) for key, value in items]


def get_id(obj: Any) -> str:
    caching_id = getattr(obj, "__caching_id__", None)
    if caching_id is None:
        return repr(obj)
    return str(caching_id())


def function_namespace(
    f: Callable[..., Any], args: Any = None
) -> tuple[str, str | None]:
    """Attempts to return a unique namespace for the function."""
    m_args = get_arg_names(f)

    instance_token = None

    instance_self = getattr(f, "__self__", None)

    if instance_self and not inspect.isclass(instance_self):
        instance_token = get_id(instance_self)
    elif m_args and m_args[0] == "self" and args:
        instance_token = get_id(args[0])

    module = f.__module__

    if m_args and m_args[0] == "cls" and not (args and inspect.isclass(args[0])):
        raise ValueError(
            "When using `delete_memoized` on a "
            "`@classmethod` you must provide the "
            "class as the first argument"
        )

    if hasattr(f, "__qualname__"):
        name = f.__qualname__
    else:
        klass = getattr(f, "__self__", None)

        if klass and not inspect.isclass(klass):
            klass = klass.__class__

        if not klass:
            klass = getattr(f, "im_class", None)

        if not klass:
            if m_args and args:
                if m_args[0] == "self":
                    klass = args[0].__class__
                elif m_args[0] == "cls":
                    klass = args[0]

        if klass:
            name = klass.__name__ + "." + f.__name__
        else:
            name = f.__name__

    ns = ".".join((module, name)).translate(null_control)

    ins = (
        ".".join((module, name, instance_token)).translate(null_control)
        if instance_token
        else None
    )

    return ns, ins


def make_template_fragment_key(
    fragment_name: str, vary_on: list[str] | None = None
) -> str:
    """Make a cache key for a specific fragment name."""
    if vary_on:
        fragment_name = f"{fragment_name}_"
    else:
        vary_on = []
    return TEMPLATE_FRAGMENT_KEY_TEMPLATE % (fragment_name, "_".join(vary_on))


def join_generator(rv: Any) -> Any:
    """Used to join strings and bytes together when inspect.isgenerator(rv) = True"""
    chunks = list(rv)

    if all(isinstance(chunk, str) for chunk in chunks):
        return "".join(chunks)
    if all(isinstance(chunk, bytes) for chunk in chunks):
        return b"".join(chunks)
    return chunks
