import datetime
import time
from importlib import import_module

import django
from django.conf import settings
from django.contrib.sessions.backends.base import UpdateError
from django.core.exceptions import SuspiciousOperation
from django.http import parse_cookie
from django.http.cookie import SimpleCookie
from django.utils import timezone
from django.utils.encoding import force_str
from django.utils.functional import LazyObject
from django.utils.http import http_date

from channels.db import database_sync_to_async


class CookieMiddleware:
    """
    Extracts cookies from HTTP or WebSocket-style scopes and adds them as a
    scope["cookies"] entry with the same format as Django's request.COOKIES.
    """

    def __init__(self, inner):
        self.inner = inner

    async def __call__(self, scope, receive, send):
        # Check this actually has headers. They're a required scope key for HTTP and WS.
        if "headers" not in scope:
            raise ValueError(
                "CookieMiddleware was passed a scope that did not have a headers key "
                + "(make sure it is only passed HTTP or WebSocket connections)"
            )
        # Go through headers to find the cookie one
        for name, value in scope.get("headers", []):
            if name == b"cookie":
                cookies = parse_cookie(value.decode("latin1"))
                break
        else:
            # No cookie header found - add an empty default.
            cookies = {}
        # Return inner application
        return await self.inner(dict(scope, cookies=cookies), receive, send)

    @classmethod
    def set_cookie(
        cls,
        message,
        key,
        value="",
        max_age=None,
        expires=None,
        path="/",
        domain=None,
        secure=False,
        httponly=False,
        samesite="lax",
    ):
        """
        Sets a cookie in the passed HTTP response message.

        ``expires`` can be:
        - a string in the correct format,
        - a naive ``datetime.datetime`` object in UTC,
        - an aware ``datetime.datetime`` object in any time zone.
        If it is a ``datetime.datetime`` object then ``max_age`` will be calculated.
        """
        value = force_str(value)
        cookies = SimpleCookie()
        cookies[key] = value
        if expires is not None:
            if isinstance(expires, datetime.datetime):
                if timezone.is_aware(expires):
                    expires = timezone.make_naive(expires, timezone.utc)
                delta = expires - expires.utcnow()
                # Add one second so the date matches exactly (a fraction of
                # time gets lost between converting to a timedelta and
                # then the date string).
                delta = delta + datetime.timedelta(seconds=1)
                # Just set max_age - the max_age logic will set expires.
                expires = None
                max_age = max(0, delta.days * 86400 + delta.seconds)
            else:
                cookies[key]["expires"] = expires
        else:
            cookies[key]["expires"] = ""
        if max_age is not None:
            cookies[key]["max-age"] = max_age
            # IE requires expires, so set it if hasn't been already.
            if not expires:
                cookies[key]["expires"] = http_date(time.time() + max_age)
        if path is not None:
            cookies[key]["path"] = path
        if domain is not None:
            cookies[key]["domain"] = domain
        if secure:
            cookies[key]["secure"] = True
        if httponly:
            cookies[key]["httponly"] = True
        if samesite is not None:
            assert samesite.lower() in [
                "strict",
                "lax",
                "none",
            ], "samesite must be either 'strict', 'lax' or 'none'"
            cookies[key]["samesite"] = samesite
        # Write out the cookies to the response
        for c in cookies.values():
            message.setdefault("headers", []).append(
                (b"Set-Cookie", bytes(c.output(header="").strip(), encoding="utf-8"))
            )

    @classmethod
    def delete_cookie(cls, message, key, path="/", domain=None):
        """
        Deletes a cookie in a response.
        """
        return cls.set_cookie(
            message,
            key,
            max_age=0,
            path=path,
            domain=domain,
            expires="Thu, 01-Jan-1970 00:00:00 GMT",
        )


class InstanceSessionWrapper:
    """
    Populates the session in application instance scope, and wraps send to save
    the session.
    """

    # Message types that trigger a session save if it's modified
    save_message_types = ["http.response.start"]

    # Message types that can carry session cookies back
    cookie_response_message_types = ["http.response.start"]

    def __init__(self, scope, send):
        self.cookie_name = settings.SESSION_COOKIE_NAME
        self.session_store = import_module(settings.SESSION_ENGINE).SessionStore

        self.scope = dict(scope)

        if "session" in self.scope:
            # There's already session middleware of some kind above us, pass
            # that through
            self.activated = False
        else:
            # Make sure there are cookies in the scope
            if "cookies" not in self.scope:
                raise ValueError(
                    "No cookies in scope - SessionMiddleware needs to run "
                    "inside of CookieMiddleware."
                )
            # Parse the headers in the scope into cookies
            self.scope["session"] = LazyObject()
            self.activated = True

        # Override send
        self.real_send = send

    async def resolve_session(self):
        session_key = self.scope["cookies"].get(self.cookie_name)
        self.scope["session"]._wrapped = self.session_store(session_key)

    async def send(self, message):
        """
        Overridden send that also does session saves/cookies.
        """
        # Only save session if we're the outermost session middleware
        if self.activated:
            modified = self.scope["session"].modified
            empty = self.scope["session"].is_empty()
            # If this is a message type that we want to save on, and there's
            # changed data, save it. We also save if it's empty as we might
            # not be able to send a cookie-delete along with this message.
            if (
                message["type"] in self.save_message_types
                and message.get("status", 200) != 500
                and (modified or settings.SESSION_SAVE_EVERY_REQUEST)
            ):
                await self.save_session()
                # If this is a message type that can transport cookies back to the
                # client, then do so.
                if message["type"] in self.cookie_response_message_types:
                    if empty:
                        # Delete cookie if it's set
                        if settings.SESSION_COOKIE_NAME in self.scope["cookies"]:
                            CookieMiddleware.delete_cookie(
                                message,
                                settings.SESSION_COOKIE_NAME,
                                path=settings.SESSION_COOKIE_PATH,
                                domain=settings.SESSION_COOKIE_DOMAIN,
                            )
                    else:
                        # Get the expiry data
                        if self.scope["session"].get_expire_at_browser_close():
                            max_age = None
                            expires = None
                        else:
                            max_age = self.scope["session"].get_expiry_age()
                            expires_time = time.time() + max_age
                            expires = http_date(expires_time)
                        # Set the cookie
                        CookieMiddleware.set_cookie(
                            message,
                            self.cookie_name,
                            self.scope["session"].session_key,
                            max_age=max_age,
                            expires=expires,
                            domain=settings.SESSION_COOKIE_DOMAIN,
                            path=settings.SESSION_COOKIE_PATH,
                            secure=settings.SESSION_COOKIE_SECURE or None,
                            httponly=settings.SESSION_COOKIE_HTTPONLY or None,
                            samesite=settings.SESSION_COOKIE_SAMESITE,
                        )
        # Pass up the send
        return await self.real_send(message)

    async def save_session(self):
        """
        Saves the current session.
        """
        try:
            if django.VERSION >= (5, 1):
                await self.scope["session"].asave()
            else:
                await database_sync_to_async(self.scope["session"].save)()
        except UpdateError:
            raise SuspiciousOperation(
                "The request's session was deleted before the "
                "request completed. The user may have logged "
                "out in a concurrent request, for example."
            )


class SessionMiddleware:
    """
    Class that adds Django sessions (from HTTP cookies) to the
    scope. Works with HTTP or WebSocket protocol types (or anything that
    provides a "headers" entry in the scope).

    Requires the CookieMiddleware to be higher up in the stack.
    """

    def __init__(self, inner):
        self.inner = inner

    async def __call__(self, scope, receive, send):
        """
        Instantiate a session wrapper for this scope, resolve the session and
        call the inner application.
        """
        wrapper = InstanceSessionWrapper(scope, send)

        await wrapper.resolve_session()

        return await self.inner(wrapper.scope, receive, wrapper.send)


# Shortcut to include cookie middleware
def SessionMiddlewareStack(inner):
    return CookieMiddleware(SessionMiddleware(inner))
