import base64
import json
import logging
import secrets
import typing as t
from asyncio import iscoroutinefunction

from asgiref.sync import async_to_sync
from django.http import HttpRequest

_with_sync_sentinel = object()

_CallableT = t.TypeVar("_CallableT", bound=t.Callable)
_T = t.TypeVar("_T")

logger = logging.getLogger(__package__)


def with_sync(f: _CallableT) -> _CallableT:
    f.__with_sync__ = _with_sync_sentinel  # type: ignore
    return f


class WithSyncMeta(type):
    """Add synchronous versions of `@with_sync` methods to a class.

    A method must be decorated with `with_async` in order for a sync method to be generated.
    Generated sync methods have the name `{async_method_name}_sync`. Example:

    ```
    class Foo(metaclass=WithSyncMeta):
        @with_sync
        async def bar(self):
            await baz()
    ```

    Will be transformed into the equivalent of:

    ```
    class Foo:
        async def bar(self):
            await baz()

        def bar_sync(self):
            asgiref.sync.async_to_sync(baz)()
    ```
    """

    def __new__(
        cls: type[_T],
        name: str,
        bases: t.Iterable[str],
        dct: dict[str, t.Any],
    ) -> type[_T]:
        new_class: type[_T] = t.cast(type[_T], super().__new__(  # type: ignore
            cls, name, bases, dct)
        )

        sync_wrappers = {}

        for base in reversed(new_class.__mro__):
            for property_name, property_value in base.__dict__.items():
                if (
                    iscoroutinefunction(property_value)
                    and getattr(property_value, "__with_sync__", None)
                    is _with_sync_sentinel
                ):
                    sync_wrappers[f"{property_name}_sync"] = async_to_sync(
                        property_value
                    )

        for sync_name, sync_wrapper in sync_wrappers.items():
            setattr(new_class, sync_name, sync_wrapper)

        return new_class


class WithSyncProtocol(type(t.Protocol), WithSyncMeta):  # type: ignore
    pass


def extract_client_ip_from_request(request: HttpRequest) -> str:
    logger.debug(
        "got request %r with REMOTE_ADDR %s and X-Forwarded-For %s",
        request,
        request.META.get("REMOTE_ADDR"),
        request.META.get("HTTP_X_FORWARDED_FOR"),
    )
    return get_client_ip(request.META)


def get_client_ip(headers: dict[str, str]) -> str:
    """parse the X-Forwarded-For header for an IP address or, if missing, return REMOTE_ADDR

    Note that this is unsafe, as it trusts the header which can be
    modified by clients under certain configurations. In our
    deployment, we always front django with a reverse proxy that does
    the right thing though, so this should be safe.

    The right-most IP address is the one we're interested in, as it's
    added by the closest proxy, according to:

    https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For#directives

    We assume Django takes care of merging the multiple
    X-Forwarded-For headers in a single list here.
    """
    # take the right-most IP address in the X-Forwarded-For header
    #
    # this follows the "Parsing" section of the above MDN docs, which says:
    #
    #   The count of reverse proxies between the internet and the
    #   server is configured. The X-Forwarded-For IP list is searched
    #   from the rightmost by that count minus one. (For example, if
    #   there is only one reverse proxy, that proxy will add the
    #   client's IP address, so the rightmost address should be
    #   used. If there are three reverse proxies, the last two IP
    #   addresses will be internal.)
    #
    # We assume only one proxy, so we just use the right-most IP
    # address.
    client_ip = headers.get("HTTP_X_FORWARDED_FOR", "").split(",").pop()
    # if we *don't* have a reverse proxy, this above will be the empty
    # string, so fall back to the normal REMOTE_ADDR variable.
    if not client_ip:
        client_ip = headers.get("REMOTE_ADDR", "")
    return client_ip.strip()


def make_valid_payload() -> str:
    """generates a proper altcha payload for testing"""
    challenge = secrets.token_hex(nbytes=16)
    payload_dict = {"challenge": challenge}
    json_str = json.dumps(payload_dict)
    encoded_bytes = base64.b64encode(json_str.encode("utf-8"))
    return encoded_bytes.decode("utf-8")
