import textwrap
from typing import TypedDict, final

import bs4
from django import forms
from django.contrib.auth.models import User
from django.core.cache import cache
from django.core.handlers.wsgi import WSGIRequest
from django.http import HttpResponse
from django.shortcuts import render
from django.utils import timezone
from typing_extensions import override

from vuln_demo.apps.vulnerabilities.levels.abc import LevelABC
from vuln_demo.apps.vulnerabilities.models import CompletedLevel
from vuln_demo.util.auth import cast_user


class CommentForm(forms.Form):
    """Form class passed to level 1 view."""

    text = forms.CharField(label="", widget=forms.Textarea())


class PostComment(TypedDict):
    """Data from submitted posts by the user (for cache)."""

    content: str
    created_at: str


@final
class Level6(LevelABC):
    """Scenario demonstrating XSS vulnerability."""

    HINT_AMOUNT = 4

    @override
    @classmethod
    def _get_hint(cls, request: WSGIRequest, hint_num: int) -> str:
        if hint_num == 1:
            return textwrap.dedent(
                """
                <p>
                    As you're browsing through the comments, you can't help but notice that some of them contain
                    curious styles and formatting. You wanted to leave a comment yourself, but you're not sure
                    how to make it stand out. There doesn't seem to be any formatting options available.
                    How did the earlier comments manage to do it?
                </p>
                """
            )
        if hint_num == 2:
            return textwrap.dedent(
                """
                <p>
                    Check how some of the nicely formatted posts look like in HTML source. Try to replicate
                    them by simply sending a comment that looks like that.
                </p>
                """
            )
        if hint_num == 3:
            return textwrap.dedent(
                """
                <p>
                    Did your first attempts at formatting make a difference in how your message appeared? Think
                    about other HTML elements that might not be so visible. What if you included something else?
                </p>
                """
            )
        if hint_num == 4:
            return textwrap.dedent(
                """
                <p>
                    Did you know that many modern websites leave a lot of the logic to the client-side?
                    Your browser will actually run any JavaScript code it receives from the server for this purpose.
                </p>
                <p>
                    Can you use this knowledge to send a malicous comment, that would make anyone that clicks on
                    this webpage run your own code?
                </p>
                """
            )

        raise

    @staticmethod
    def get_posts(user: User) -> list[PostComment]:
        """
        Obtain the cached posts (submitted by the user).

        This will return a list of posts submitted by the user (cached using :meth:`add_post`).
        These posts are cached for 1 day, and will be refreshed each time they're accessed.
        """
        key = f"level6-posts-{user.username}"

        if cached_posts := cache.get(key):
            cache.touch(key, timeout=86400)  # 1 day (refresh)
            return cached_posts

        return []

    @staticmethod
    def add_post(user: User, content: str, max_posts: int) -> list[PostComment]:
        """
        Add a post to the user's cache.

        This will add a new post to the user's posts cache, which will be displayed on the level 6 page.
        The cached posts can be obtained using :meth:`get_posts`.
        This post will be cached for 1 day.

        :param max_posts: Maximum number of posts allowed in the cache.
        :return: List of cached posts (including the new post).
        """
        key = f"level6-posts-{user.username}"

        post = PostComment(content=content, created_at=timezone.now().strftime("%Y-%m-%d"))

        posts = cache.get(key)
        if posts is None:
            posts = []
        posts.append(post)

        # In case someone makes a huge amount of posts start deleting the old ones,
        # keeping up to `max_posts` most recent posts.
        while len(posts) > max_posts:
            posts = posts[-max_posts:]

        cache.set(key, posts, timeout=86400)  # 1 day
        return posts

    @staticmethod
    def reset_posts(user: User) -> None:
        """Reset the posts cache for the user."""
        key = f"level6-posts-{user.username}"
        cache.delete(key)

    @override
    def get(self, request: WSGIRequest) -> HttpResponse:
        assert cast_user(request.user, cast=True)
        already_completed = CompletedLevel.check_completed(request.user, 6)
        user_posts = self.get_posts(request.user)

        form = CommentForm()
        return render(
            request,
            "vulnerabilities/levels/level6.html",
            {
                "form": form,
                "completed": already_completed,
                "user_posts": user_posts,
            },
        )

    def post(self, request: WSGIRequest) -> HttpResponse:
        """Handle comment form submission."""
        assert cast_user(request.user, cast=True)
        already_completed = CompletedLevel.check_completed(request.user, 6)
        user_posts = self.get_posts(request.user)

        form = CommentForm(request.POST)
        if not form.is_valid():
            return render(
                request,
                "vulnerabilities/levels/level6.html",
                {
                    "form": form,
                    "completed": already_completed,
                    "user_posts": user_posts,
                },
            )

        raw_comment = form.cleaned_data["text"]

        extra_args = {}
        if len(user_posts) >= 3:
            extra_args["info_msg"] = "Your comments were truncated, keeping only the last 3"
        user_posts = self.add_post(request.user, raw_comment, max_posts=3)

        # Reset the form, we don't want to re-send it with the old data.
        form = CommentForm()

        soup = bs4.BeautifulSoup(raw_comment, "html.parser")

        # There weren't any HTML tags in the comment
        if len(soup.find_all()) == 0:
            return render(
                request,
                "vulnerabilities/levels/level6.html",
                {
                    "form": form,
                    "completed": already_completed,
                    "user_posts": user_posts,
                    "note_msg": (
                        "You've submitted the message, but it did it bring you closer to finding an exploit?"
                    ),
                    **extra_args,
                },
            )

        # There weren't any script tags in the comment
        # This is a simple check to see if the comment contained a script tag, which is a common way to
        # inject malicious code into a website. That said, it is not a foolproof way to check for malicious
        # code. It is possible to inject malicious code without using a script tag, however, we don't really
        # care about that here. The users will very likely use a script tag first when learning about XSS.
        if soup.find("script") is None:
            return render(
                request,
                "vulnerabilities/levels/level6.html",
                {
                    "form": form,
                    "completed": already_completed,
                    "user_posts": user_posts,
                    "note_msg": (
                        "You've submitted the message, but it did it bring you closer to finding an exploit?"
                    ),
                    **extra_args,
                },
            )

        # comment contained a script tag
        self.reset_posts(request.user)  # reset the posts cache
        return self.completed_redirect(request, 6, skip_db=already_completed)
