import json
import logging
import time
from html import escape
from typing import Any, AsyncIterator, Callable, List, Optional, cast
from urllib.parse import urlencode, urlparse

import httpx
from django import forms
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import login as auth_login
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.core.paginator import Paginator
from django.db.models import F, Prefetch, Q
from django.db.models.base import sync_to_async
from django.http import (
    HttpRequest,
    HttpResponse,
    HttpResponseBadRequest,
    HttpResponseForbidden,
    HttpResponseNotAllowed,
    HttpResponseRedirect,
    JsonResponse,
    StreamingHttpResponse,
)
from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.http import require_GET, require_POST

import promptgame.gameui.attack
import promptgame.gameui.notifications as notify
import promptgame.gameui.redis_utils as ru
from promptgame.gameui import (
    llm_wrappers,
    matchmaking,
    restrictions,
    user_account_tools,
    utils,
)
from promptgame.gameui.attack import PermissionDenied
from promptgame.gameui.attack import SSEMessages as SM
from promptgame.gameui.attack import (
    async_make_auth_and_transfer,
    get_transfer_charges,
    make_flagged_auth,
    refresh_restriction_if_old,
    user_attack_permission,
    validate_attacker_input,
    validate_sandbox_input,
)
from promptgame.gameui.attack_lists import AttackListForm, render_paginated_table
from promptgame.gameui.cooldown import accounts_with_cooldown_info
from promptgame.gameui.form_helpers import RequiredAnyLengthCharField
from promptgame.gameui.models import (
    LLM_CHOICES,
    Account,
    AuthAttempt,
    Defense,
    Flag,
    LoginURL,
)
from promptgame.gameui.tiers import valid_tier
from promptgame.gameui.user_account_tools import (
    async_get_authenticated_user,
    get_account,
)

log = logging.getLogger(__name__)


def robots(request: HttpRequest) -> HttpResponse:
    """Return a robots.txt"""
    contents = """User-agent: *
Allow: /"""
    return HttpResponse(contents, content_type="text/plain")


def sitemap(request: HttpRequest) -> HttpResponse:
    """Return a sitemap.xml with important pages for Google."""
    # get the protocol (https or http) in a way that works on localhost
    protocol = request.scheme
    host = request.get_host()
    contents = render_to_string("sitemap.xml", {"prefix": f"{protocol}://{host}"})
    return HttpResponse(contents, content_type="application/xml")


def default_next(request: HttpRequest) -> str:
    """Get the next page to redirect to (for login and create account pages)."""
    # for GET requests, try using "next" parameter; for POST requests, try using
    # "next" parameter in POST dict; otherwise return reverse("overview")
    next_page = None
    if request.method == "GET":
        next_page = request.GET.get("next", None)
    elif request.method == "POST":
        next_page = request.POST.get("next", None)
    if next_page is None:
        next_page = reverse("overview")
    return next_page


def login(request: HttpRequest) -> HttpResponse:
    """Dummy login that auto-creates a user and logs in as them if the user is
    not already authenticated."""
    next_page = default_next(request)

    if not request.user.is_authenticated:
        # redirect to the create_account page
        # (if the next page is not None, pass it along as a GET parameter)
        create_account_url = reverse("create_account")
        if next_page is not None:
            # safely append next={next_page} to the url (with escaping)
            create_account_url += "?" + urlencode({"next": next_page})
        return HttpResponseRedirect(create_account_url)

    log.debug(
        f"Authenticated user {request.user.username} and redirecting to {next_page}"
    )

    return HttpResponseRedirect(next_page)


def plain_template_view(template_path: str) -> Callable[[HttpRequest], HttpResponse]:
    """Return a view that renders a template with no context."""
    return lambda request: render(
        request,
        template_path,
        {"account": user_account_tools.maybe_get_account(request.user)},
    )


async def captcha_post(request: HttpRequest) -> HttpResponse:
    if request.method != "POST":
        return HttpResponseNotAllowed(["POST"])

    # Check the CAPTCHA
    recaptcha_response = request.POST.get("g-recaptcha-response")
    # TODO(sam): include remoteip in the request (maybe it helps with CAPTCHA
    # farms?): https://developers.google.com/recaptcha/docs/verify#api_request
    data = {"secret": settings.RECAPTCHA_SECRET_KEY, "response": recaptcha_response}

    async with httpx.AsyncClient() as client:
        if settings.DEBUG:
            logging.warning("Skipping CAPTCHA check because settings.DEBUG is True")
            result = {"success": True}
        else:
            logging.info("Checking submitted CAPTCHA")
            response = await client.post(
                "https://www.google.com/recaptcha/api/siteverify", data=data
            )
            result = response.json()

    if not result["success"]:
        # verification failed! Handle this
        logging.warning(f"CAPTCHA failed: {result=}")
        messages.error(request, "Captcha Failed. Please try again.")
        return redirect(reverse("create_account"))

    # await and sync_to_nosync these two calls as Django doesn't like it otherwise.
    user, account = await sync_to_async(user_account_tools.create_user_and_account)()
    await sync_to_async(auth_login)(request, user)

    return HttpResponseRedirect(reverse("subaccounts"))


@require_GET
def create_account(request: HttpRequest) -> HttpResponse:
    next_page = default_next(request)
    if request.user.is_authenticated:
        return redirect(next_page)

    new_domain = settings.NEW_DOMAIN
    if new_domain:
        parsed_new_domain = urlparse(new_domain)
        parsed_request_url = urlparse(request.build_absolute_uri())
        if parsed_new_domain.hostname != parsed_request_url.hostname:
            return redirect(new_domain, permanent=True)

    # for non-POST requests, we just render the create_account template
    return render(
        request,
        "create_account.html",
        context={
            "recaptcha_site_key": settings.RECAPTCHA_SITE_KEY,
            "next": next_page,
            "account_creation_locked": settings.ACCOUNT_CREATION_LOCKED,
        },
    )


def leaderboard(request: HttpRequest) -> HttpResponse:
    account = user_account_tools.maybe_get_account(request.user)

    # Filter out accounts where unlock_at is null (i.e. they haven't made an initial defense)
    accounts = (
        accounts_with_cooldown_info(attacker=account)
        .order_by("-balance", "account_id")
        .filter(unlock_at__isnull=False)
    )
    paginator = Paginator(accounts, settings.LEADERBOARD_PAGE_SIZE)

    page_parameter = request.GET.get("page")
    try:
        page_number = max(1, min(int(page_parameter or 0), paginator.num_pages))
    except ValueError:
        page_number = 1

    if account and not page_parameter:
        account = user_account_tools.get_account(request.user)
        rank = (
            accounts.filter(
                Q(balance__gt=account.balance)
                | Q(
                    balance=account.balance,
                    account_id__lt=account.account_id,
                )
            ).count()
            + 1
        )
        page_number = (rank // settings.LEADERBOARD_PAGE_SIZE) + 1

    page = paginator.get_page(page_number)

    start_index = max(1, page_number - settings.LEADERBOARD_NUM_LEFT_PAGES)
    end_index = min(
        paginator.num_pages + 1, page_number + settings.LEADERBOARD_NUM_RIGHT_PAGES + 1
    )
    page_numbers = range(start_index, end_index)

    return render(
        request,
        "leaderboard.html",
        {
            "account": account,
            "page": page,
            "page_number": page_number,
            "page_numbers": page_numbers,
        },
    )


@login_required
def overview(request: HttpRequest) -> HttpResponse:
    # FIXME(sam): add pagination/infinite scroll (maybe on a different page)
    account = user_account_tools.get_account(request.user)
    with_cooldown = accounts_with_cooldown_info(account)
    preloaded_auth_attempts = (
        AuthAttempt.objects.all()
        .prefetch_related(Prefetch("defending_account", queryset=with_cooldown))
        .prefetch_related(Prefetch("attacking_account", queryset=with_cooldown))
    )
    num_total_auth_attempts = preloaded_auth_attempts.count()

    top_auth_attempts = AuthAttempt.objects.filter(
        timestamp__gte=timezone.now() - timezone.timedelta(days=7)
    ).order_by("-defending_transfer_amount")[:3]

    recent_auth_attempts = (
        preloaded_auth_attempts.filter(
            timestamp__gte=timezone.now() - timezone.timedelta(hours=24),
            was_successful=True,
        )
        .exclude(attacking_account=F("defending_account"))
        .order_by("-timestamp")[: settings.NUM_RECENT_AUTH_ATTEMPTS_BIG_SCREEN]
    )

    attacking_auth_attempts = (
        preloaded_auth_attempts.filter(
            attacking_account=account,
        )
        .exclude(defending_account=account)
        # We don't include attacking_net_transfer in the net_transfer_amount
        # because the attacker's transfer comes from their shadow balance,n
        # not the one shown to users.
        .annotate(net_transfer_amount=F("defending_transfer_amount"))
        .order_by("-timestamp")[:50]
    )

    defending_auth_attempts = (
        preloaded_auth_attempts.filter(defending_account=account)
        .exclude(attacking_account=account)
        .annotate(
            net_transfer_amount=F("attacking_transfer_amount")
            - F("defending_transfer_amount")
        )
        .order_by("-timestamp")[:50]
    )

    self_attack_attempts = preloaded_auth_attempts.filter(
        attacking_account=account, defending_account=account
    ).order_by("-timestamp")[:50]

    return render(
        request,
        "overview.html",
        {
            "account": account,
            "num_total_auth_attempts": num_total_auth_attempts,
            "top_auth_attempts": top_auth_attempts,
            "recent_auth_attempts": recent_auth_attempts,
            "attacking_auth_attempts": attacking_auth_attempts,
            "defending_auth_attempts": defending_auth_attempts,
            "self_attack_attempts": self_attack_attempts,
            "max_recent_auth_attempts_mobile": settings.NUM_RECENT_AUTH_ATTEMPTS_MOBILE,
            "login_url": get_login_url(request),
        },
    )


@login_required
@require_GET
def security(request: HttpRequest) -> HttpResponse:
    account = user_account_tools.get_account(request.user)
    return render(
        request,
        "security.html",
        {
            "account": account,
            "transfer_percent_with_post_prompt": settings.TRANSFER_CHARGE_FRACTION_WITH_POST_PROMPT
            * 100,
            "transfer_percent_without_post_prompt": settings.TRANSFER_CHARGE_FRACTION_WITHOUT_POST_PROMPT
            * 100,
            "auto_unlock_delta": settings.AUTO_UNLOCK_DELTA,
            "cooldown_delta": settings.COOLDOWN_MAX_DELTA,
        },
    )


def _info_message_response(*, type: str, message: str) -> HttpResponse:
    return utils.render_html_response(
        "chat/info_message.html",
        type=type,
        message=message,
    )


def _security_update_response(
    *, message_type: str, message: str, llm_response: str
) -> HttpResponse:
    return utils.render_html_response(
        "chat/security_update_response.html",
        message_type=message_type,
        message=message,
        llm_response=llm_response,
    )


async def security_submit(request: HttpRequest) -> HttpResponse:
    if request.method != "POST":
        return HttpResponseNotAllowed(["POST"])
    user = await async_get_authenticated_user(request)
    if user is None:
        log.warning("User is not authenticated, but is submitting a defense?")
        return HttpResponseForbidden()
    try:
        account = await Account.objects.aget(owner=user)
    except Account.DoesNotExist:
        log.warning(f"User {user.username} does not have an account")
        return HttpResponseBadRequest()

    log.info(f"Attempting to save new defense for {user=} and {account=}")

    access_code = request.POST.get("access_code", "")
    defense_pre_prompt = request.POST.get("defense_pre_prompt", "")
    defense_post_prompt = request.POST.get("defense_post_prompt", "")
    llm_choice = request.POST.get("llm_choice")

    if (
        utils.is_null_or_white_space(defense_pre_prompt)
        and utils.is_null_or_white_space(defense_post_prompt)
    ) or utils.is_null_or_white_space(access_code):
        log.info("Defense validation failed: some component is missing")
        return _info_message_response(
            type="error",
            message="❌ Your defense wasn't saved: one of the fields is missing.",
        )
    elif (
        defense_pre_prompt is not None
        and not utils.get_number_tokens(
            defense_pre_prompt, settings.MAX_DEFENSE_PRE_PROMPT_LENGTH_TOKENS
        ).verification_passed
    ):
        log.info("Defense validation failed: pre-prompt is too long")
        return _info_message_response(
            type="error",
            message=f"❌ Your defense wasn't saved: your opening defense is too long (>{settings.MAX_DEFENSE_PRE_PROMPT_LENGTH_TOKENS} tokens)",
        )
    elif (
        defense_post_prompt is not None
        and not utils.get_number_tokens(
            defense_post_prompt, settings.MAX_DEFENSE_POST_PROMPT_LENGTH_TOKENS
        ).verification_passed
    ):
        log.info("Defense validation failed: post-prompt is too long")
        return _info_message_response(
            type="error",
            message=f"❌ Your defense wasn't saved: your closing defense is too long (>{settings.MAX_DEFENSE_POST_PROMPT_LENGTH_TOKENS} tokens)",
        )
    elif (
        access_code is not None
        and not utils.get_number_tokens(
            access_code, settings.MAX_ACCESS_CODE_LENGTH_TOKENS
        ).verification_passed
    ):
        log.info("Defense validation failed: access code is too long")
        return _info_message_response(
            type="error",
            message=f"❌ Your defense wasn't saved: your access code is too long (>{settings.MAX_ACCESS_CODE_LENGTH_TOKENS} tokens)",
        )
    elif llm_choice not in Defense.llm_choice_to_name:
        log.info(f"User tried to set defense with invalid model: {llm_choice=}")
        if llm_choice is None:
            # Sam: added this friendly error on 2023-10-26 so that active users
            # don't see crazy error messages once we deploy the llm_choice
            # change to Cloud Run (the app currently doesn't pass an llm_choice).
            # Can remove after a few hours/days.
            return _info_message_response(
                type="error",
                message="❌ Your defense wasn't saved due to an invalid request (no "
                "LLM chosen). Try refreshing the page and saving again.",
            )
        return _info_message_response(
            type="error",
            message=f"❌ Your defense wasn't saved: invalid AI model '{llm_choice}'",
        )
    else:
        llm_response_chunks = []
        api_success_flags = []
        llm_choice_enum = llm_wrappers.LLMChoice[llm_choice]

        with utils.log_timer("llm_stream_latency"):
            async for chunk, flag in promptgame.gameui.attack.streaming_llm_output(
                attacking_account=account,
                attack_prompt=access_code,
                defense_pre_prompt=defense_pre_prompt,
                defense_post_prompt=defense_post_prompt,
                llm_choice=llm_choice_enum,
                max_tokens=settings.MAX_SET_DEFENSE_RESPONSE_TOKENS,
                enable_gpt_repeat_hack_for_defenses=False,
            ):
                llm_response_chunks.append(chunk)
                api_success_flags.append(flag)

        llm_response_chunks = cast(List[str], llm_response_chunks)
        llm_response = "".join(llm_response_chunks)
        is_correct = settings.ACCESS_GRANTED_REGEX.match(llm_response) is not None
        queried_api_successfully = all(api_success_flags)
        if not queried_api_successfully:
            log.info("Defense saving failed due to API error")
            return _info_message_response(
                type="error",
                message=f"❌ Your defense wasn't saved: {llm_response}",
            )

        full_input = "\n".join([defense_pre_prompt, access_code, defense_post_prompt])

        with utils.log_timer("moderation_latency"):
            moderation_response = await llm_wrappers.content_moderation(full_input)

        defense = await Defense.objects.acreate(
            creator_account=account,
            pre_prompt=defense_pre_prompt,
            post_prompt=defense_post_prompt,
            access_code=access_code,
            access_code_output=llm_response,
            llm_choice=llm_choice_enum.name,
        )

        match moderation_response:
            case llm_wrappers.ModerationResponseInappropriate(
                failure_message=moderation_failure_message,
                endpoint_metadata=moderation_metadata,
            ):
                log.info("Moderation: Defense flagged as inappropriate")
                await Flag.objects.acreate(
                    report_source="automatic",
                    defense=defense,
                    metadata=moderation_metadata,
                )
                return _info_message_response(
                    type="error",
                    message=f"❌ Your defense wasn't saved: {moderation_failure_message}",
                )
            case llm_wrappers.ModerationResponseAPIError(
                failure_message=moderation_failure_message
            ):
                # If moderation fails, let the user save the defense anyway but
                # leave a flag saying it wasn't checked by an automatic
                # moderator.
                log.info(
                    "Moderation: API error but letting defense through "
                    f"anyway: {moderation_failure_message}"
                )
                defense.auto_moderated = False
                await defense.asave()
            case llm_wrappers.ModerationResponseOK(endpoint_metadata=_):
                log.info("Moderation: Defense passed")
            case _:
                raise RuntimeError(f"cannot handle {moderation_response=}")

        if is_correct:
            log.info("Saving defense")
            message_type = "success"
            message = '✅ Your defense was saved: the AI said "Access Granted" given the access code.'
            account.active_defense = defense
            await account.asave()

        else:
            log.info("Defense not saved due to incorrect access code")
            message_type = "error"
            message = "❌ Your defense wasn't saved: the AI didn't say \"Access Granted\" given the access code."

        return _security_update_response(
            message_type=message_type,
            message=message,
            llm_response=llm_response,
        )


@login_required
def subaccounts(request: HttpRequest) -> HttpResponse:
    account = user_account_tools.get_account(request.user)
    target_accounts = matchmaking.get_or_update_targets(account)
    balance = account.balance
    has_protected_account = any(
        not valid_tier(balance, account.balance) for account in target_accounts
    )
    return render(
        request,
        "subaccounts.html",
        {
            "accounts": target_accounts,
            "account": request.user.account,
            "tutorial_accounts": settings.TUTORIAL_ACCOUNTS,
            "has_protected_account": has_protected_account,
            "transfer_percent_with_post_prompt": settings.TRANSFER_CHARGE_FRACTION_WITH_POST_PROMPT
            * 100,
            "transfer_percent_without_post_prompt": settings.TRANSFER_CHARGE_FRACTION_WITHOUT_POST_PROMPT
            * 100,
        },
    )


@login_required
def subaccounts_refresh(request: HttpRequest) -> HttpResponse:
    account = user_account_tools.get_account(request.user)
    target_accounts = matchmaking.get_or_update_targets(account, force_refresh=True)
    # Variable which is True if any of the accounts are not in a valid tier.
    balance = account.balance
    has_protected_account = any(
        not valid_tier(balance, account.balance) for account in target_accounts
    )

    return render(
        request,
        "subaccounts_refresh.html",
        {
            "account": account,
            "accounts": target_accounts,
            "has_protected_account": has_protected_account,
        },
    )


# attack view for subaccount
@login_required
@require_GET
def subaccount_login(request: HttpRequest, account_id: int) -> HttpResponse:
    account = user_account_tools.get_account(request.user)
    subaccount = get_object_or_404(
        accounts_with_cooldown_info(attacker=account), account_id=account_id
    )

    # Determine if the user can attack
    match user_attack_permission(attacker=account, defender=subaccount):
        case PermissionDenied(user_message=user_message):
            can_attack = False
            denial_message = user_message
        case _:
            can_attack = True
            denial_message = None

    # Calculate attack success probability
    total_attack_query = AuthAttempt.objects.filter(
        attacking_account=subaccount
    ).exclude(defending_account=subaccount)
    successful_attack_attempt_query = total_attack_query.filter(was_successful=True)
    total_attacks = total_attack_query.count()
    successful_attack_attempts = successful_attack_attempt_query.count()

    attack_success_probability = (
        (successful_attack_attempts / total_attacks) * 100 if total_attacks > 0 else 0
    )

    # Calculate defense success probability
    total_defend_query = AuthAttempt.objects.filter(
        defending_account=subaccount
    ).exclude(attacking_account=subaccount)
    successful_defend_attempt_query = total_defend_query.filter(was_successful=False)
    total_defends = total_defend_query.count()
    successful_defends_attempts = successful_defend_attempt_query.count()

    defend_success_probability = (
        (successful_defends_attempts / total_defends) * 100 if total_defends > 0 else 0
    )
    xfer_charges = get_transfer_charges(
        attacking_account=account, defending_account=subaccount
    )
    refresh_restriction_if_old(subaccount)
    restriction = restrictions.get_restriction(subaccount)
    show_restriction = restrictions.check_restriction_enabled(account, subaccount)
    restriction_message = restriction.description if show_restriction else None

    # Get last break in
    successful_attack_against_subaccount_query = total_defend_query.filter(
        was_successful=True
    ).order_by("-timestamp")
    last_break_in_auth_attempt = successful_attack_against_subaccount_query.first()
    last_break_in = (
        last_break_in_auth_attempt.timestamp if last_break_in_auth_attempt else None
    )

    return render(
        request,
        "subaccount_guess_pw.html",
        {
            "subaccount": subaccount,
            "restriction_message": restriction_message,
            "account": account,
            "can_attack": can_attack,
            "denial_message": denial_message,
            "attacker_input": request.GET.get("attacker_input", ""),
            "llm_response": request.GET.get("llm_response", ""),
            "transfer_percent_with_post_prompt": settings.TRANSFER_CHARGE_FRACTION_WITH_POST_PROMPT
            * 100,
            "transfer_percent_without_post_prompt": settings.TRANSFER_CHARGE_FRACTION_WITHOUT_POST_PROMPT
            * 100,
            "steal_amount": xfer_charges.steal_amount,
            "total_attacks": total_attacks,
            "total_defends": total_defends,
            "successful_attack_attempts": successful_attack_attempts,
            "successful_defend_attempts": successful_defends_attempts,
            "attack_success_probability": attack_success_probability,
            "defend_success_probability": defend_success_probability,
            "last_break_in": last_break_in,
            "join_date": subaccount.owner.date_joined.strftime("%B %-d, %Y"),
        },
    )


async def unlock_account(request: HttpRequest) -> HttpResponse:
    if request.method != "POST":
        # return a 405
        return HttpResponseNotAllowed(["POST"])
    user = await async_get_authenticated_user(request)
    if user is None or not user.is_authenticated:
        # Here the user not logged in (might happen if cookies expire or
        # something while browsing). We flash a message and redirect them to the
        # create account page.
        # TODO(sam): once we have a login page, redirect them to that.
        messages.error(request, "You must be logged in to unlock an account.")
        return redirect(reverse("create_account"))
    account = await Account.objects.aget(owner=user)
    log.info(f"Unlocking {account=} for {user=}")
    # unlocking account: set unlock time to the past
    account.unlock_at = timezone.now() - timezone.timedelta(minutes=1)
    await account.asave()
    redis_client = ru.get_async_client()
    await notify.async_send_event(
        event=notify.LockStateChanged(
            locked=account.locked,
            unlock_at_isoformat=account.unlock_at.isoformat(),
            account_id=account.account_id,
        ),
        to_account_id=account.account_id,
        redis_client=redis_client,
    )
    return redirect(reverse("subaccounts"))


async def streaming_login_response(
    attacker_account: Account, defender_account: Account, attacker_input: str
) -> AsyncIterator[bytes]:
    # start by clearing the response area
    yield SM.clear_response_area()

    # Attack permission for attacker and defender
    match user_attack_permission(attacker=attacker_account, defender=defender_account):
        case PermissionDenied(user_message=user_message, log_message=log_message):
            log.info(log_message)
            yield SM.add_message_bubble(message=user_message, type="error")
            yield SM.trigger_events("attack-failure")
            return

    error_message = validate_attacker_input(
        attacker_input, attacker_account, defender_account
    )
    if error_message is not None:
        yield SM.add_message_bubble(message=error_message, type="error")
        yield SM.trigger_events("attack-failure")
        return

    # Attack moderation
    with utils.log_timer("moderation_latency"):
        moderation_response = await llm_wrappers.content_moderation(attacker_input)

    auto_moderated = True
    match moderation_response:
        case llm_wrappers.ModerationResponseInappropriate(
            failure_message=moderation_failure_message,
            endpoint_metadata=moderation_metadata,
        ):
            log.info(
                "Attack moderation failed because OpenAI API reported it was "
                "inappropriate"
            )
            await make_flagged_auth(
                attacking_account=attacker_account,
                defending_account=defender_account,
                attacker_input=attacker_input,
                moderation_metadata=moderation_metadata,
            )
            yield SM.add_message_bubble(
                message=moderation_failure_message, type="error"
            )
            yield SM.trigger_events("attack-failure")
            return
        case llm_wrappers.ModerationResponseAPIError(
            failure_message=moderation_failure_message
        ):
            auto_moderated = False
            log.info(f"Moderation: API error: {moderation_failure_message}")
        case llm_wrappers.ModerationResponseOK(endpoint_metadata=_):
            # continue on to creation logic
            log.info("Attack moderation success")
        case _:
            raise RuntimeError(f"cannot handle {moderation_response=}")

    active_defense: Any = defender_account.active_defense
    if not isinstance(active_defense, Defense):
        # fix type error in pyre
        raise ValueError(f"Invalid defense {active_defense=} for {defender_account=}")
    # stream the escaped response back to the user

    with utils.log_timer("llm_stream_latency"):
        llm_response_chunks = promptgame.gameui.attack.streaming_llm_output(
            attacking_account=attacker_account,
            attack_prompt=attacker_input,
            defense_pre_prompt=active_defense.pre_prompt,
            defense_post_prompt=active_defense.post_prompt,
            llm_choice=active_defense.llm_choice_as_enum,
            max_tokens=settings.MAX_ATTACK_RESPONSE_TOKENS,
            enable_gpt_repeat_hack_for_defenses=True,
        )
        raw_chunks = []
        queried_api_successfully = True

        # Create an output to put the LLM response in
        yield SM.start_llm_response()

        async for chunk, chunk_queried_successfully in llm_response_chunks:
            raw_chunks.append(chunk)
            queried_api_successfully = (
                queried_api_successfully and chunk_queried_successfully
            )
            if chunk_queried_successfully:
                yield SM.append_to_llm_response_bubble(chunk)

    llm_response_raw = "".join(raw_chunks)
    is_correct = settings.ACCESS_GRANTED_REGEX.match(llm_response_raw) is not None

    if not queried_api_successfully:
        log.info("Attack failed due to OpenAI API error during streaming")
        yield SM.add_message_bubble(
            # FIXME(sam): what was the logic behind this way of showing errors?
            # Why are we putting the LLM response in a separate bubble?
            message=llm_response_raw,
            type="error",
        )
        yield SM.trigger_events("attack-failure")
        return

    with utils.log_timer("moderation_latency"):
        moderation_response = await llm_wrappers.content_moderation(llm_response_raw)

    match moderation_response:
        case llm_wrappers.ModerationResponseInappropriate:
            # Just log for now; a flag will be created in async_make_auth_and_transfer
            log.info("LLM response failed moderation")
        case llm_wrappers.ModerationResponseAPIError(
            failure_message=moderation_failure_message
        ):
            auto_moderated = False
            log.info(f"Moderation: API error: {moderation_response.failure_message}")

    # Store a record in the database of the attack
    transfer_result = await async_make_auth_and_transfer(
        attacking_account=attacker_account,
        defending_account=defender_account,
        was_successful=is_correct,
        attacker_input=attacker_input,
        llm_output=llm_response_raw,
        moderation_response=moderation_response,
        auto_moderated=auto_moderated,
    )

    testing_own_account = attacker_account.pk == defender_account.pk
    if not testing_own_account:
        # Send live balance/lock state notifications via Redis
        await _send_attack_notifications(
            attacker_account=attacker_account, defender_account=defender_account
        )

    if transfer_result.transfer_explanation is not None:
        yield SM.add_message_bubble(
            message=transfer_result.transfer_explanation, type="info"
        )

    # Give a message saying whether the attack was successful
    if is_correct:
        message = "🎉 Access granted. Your attack was successful!"
        yield SM.add_message_bubble(message=message, type="success")
        yield SM.add_message_bubble(
            message=f"{defender_account.account_name} was using "
            f"{active_defense.human_readable_llm_choice} as their AI model",
            type="info",
        )
        if not testing_own_account:
            yield SM.trigger_events("attack-success", "attack-success-other")
        else:
            yield SM.trigger_events("attack-success")
    else:
        message = "⛔ Access denied. Your attack failed!"
        yield SM.add_message_bubble(message=message, type="error")
        yield SM.trigger_events("attack-failure")


async def _send_attack_notifications(
    *, attacker_account: Account, defender_account: Account
) -> None:
    # make sure we're sending fresh data
    await attacker_account.arefresh_from_db()
    await defender_account.arefresh_from_db()
    redis_client = ru.get_async_client()
    # attacker_net_gain = defender_transfer_amount - attacker_transfer_amount
    # defender_net_gain = -attacker_net_gain
    await notify.async_send_event(
        event=notify.NewBalance(
            balance=attacker_account.balance,
            account_id=attacker_account.account_id,
        ),
        to_account_id=attacker_account.account_id,
        redis_client=redis_client,
    )
    await notify.async_send_event(
        event=notify.NewBalance(
            balance=defender_account.balance,
            account_id=defender_account.account_id,
        ),
        to_account_id=defender_account.account_id,
        redis_client=redis_client,
    )
    # TODO(sam): add attack notifications and lock updates
    # await notify.async_send_event(
    #     event=notify.AttackDetected(
    #         attacker_account_id=attacker_account.account_id,
    #         defender_account_id=defender_account.account_id,
    #         attack_successful=is_correct,
    #         amount_lost=max(0, -defender_net_gain),
    #         amount_gained=max(0, defender_net_gain),
    #     ),
    #     to_account_id=defender_account.account_id,
    #     redis_client=redis_client,
    # )
    assert defender_account.unlock_at is not None  # for pytype
    await notify.async_send_event(
        event=notify.LockStateChanged(
            locked=defender_account.locked,
            unlock_at_isoformat=defender_account.unlock_at.isoformat(),
            account_id=defender_account.account_id,
        ),
        to_account_id=defender_account.account_id,
        redis_client=redis_client,
    )


def _streamed_error(message: str) -> StreamingHttpResponse:
    # return a pseudo-SSE stream that just clears the output and sends an error
    # message
    async def generator() -> AsyncIterator[bytes]:
        yield SM.clear_response_area()
        yield SM.add_message_bubble(message=message, type="error")
        yield SM.trigger_events("attack-failure")

    return StreamingHttpResponse(
        generator(),
        content_type="text/event-stream",
    )


async def subaccount_login_submit(
    request: HttpRequest, account_id: int
) -> HttpResponse | StreamingHttpResponse:
    """Attempt to login to a subaccount. Async view that streams back escaped HTML."""
    generic_error = "Server error, try refreshing the page."
    if request.method != "POST":
        # return a readable error message
        # (status code is 200 even though there was an error)
        log.warning("Attempted to GET the subaccount login endpoint")
        return _streamed_error(generic_error)

    maybe_user = await async_get_authenticated_user(request)
    if maybe_user is None:
        # if the user is not authenticated, then we return a 400
        # TODO(sam): consider returning a more informative error message
        log.warning("User is not authenticated, but submitting solutions?")
        return _streamed_error(generic_error)
    # find the Account for this User
    user = maybe_user
    try:
        attacker_account = await Account.objects.aget(owner=user)
    except Account.DoesNotExist:
        # if the user doesn't have an account, then we return a 500
        log.warning(f"User {user.username} does not have an account")
        return _streamed_error(generic_error)
    # find the subaccount for account_id
    try:
        # Get their active_defense as well, since we need it to query the LLM.
        defender_account = (
            await accounts_with_cooldown_info(attacker=attacker_account)
            .select_related("active_defense")
            .aget(account_id=account_id)
        )
    except Account.DoesNotExist:
        # return 404
        log.warning(
            f"User {user.username} tried to login to non-existent account {account_id}"
        )
        return _streamed_error(generic_error)

    log.info(
        f"User {user=} with account {attacker_account=} is attempting "
        f"to log in to {defender_account=}"
    )

    # get attacker input from POST dict
    attacker_input = request.POST.get("attacker_input")
    if utils.is_null_or_white_space(attacker_input):
        log.warning(f"User {user.username} did not supply attacker_input")
        return _streamed_error(message="Attack failed: no attack text provided.")
    assert attacker_input is not None

    return StreamingHttpResponse(
        streaming_login_response(
            attacker_account=attacker_account,
            defender_account=defender_account,
            attacker_input=attacker_input,
        ),
        content_type="text/event-stream",
        headers={
            "X-Accel-Buffering": "no",
            "Cache-Control": "no-cache",
        },
    )


async def notifications_stream(user: User, account: Account) -> AsyncIterator[bytes]:
    # Close any open Django DB connections. Normally Django garbage-collects
    # these at the end of a request, but this view streams for a long time, so
    # we need to drop the connection manually here.
    await utils.async_close_db_connections()

    async with notify.MessageStore(for_account_id=account.account_id) as message_store:
        # ensure that we only listen for messages for a limited amount of time
        start_time = time.monotonic()
        while time.monotonic() - start_time < settings.NOTIFICATIONS_VIEW_LIFETIME:
            # send noop ping to detect dropped clients
            yield utils.sse_message(data="", event="noop")

            # get any fresh events (waiting up to NOTIFICATIONS_NOOP_INTERVAL if
            # there are no fresh events)
            events = await message_store.get_messages(
                wait_time=settings.NOTIFICATIONS_NOOP_INTERVAL
            )
            for event in events:
                match event:
                    case notify.NewBalance(balance=balance):
                        yield utils.sse_message(
                            data=escape(f"{balance} 💰"), event="new_balance"
                        )
                    case notify.LockStateChanged(locked=locked, unlock_at=unlock_at):
                        yield utils.sse_message(
                            data=escape("Locked 🔒" if locked else "Unlocked 🟢"),
                            event="lock_state_changed",
                        )
                        yield utils.sse_message(
                            data=render_to_string(
                                "lock_alert.html",
                                {
                                    "locked": locked,
                                    "has_been_attacked": locked,
                                    "auto_unlock_time_left": account._auto_unlock_time_left(
                                        unlock_at
                                    ),
                                },
                            ),
                            event="lock_state_changed_alert",
                        )
                    case notify.AttackDetected(
                        attacker_account_id=attacker_account_id,
                        defender_account_id=defender_account_id,
                        attack_successful=attack_successful,
                        amount_lost=amount_lost,
                        amount_gained=amount_gained,
                    ):
                        raise NotImplementedError(f"Cannot handle event {event}")
                    case _:
                        raise NotImplementedError(f"Cannot handle event {event}")


async def notifications(request: HttpRequest) -> HttpResponse | StreamingHttpResponse:
    if request.method != "GET":
        return HttpResponseNotAllowed(["GET"])
    user = await async_get_authenticated_user(request)
    if user is None:
        log.warning("User is not authenticated, but asking for notifications?")
        return HttpResponseBadRequest()
    try:
        account = await Account.objects.aget(owner=user)
    except Account.DoesNotExist:
        log.warning(f"User {user.username} does not have an account")
        return HttpResponseBadRequest()
    # FIXME(sam): this contains an infinite loop, so it never actually exits
    # when the response finishes. StreamingHttpResponse _does_ have a .closed
    # attribute, but there's no easy way to access it from within the async
    # generator. StreamingHttpResponse can also call close(), but async
    # iterators don't have a .close() attribute. I could fix this with a special
    # wrapper that has a .close attribute which it can pass to the
    # notifications_stream, but it all feels super hacky.
    return StreamingHttpResponse(
        notifications_stream(user=user, account=account),
        content_type="text/event-stream",
        # these headers ensure that nginx doesn't buffer the response
        headers={
            "X-Accel-Buffering": "no",
            "Cache-Control": "no-cache",
        },
    )


def get_login_url(request: HttpRequest) -> str:
    # See if there's an existing login url with the same user; otherwise make one
    login_url = LoginURL.objects.filter(user=request.user).first()
    if not login_url:
        login_url = LoginURL.objects.create(user=request.user)
    url = request.build_absolute_uri(
        reverse("login_with_url", args=[str(login_url.id)])
    )
    return url


def login_with_url(request, url_id):
    try:
        log.info(f"Logging in a user with {url_id=}")
        login_url = LoginURL.objects.get(id=url_id)
        user = login_url.user
        if user.is_superuser:
            return HttpResponseForbidden(
                "No login links for superusers since it's a security risk for this link to be in history."
            )
        auth_login(request, user)

        account = user_account_tools.get_account(request.user)

        return render(
            request,
            "login.html",
            {
                "account": account,
            },
        )
    except (LoginURL.DoesNotExist, ValidationError):
        # This will redirect to the create account page if the user is not logged in
        # or to the overview page if the user is already logged in.
        log.warning("Got an invalid login URL")
        messages.error(request, "Invalid login url.")
        return HttpResponseRedirect("/")


@login_required
@require_POST
def create_flag(request):
    account = user_account_tools.get_account(request.user)
    try:
        data = json.loads(request.body)

        authattempt = get_object_or_404(AuthAttempt, id=int(data.get("authattempt_id")))

        flag = Flag.objects.create(
            flagger=account,
            report_source="user",
            authattempt=authattempt,
        )

        return JsonResponse(
            {"success": True, "flag_id": flag.id, "message": "Created flag"}
        )
    except json.JSONDecodeError:
        return JsonResponse(
            {"success": False, "message": "Received invalid JSON"}, status=400
        )
    except Exception as e:
        return JsonResponse(
            {"success": False, "message": f"Received unexpected exception: {str(e)}"},
            status=500,
        )


@login_required
@require_POST
def check_token_length(request: HttpRequest) -> HttpResponse:
    # determine the type of attack to set the limit
    query_dict = request.POST
    field_name: Optional[str] = query_dict.get("field_name", None)
    query_dict_repr = utils.short_format_dict(query_dict)
    if field_name is None:
        log.error(f"No field name for check_token_length ({query_dict_repr})")
        return HttpResponseBadRequest()

    input_text: Optional[str] = query_dict.get(field_name)
    if input_text is None:
        log.error(f"No input text for check_token_length ({query_dict_repr})")
        return HttpResponseBadRequest()

    token_limit: Optional[int] = {
        "attacker_input": settings.MAX_ATTACK_LENGTH_TOKENS,
        "access_code": settings.MAX_ACCESS_CODE_LENGTH_TOKENS,
        "defense_pre_prompt": settings.MAX_DEFENSE_PRE_PROMPT_LENGTH_TOKENS,
        "defense_post_prompt": settings.MAX_DEFENSE_POST_PROMPT_LENGTH_TOKENS,
    }.get(field_name)
    if token_limit is None:
        log.error(f"Invalid field_name for token length checking ({query_dict_repr})")
        return HttpResponseBadRequest()

    # perform the token length check and return the number of tokens to the client
    token_validation = utils.get_number_tokens(input_text, token_limit)
    return render(
        request,
        "check_token_length.html",
        {
            "tokens_used": token_validation.num_tokens,
            "token_limit": token_limit,
            "verification_passed": token_validation.verification_passed,
        },
    )


@login_required
def auth_attempt_table(request: HttpRequest) -> HttpResponse:
    user = cast(User, request.user)
    account = get_account(user)
    form = AttackListForm(request.GET, current_account=account)

    if form.is_valid():
        table_html = render_paginated_table(
            my_account=account,
            attack_form=form,
        )
        return HttpResponse(table_html)

    log.error(f"Form validation error: {form.errors}")
    return HttpResponseBadRequest("Invalid input")


@login_required
def sandbox(request: HttpRequest) -> HttpResponse:
    account = user_account_tools.get_account(request.user)
    return render(request, "sandbox.html", {"account": account})


class SandboxForm(forms.Form):
    defense_pre_prompt = RequiredAnyLengthCharField()
    attacker_input = RequiredAnyLengthCharField()
    defense_post_prompt = RequiredAnyLengthCharField()
    llm_choice = forms.ChoiceField(
        choices=LLM_CHOICES,
        required=True,
    )


async def streaming_sandbox_response(
    account: Account,
    defense_pre_prompt: str,
    attacker_input: str,
    defense_post_prompt: str,
    llm_choice: llm_wrappers.LLMChoice,
) -> AsyncIterator[bytes]:
    yield SM.clear_response_area()

    error_message = validate_sandbox_input(
        defense_pre_prompt=defense_pre_prompt,
        attacker_input=attacker_input,
        defense_post_prompt=defense_post_prompt,
    )
    if error_message is not None:
        yield SM.add_message_bubble(message=error_message, type="error")
        yield SM.trigger_events("attack-failure")
        return

    # We don't bother with moderation in the sandbox, since the submissions are
    # not visible to other users.

    # stream the escaped response back to the user
    # TODO(sam): refactor this so we don't have duplication between this and the
    # subaccount_login_submit thing. Unfortunately refactoring is non-trivial
    # because this code is really an async generator, but you can't return
    # things from an async generator.
    with utils.log_timer("llm_stream_latency"):
        llm_response_chunks = promptgame.gameui.attack.streaming_llm_output(
            attacking_account=account,
            attack_prompt=attacker_input,
            defense_pre_prompt=defense_pre_prompt,
            defense_post_prompt=defense_post_prompt,
            llm_choice=llm_choice,
            max_tokens=settings.MAX_ATTACK_RESPONSE_TOKENS,
            enable_gpt_repeat_hack_for_defenses=False,
        )
        raw_chunks = []
        queried_api_successfully = True

        # Create an output to put the LLM response in
        yield SM.start_llm_response()

        async for chunk, chunk_queried_successfully in llm_response_chunks:
            raw_chunks.append(chunk)
            queried_api_successfully = (
                queried_api_successfully and chunk_queried_successfully
            )
            if chunk_queried_successfully:
                yield SM.append_to_llm_response_bubble(chunk)

    llm_response_raw = "".join(raw_chunks)
    is_correct = settings.ACCESS_GRANTED_REGEX.match(llm_response_raw) is not None

    if not queried_api_successfully:
        log.info("Attack failed due to OpenAI API error during streaming")
        yield SM.add_message_bubble(
            message=llm_response_raw,
            type="error",
        )
        yield SM.trigger_events("attack-failure")
        return

    # Store a record in the database of the attack
    await AuthAttempt.objects.acreate(
        attacking_account=account,
        defending_account=account,
        was_successful=is_correct,
        defense_pre_prompt=defense_pre_prompt,
        attacker_input=attacker_input,
        llm_choice=llm_choice.name,
        access_code="",
        defense_post_prompt=defense_post_prompt,
        llm_output=llm_response_raw,
        auto_moderated=False,
    )

    # Give a message saying whether the attack was successful
    if is_correct:
        message = "🎉 Access granted. Your attack was successful!"
        yield SM.add_message_bubble(message=message, type="success")
        yield SM.trigger_events("attack-success")
    else:
        message = "⛔ Access denied. Your attack failed!"
        yield SM.add_message_bubble(message=message, type="error")
        yield SM.trigger_events("attack-failure")


async def sandbox_submit(request: HttpRequest) -> StreamingHttpResponse:
    # TODO(sam): deduplicate this with subaccount_login_submit once I have a
    # better idea of what I want the API to look like.
    generic_error = "Server error, try refreshing the page."
    if request.method != "POST":
        log.warning("Attempted to GET the sandbox submit endpoint")
        return _streamed_error(generic_error)

    maybe_user = await async_get_authenticated_user(request)
    if maybe_user is None:
        log.warning("User is not authenticated, but using sandbox")
        return _streamed_error(generic_error)
    # find the Account for this User
    user = maybe_user
    account = await user_account_tools.async_get_account(user)

    log.info(f"User {user=} with account {account=} is submitting to the " "sandbox")

    form = SandboxForm(request.POST)
    if not form.is_valid():
        # the call to dict() is required, otherwise Django gives us a special
        # object that always serializes to a HTML string, not a more-readable
        # dictionary representation
        validation_error = str(dict(form.errors.as_data()))
        log.warning(
            f"User {user.username} submitted invalid sandbox form (message: {validation_error[:50]}...)"
        )
        return _streamed_error(generic_error)

    return StreamingHttpResponse(
        streaming_sandbox_response(
            account=account,
            defense_pre_prompt=form.cleaned_data["defense_pre_prompt"],
            attacker_input=form.cleaned_data["attacker_input"],
            defense_post_prompt=form.cleaned_data["defense_post_prompt"],
            llm_choice=llm_wrappers.LLMChoice[form.cleaned_data["llm_choice"]],
        ),
        content_type="text/event-stream",
        headers={
            "X-Accel-Buffering": "no",
            "Cache-Control": "no-cache",
        },
    )


class TutorialForm(forms.Form):
    attacker_input = RequiredAnyLengthCharField()


async def streaming_tutorial_response(
    account: Account,
    defense_pre_prompt: str,
    attacker_input: str,
    defense_post_prompt: str,
    defense_access_code: str,
    llm_choice: llm_wrappers.LLMChoice,
    step: int,
    bounty: int,
) -> AsyncIterator[bytes]:
    yield SM.clear_response_area()

    error_message = validate_sandbox_input(
        defense_pre_prompt=defense_pre_prompt,
        attacker_input=attacker_input,
        defense_post_prompt=defense_post_prompt,
    )

    if error_message is not None:
        yield SM.add_message_bubble(message=error_message, type="error")
        yield SM.trigger_events("attack-failure")
        return

    with utils.log_timer("llm_stream_latency"):
        llm_response_chunks = promptgame.gameui.attack.streaming_llm_output(
            attacking_account=account,
            attack_prompt=attacker_input,
            defense_pre_prompt=defense_pre_prompt,
            defense_post_prompt=defense_post_prompt,
            llm_choice=llm_choice,
            max_tokens=settings.MAX_ATTACK_RESPONSE_TOKENS,
            enable_gpt_repeat_hack_for_defenses=False,
        )
        raw_chunks = []
        queried_api_successfully = True

        # Create an output to put the LLM response in
        yield SM.start_llm_response()

        async for chunk, chunk_queried_successfully in llm_response_chunks:
            raw_chunks.append(chunk)
            queried_api_successfully = (
                queried_api_successfully and chunk_queried_successfully
            )
            if chunk_queried_successfully:
                yield SM.append_to_llm_response_bubble(chunk)

    llm_response_raw = "".join(raw_chunks)
    is_correct = settings.ACCESS_GRANTED_REGEX.match(llm_response_raw) is not None
    response_contains_access_code = defense_access_code in llm_response_raw

    if not queried_api_successfully:
        log.info("Attack failed due to OpenAI API error during streaming")
        yield SM.add_message_bubble(
            message=llm_response_raw,
            type="error",
        )
        yield SM.trigger_events("attack-failure")
        return

    # Store a record in the database of the attack
    await AuthAttempt.objects.acreate(
        attacking_account=account,
        defending_account=account,
        was_successful=is_correct,
        defense_pre_prompt=defense_pre_prompt,
        llm_choice=llm_choice.name,
        attacker_input=attacker_input,
        access_code="",
        defense_post_prompt=defense_post_prompt,
        llm_output=llm_response_raw,
        auto_moderated=False,
    )

    # Give a message saying whether the attack was successful
    if is_correct:
        bounty_message = ""

        # If first completion of this step
        if account.tutorial_step < step + 1:
            # Award bounty
            # TODO: this should be in a transaction
            account.balance += bounty
            redis_client = ru.get_async_client()
            await notify.async_send_event(
                event=notify.NewBalance(
                    balance=account.balance,
                    account_id=account.account_id,
                ),
                to_account_id=account.account_id,
                redis_client=redis_client,
            )
            bounty_message = f"You earned {bounty} 💸."

            # Advance the user's tutorial progress
            account.tutorial_step = step + 1

        # Save the account
        await account.asave()

        yield SM.add_tutorial_success_bubble(
            step=step,
            bounty_message=bounty_message,
            is_new_user=account.unlock_at is None,
        )
        yield SM.trigger_events("attack-success")
    elif response_contains_access_code:
        message = "Seems like you found the access code! Input it next to gain access."
        yield SM.add_message_bubble(message=message, type="info")
        yield SM.trigger_events("attack-failure")
    else:
        message = "⛔ Access denied. Your attack failed!"
        yield SM.add_message_bubble(message=message, type="error")
        yield SM.trigger_events("attack-failure")


async def tutorial_submit(request: HttpRequest, step: int) -> StreamingHttpResponse:
    generic_error = "Server error, try refreshing the page."

    if request.method != "POST":
        log.warning("Attempted to GET the tutorial submit endpoint")
        return _streamed_error(generic_error)

    if step < 1 or step > len(settings.TUTORIAL_ACCOUNTS):
        log.warning(f"User tried to submit to invalid tutorial step {step}")
        return _streamed_error(generic_error)

    maybe_user = await async_get_authenticated_user(request)
    if maybe_user is None:
        log.warning("User is not authenticated, but using tutorial")
        return _streamed_error(generic_error)
    # find the Account for this User
    user = maybe_user
    try:
        account = await Account.objects.aget(owner=user)
    except Account.DoesNotExist:
        log.warning(f"User {user.username} does not have an account for the tutorial")
        return _streamed_error(generic_error)

    log.info(
        f"User {user=} with account {account=} is submitting to tutorial step {step}"
    )

    form = TutorialForm(request.POST)

    if not form.is_valid():
        # the call to dict() is required, otherwise Django gives us a special
        # object that always serializes to a HTML string, not a more-readable
        # dictionary representation
        validation_error = str(dict(form.errors.as_data()))
        log.warning(
            f"User {user.username} submitted invalid tutorial form (message: {validation_error[:50]}...)"
        )
        return _streamed_error(generic_error)

    subaccount = settings.TUTORIAL_ACCOUNTS[step - 1]
    return StreamingHttpResponse(
        streaming_tutorial_response(
            account=account,
            defense_pre_prompt=subaccount["active_defense"]["pre_prompt"],
            attacker_input=form.cleaned_data["attacker_input"],
            defense_post_prompt=subaccount["active_defense"]["post_prompt"],
            defense_access_code=subaccount["active_defense"]["access_code"],
            step=step,
            bounty=subaccount["bounty"],
            llm_choice=llm_wrappers.LLMChoice.GPT_35_TURBO_0613,
        ),
        content_type="text/event-stream",
        headers={
            "X-Accel-Buffering": "no",
            "Cache-Control": "no-cache",
        },
    )


@login_required
def tutorial(request: HttpRequest, step: int) -> HttpResponse:
    account = user_account_tools.get_account(request.user)

    if step < 1 or step > len(settings.TUTORIAL_ACCOUNTS):
        log.warning(
            f"User {account.account_name} tried to view invalid tutorial step {step}"
        )
        return redirect("subaccounts")

    return render(
        request,
        "tutorial.html",
        {
            "account": account,
            "attacker_input": request.GET.get("attacker_input", ""),
            "step": step,
            "tutorial_account": settings.TUTORIAL_ACCOUNTS[step - 1],
        },
    )


def paper(request: HttpRequest) -> HttpResponse:
    return render(request, "paper.html")
