from json import dumps, loads
from typing import NamedTuple, Dict, Any
from urllib.parse import unquote

from django.core.serializers.json import DjangoJSONEncoder
from django.http import HttpRequest, HttpResponse
from django.http.response import HttpResponseRedirectBase


class Source(NamedTuple):
    """Describes an element sourcing (triggering) a request."""

    id: str
    """The id of the triggered element if exists."""

    name: str
    """The name of the triggered element if exists."""


class Ajax:
    """Describes additional data sent with Ajax request.

    .. note:: The object is lazily initialized to allow faster
        middleware processing.

        Without initialization you won't be able to access it's attributes.
        For initialization it's enough to check it in boolean context, e.g.::

            bool(Ajax(request))

            # or

            if request.ajax:
                ...

    """
    __slots__ = ['is_used', 'url', 'source', 'target', 'user_input', 'restore_history', '_request']

    def __init__(self, request: HttpRequest):
        self._request = request

    def _boot(self):
        headers = self._request.headers

        self.is_used: bool = headers.get('Hx-Request', '') == 'true'
        """Indicates whether Ajax request is issued."""

        self.restore_history: bool = headers.get('HX-History-Restore-Request', '') == 'true'
        """Indicates the client side request to get the entire page 
        (as opposed to a page fragment request), when the client was 
        unable to restore a browser history state from the cache.
        
        """

        self.url: str = headers.get('Hx-Current-Url', '')
        """The current URL of the browser."""

        self.source: Source = Source(
            id=headers.get('HX-Trigger', ''),
            name=headers.get('HX-Trigger-Name', ''),
        )
        """Describes an element sourcing (triggering) a request."""

        self.target: str = headers.get('HX-Target', '')
        """The id of the target element if it exists."""

        self.user_input: str = headers.get('HX-Prompt', '')
        """The user input to a prompt (hx-prompt)."""

    def __bool__(self):
        self._boot()
        return self.is_used

    @property
    def event(self) -> dict:
        """Returns a dictionary describing a triggering event.

        Requires `event-header` extension:
            https://htmx.org/extensions/event-header/

        """
        headers = self._request.headers

        data = headers.get('Triggering-Event', '')
        if not data:
            return {}

        # encoded = headers.get('Triggering-Event-Uri-Autoencoded', '') == 'true'
        data = unquote(data)

        return loads(data)


class AjaxResponse(HttpResponse):
    """Represents a response object capable of driving a client side."""

    __slots__ = [
        '_wrapped', '_headers', '_triggers',
        'js_redirect',
        'history_item', 'redirect', 'refresh',
    ]

    _trigger_stages = {
        'receive': 'HX-Trigger',
        'settle': 'HX-Trigger-After-Settle',
        'swap': 'HX-Trigger-After-Swap',
    }

    def __init__(self, response: HttpResponse, *, js_redirect: bool = True, **kwargs):
        """

        :param response: Base response object.

        :param js_redirect: Whether to convert a redirect response object
            into an instruction for a client js library.

            * True - redirect is handled by a client side js library. Js library
                will get a result from this response.

            * False - redirect is handled by a browser. Js library will get
                the result from an URL browser has redirected it to.

        """
        super().__init__(**kwargs)

        self._wrapped = response
        self.js_redirect: bool = js_redirect

        self._triggers: Dict[str, Dict[str, Any]] = {
            'receive': {},
            'settle': {},
            'swap': {},
        }

        self.history_item: str = ''
        """Allows to push a new url into the browser history stack."""

        self.redirect: str = ''
        """Instructs a client-side redirect to a new location."""

        self.refresh: bool = False
        """Instructs a client side to make full refresh of the page."""

    @property
    def wrapped_response(self) -> HttpResponse:
        """Returns an base response modified to allow client driving."""

        response = self._wrapped
        headers = getattr(response, 'headers', None)

        if headers is None:  # pragma: nocover
            # pre Django 3.2
            headers = response

        val = self.history_item
        if val:
            headers['HX-Push'] = val

        val = self.redirect
        if val:
            headers['HX-Redirect'] = val

        if self.redirect:
            headers['HX-Refresh'] = 'true'

        if self.js_redirect and isinstance(response, HttpResponseRedirectBase):
            self.redirect = response.url
            del headers['location']  # Do not trigger browser redirect

        # Now encode event triggers data.
        trigger_stages = self._trigger_stages

        for stage, events in self._triggers.items():

            if not events:
                continue

            header_key = trigger_stages.get(stage)

            if header_key:
                headers[header_key] = dumps(events, cls=DjangoJSONEncoder)

        return response

    def trigger_event(self, *, name: str, kwargs: Dict[str, Any] = None, step: str = 'receive'):
        """Can be used to trigger client side actions on the target element within a response.

        .. code-block::
            // Python
            response.trigger_event(name='myEvent', kwargs={'one': {'two': 3}})

            // JS
            document.body.addEventListener('myEvent', function(event){
                console.log(event.detail.one.two);
            })

        :param name: Event name to trigger.

        :param kwargs: Keyword arguments to pass to an event.
            Those will be available from event.detail object.

        :param step: When to trigger this event.

            https://htmx.org/docs/#request-operations

            * receive - trigger events as soon as the response is received. Default.
            * settle - trigger events after the settling step.
            * swap - trigger events after the swap step.

        """
        self._triggers[step][name] = kwargs or {}