BareGit
#include "app.h"

#include <algorithm>
#include <charconv>
#include <cctype>
#include <cstddef>
#include <cstdlib>
#include <filesystem>
#include <functional>
#include <memory>
#include <stdexcept>
#include <string>
#include <string_view>
#include <system_error>
#include <unordered_map>
#include <utility>
#include <vector>

#include <spdlog/spdlog.h>
#include <mw/crypto.hpp>
#include <mw/utils.hpp>

#include "email_sender_file.h"
#include "email_sender_mailjet.h"
#include "form_limits.h"
#include "game_field.h"
#include "public_id.h"
#include "multipart_reader.h"
#include "secret_token.h"

namespace
{

enum class RouteKind
{
    DYNAMIC,
    STATIC_MOUNT
};

struct RouteDefinition
{
    RouteKind kind;
    std::vector<RouteSegment> segments;
};

struct IndexCard
{
    const Card* card;
    std::string public_id;
};

struct HtmlSubstitution
{
    std::string marker;
    RenderedHtml html;
};

std::unique_ptr<EmailSenderInterface> makeEmailSender(const Config& config)
{
    if(config.email.transport == EmailTransport::FILE)
    {
        std::filesystem::path target = config.email.link_file;
        if(target.empty())
        {
            target = config.database_path.parent_path() /
                "latest-authentication-link.txt";
        }
        return std::make_unique<FileEmailSender>(std::move(target));
    }
    const char* api_key = std::getenv(
        config.email.mailjet_api_key_environment.c_str());
    const char* secret_key = std::getenv(
        config.email.mailjet_secret_key_environment.c_str());
    if(api_key == nullptr || *api_key == '\0' ||
       secret_key == nullptr || *secret_key == '\0')
    {
        throw std::runtime_error(
            "Mailjet credential environment variables are missing");
    }
    auto sender = std::make_unique<MailjetEmailSender>(
        std::make_unique<mw::HTTPSession>(),
        config.email.from_address,
        config.email.from_name,
        api_key,
        secret_key);
    auto configured = sender->configure();
    if(!configured)
    {
        throw std::runtime_error(
            "Failed to configure Mailjet transport: " +
            configured.error().msg());
    }
    return sender;
}

std::optional<std::string> cookieValue(
    const App::Request& request, std::string_view name)
{
    std::optional<std::string> result;
    const std::size_t header_count = request.get_header_value_count("Cookie");
    for(std::size_t header_index = 0;
        header_index < header_count;
        ++header_index)
    {
        const std::string header_value = request.get_header_value(
            "Cookie", nullptr, header_index);
        std::string_view header = header_value;
        while(!header.empty())
        {
            const std::size_t separator = header.find(';');
            std::string_view item = header.substr(0, separator);
            item = mw::strip(item);
            const std::size_t equals = item.find('=');
            if(equals != std::string_view::npos &&
               item.substr(0, equals) == name)
            {
                if(result)
                {
                    return std::nullopt;
                }
                result = std::string(item.substr(equals + 1));
            }
            if(separator == std::string_view::npos)
            {
                break;
            }
            header.remove_prefix(separator + 1);
        }
    }
    return result;
}

RouteSegment literal(std::string value)
{
    return {RouteSegmentKind::LITERAL, std::move(value)};
}

RouteSegment placeholder(std::string value)
{
    return {RouteSegmentKind::PLACEHOLDER, std::move(value)};
}

const std::unordered_map<std::string, RouteDefinition> ROUTES = {
    {"welcome", {RouteKind::DYNAMIC, {}}},
    {"authentication",
     {RouteKind::DYNAMIC, {literal("authentication")}}},
    {"authentication-email",
     {RouteKind::DYNAMIC,
      {literal("authentication"), literal("email")}}},
    {"authentication-sent",
     {RouteKind::DYNAMIC,
      {literal("authentication"), literal("sent")}}},
    {"authentication-confirm",
     {RouteKind::DYNAMIC,
      {literal("authentication"), literal("confirm"), placeholder("token")}}},
    {"logout", {RouteKind::DYNAMIC, {literal("logout")}}},
    {"onboarding",
     {RouteKind::DYNAMIC,
      {literal("onboarding"), literal("username")}}},
    {"account", {RouteKind::DYNAMIC, {literal("account")}}},
    {"account-username",
     {RouteKind::DYNAMIC,
      {literal("account"), literal("username")}}},
    {"collection", {RouteKind::DYNAMIC, {literal("collection")}}},
    {"collection-pull",
     {RouteKind::DYNAMIC,
      {literal("collection"), literal("pull")}}},
    {"creator-cards",
     {RouteKind::DYNAMIC,
      {literal("creator"), literal("cards")}}},
    {"card-index",
     {RouteKind::DYNAMIC, {literal("admin"), literal("cards")}}},
    {"admin-users",
     {RouteKind::DYNAMIC, {literal("admin"), literal("users")}}},
    {"admin-games",
     {RouteKind::DYNAMIC, {literal("admin"), literal("games")}}},
    {"game-new",
     {RouteKind::DYNAMIC,
      {literal("admin"), literal("games"), literal("new")}}},
    {"games",
     {RouteKind::DYNAMIC, {literal("admin"), literal("games")}}},
    {"game-edit",
     {RouteKind::DYNAMIC,
      {literal("admin"), literal("games"), placeholder("short"),
       literal("edit")}}},
    {"game-update",
     {RouteKind::DYNAMIC,
      {literal("admin"), literal("games"), placeholder("short")}}},
    {"game-delete",
     {RouteKind::DYNAMIC,
      {literal("admin"), literal("games"), placeholder("short"),
       literal("delete")}}},
    {"game-field-new",
     {RouteKind::DYNAMIC,
      {literal("admin"), literal("games"), placeholder("short"),
       literal("fields"), literal("new")}}},
    {"game-fields",
     {RouteKind::DYNAMIC,
      {literal("admin"), literal("games"), placeholder("short"),
       literal("fields")}}},
    {"game-field-edit",
     {RouteKind::DYNAMIC,
      {literal("admin"), literal("games"), placeholder("short"),
       literal("fields"), placeholder("integer"), literal("edit")}}},
    {"game-field-update",
     {RouteKind::DYNAMIC,
      {literal("admin"), literal("games"), placeholder("short"),
       literal("fields"), placeholder("integer")}}},
    {"game-field-delete",
     {RouteKind::DYNAMIC,
      {literal("admin"), literal("games"), placeholder("short"),
       literal("fields"), placeholder("integer"), literal("delete")}}},
    {"game-field-order",
     {RouteKind::DYNAMIC,
      {literal("admin"), literal("games"), placeholder("short"),
       literal("field-order")}}},
    {"admin-promote",
     {RouteKind::DYNAMIC,
      {literal("admin"), literal("users"), placeholder("integer"),
       literal("promote")}}},
    {"card-new", {RouteKind::DYNAMIC, {literal("cards"), literal("new")}}},
    {"cards", {RouteKind::DYNAMIC, {literal("cards")}}},
    {"card", {RouteKind::DYNAMIC,
              {literal("cards"), placeholder("id")}}},
    {"card-edit", {RouteKind::DYNAMIC,
                   {literal("cards"), placeholder("id"), literal("edit")}}},
    {"card-delete",
     {RouteKind::DYNAMIC,
      {literal("cards"), placeholder("id"), literal("delete")}}},
    {"series-index",
     {RouteKind::DYNAMIC, {literal("admin"), literal("series")}}},
    {"series-new",
     {RouteKind::DYNAMIC,
      {literal("admin"), literal("series"), literal("new")}}},
    {"series", {RouteKind::DYNAMIC,
                {literal("admin"), literal("series")}}},
    {"series-edit",
     {RouteKind::DYNAMIC,
      {literal("admin"), literal("series"), placeholder("integer"),
       literal("edit")}}},
    {"series-item",
     {RouteKind::DYNAMIC,
      {literal("admin"), literal("series"), placeholder("integer")}}},
    {"series-delete",
     {RouteKind::DYNAMIC,
      {literal("admin"), literal("series"), placeholder("integer"),
       literal("delete")}}},
    {"card-asset", {RouteKind::STATIC_MOUNT, {literal("static-cards")}}},
    {"static", {RouteKind::STATIC_MOUNT, {literal("static")}}},
};

const RouteDefinition& routeDefinition(const std::string& name)
{
    const auto route = ROUTES.find(name);
    if(route == ROUTES.end())
    {
        throw std::invalid_argument("Unknown route name: " + name);
    }
    return route->second;
}

std::size_t argumentCount(const RouteDefinition& route)
{
    std::size_t count = 0;
    for(const RouteSegment& segment : route.segments)
    {
        if(segment.kind == RouteSegmentKind::PLACEHOLDER)
        {
            ++count;
        }
    }
    return count;
}

std::vector<RouteSegment> resolveRoute(
    const RouteDefinition& route,
    const std::vector<std::string>& arguments,
    RouteSegmentKind argument_kind)
{
    if(arguments.size() != argumentCount(route))
    {
        throw std::invalid_argument("Incorrect route argument count");
    }

    std::vector<RouteSegment> result;
    result.reserve(route.segments.size());
    std::size_t argument_index = 0;
    for(const RouteSegment& segment : route.segments)
    {
        if(segment.kind == RouteSegmentKind::PLACEHOLDER)
        {
            result.push_back(
                {argument_kind, arguments[argument_index++]});
        }
        else
        {
            result.push_back(segment);
        }
    }
    return result;
}

bool indexCardLess(const IndexCard& left, const IndexCard& right)
{
    if(naturalPublicIdLess(left.public_id, right.public_id))
    {
        return true;
    }
    if(naturalPublicIdLess(right.public_id, left.public_id))
    {
        return false;
    }
    return left.card->id < right.card->id;
}

bool hasFieldKey(
    const GameDefinitionSnapshot& definition,
    const std::string& key)
{
    return std::ranges::any_of(
        definition.fields,
        [&key](const GameField& field)
        {
            return field.key == key;
        });
}

void sortIndexCards(std::vector<IndexCard>& cards, bool descending)
{
    if(descending)
    {
        std::ranges::sort(
            cards,
            [](const IndexCard& left, const IndexCard& right)
            {
                return indexCardLess(right, left);
            });
        return;
    }
    std::ranges::sort(cards, indexCardLess);
}

std::string uppercaseAscii(std::string value)
{
    for(char& character : value)
    {
        character = static_cast<char>(
            std::toupper(static_cast<unsigned char>(character)));
    }
    return value;
}

void respondInternalError(App::Response& response)
{
    response.status = 500;
    response.set_content(
        "<!doctype html><title>Internal server error</title>"
        "<h1>Internal server error</h1>",
        "text/html; charset=utf-8");
}

void respondNotFound(App::Response& response)
{
    response.status = 404;
    response.set_content(
        "<!doctype html><title>Card not found</title>"
        "<h1>Card not found</h1>",
        "text/html; charset=utf-8");
}

void respondBadRequest(
    App::Response& response,
    const std::string& message)
{
    response.status = 400;
    response.set_content(message + "\n", "text/plain; charset=utf-8");
}

bool enforceTextBudget(
    const App::Request& request, App::Response& response)
{
    std::size_t total_size = 0;
    for(const auto& [name, value] : request.params)
    {
        [[maybe_unused]] const std::string& ignored_name = name;
        if(value.size() > MAX_FORM_TEXT_FIELD_SIZE)
        {
            response.status = 413;
            response.set_content(
                "A form field is too large\n",
                "text/plain; charset=utf-8");
            return false;
        }
        if(value.size() > MAX_FORM_TOTAL_TEXT_SIZE - total_size)
        {
            response.status = 413;
            response.set_content(
                "Form metadata is too large\n",
                "text/plain; charset=utf-8");
            return false;
        }
        total_size += value.size();
    }
    return true;
}

mw::E<std::string> renderWithHtml(
    inja::Environment& environment,
    const inja::Template& page_template,
    const inja::json& data,
    const std::vector<HtmlSubstitution>& substitutions)
{
    std::string output;
    try
    {
        output = environment.render(page_template, data);
    }
    catch(const std::exception& error)
    {
        return std::unexpected(mw::runtimeError(error.what()));
    }
    for(const HtmlSubstitution& substitution : substitutions)
    {
        const std::size_t position = output.find(substitution.marker);
        if(position == std::string::npos ||
           output.find(
               substitution.marker,
               position + substitution.marker.size()) != std::string::npos)
        {
            return std::unexpected(mw::runtimeError(
                "A trusted HTML marker was not rendered exactly once"));
        }
        output.replace(
            position,
            substitution.marker.size(),
            substitution.html.value());
    }
    return output;
}

void respondOperationError(
    App::Response& response,
    const mw::Error& error,
    std::string_view operation)
{
    const mw::HTTPError* http_error = error.as<mw::HTTPError>();
    if(http_error != nullptr && http_error->code < 500)
    {
        response.status = http_error->code;
        response.set_content(
            http_error->msg + "\n", "text/plain; charset=utf-8");
        return;
    }
    spdlog::error("{}: {}", operation, error.msg());
    respondInternalError(response);
}

void respondTemplate(
    inja::Environment& environment,
    const std::string& filename,
    const inja::json& data,
    App::Response& response)
{
    try
    {
        response.set_content(
            environment.render_file(filename, data),
            "text/html; charset=utf-8");
    }
    catch(const std::exception& error)
    {
        spdlog::error("Failed to render {}: {}", filename, error.what());
        respondInternalError(response);
    }
}

std::string roleName(UserRole role)
{
    switch(role)
    {
    case UserRole::PLAYER:
        return "Player";
    case UserRole::CREATOR:
        return "Creator";
    case UserRole::ADMINISTRATOR:
        return "Administrator";
    }
    return "Unknown";
}

std::optional<std::string> optionalText(
    const std::unordered_map<std::string, std::string>& fields,
    const std::string& name)
{
    const auto position = fields.find(name);
    if(position == fields.end())
    {
        return std::nullopt;
    }
    std::string value(mw::strip(position->second));
    if(value.empty())
    {
        return std::nullopt;
    }
    return value;
}

std::string submittedText(
    const std::unordered_map<std::string, std::string>& fields,
    const std::string& name,
    std::string fallback = {})
{
    const auto position = fields.find(name);
    return position == fields.end()
        ? std::move(fallback)
        : position->second;
}

std::string renderableGameText(const std::string& value)
{
    return validGameText(value) ? value : std::string();
}

std::string gameControlId(std::string_view field_name)
{
    if(field_name == "short_name")
    {
        return "GameShortName";
    }
    if(field_name == "description")
    {
        return "GameDescription";
    }
    return "GameDisplayName";
}

std::string fieldControlId(std::string_view field_name)
{
    if(field_name == "key")
    {
        return "FieldKey";
    }
    if(field_name == "type")
    {
        return "FieldType";
    }
    if(field_name == "choice")
    {
        return "ChoiceEditor";
    }
    return "FieldLabel";
}

mw::E<std::int64_t> parseRarity(
    const std::unordered_map<std::string, std::string>& fields)
{
    const auto position = fields.find("rarity");
    if(position == fields.end())
    {
        return std::unexpected(mw::runtimeError("Rarity is required"));
    }
    std::int64_t rarity = 0;
    const std::string& text = position->second;
    const auto result = std::from_chars(
        text.data(), text.data() + text.size(), rarity);
    if(text.empty() || result.ec != std::errc{} ||
       result.ptr != text.data() + text.size() || rarity < 0)
    {
        return std::unexpected(mw::runtimeError(
            "Rarity must be a nonnegative integer"));
    }
    return rarity;
}

mw::E<std::int64_t> parseRevision(
    const std::unordered_map<std::string, std::string>& fields)
{
    const auto position = fields.find("revision");
    if(position == fields.end())
    {
        return std::unexpected(mw::runtimeError("Revision is required"));
    }
    std::int64_t revision = 0;
    const std::string& text = position->second;
    const auto result = std::from_chars(
        text.data(), text.data() + text.size(), revision);
    if(text.empty() || result.ec != std::errc{} ||
       result.ptr != text.data() + text.size() || revision < 1)
    {
        return std::unexpected(mw::runtimeError(
            "Revision must be a positive integer"));
    }
    return revision;
}

mw::E<std::int64_t> parseGameRevision(
    const std::unordered_map<std::string, std::string>& fields)
{
    const auto position = fields.find("game_revision");
    if(position == fields.end())
    {
        return std::unexpected(mw::httpError(
            400, "Game revision is required"));
    }
    std::int64_t revision = 0;
    const std::string& text = position->second;
    const auto result = std::from_chars(
        text.data(), text.data() + text.size(), revision);
    if(text.empty() || result.ec != std::errc{} ||
       result.ptr != text.data() + text.size() || revision < 1)
    {
        return std::unexpected(mw::httpError(
            400, "Game revision must be a positive integer"));
    }
    return revision;
}

std::optional<std::int64_t> parsePositiveId(const std::string& value)
{
    std::int64_t id = 0;
    const auto result = std::from_chars(
        value.data(), value.data() + value.size(), id);
    if(value.empty() || result.ec != std::errc{} ||
       result.ptr != value.data() + value.size() || id < 1)
    {
        return std::nullopt;
    }
    return id;
}

mw::E<std::string> scalarParameter(
    const App::Request& request,
    const std::string& name,
    bool required = true)
{
    const auto values = request.get_param_values(name);
    if(values.size() > 1)
    {
        return std::unexpected(mw::httpError(
            400, "The form field '" + name + "' was supplied more than once"));
    }
    if(values.empty())
    {
        if(required)
        {
            return std::unexpected(mw::httpError(
                400, "The form field '" + name + "' is required"));
        }
        return std::string();
    }
    return values.front();
}

mw::E<std::int64_t> positiveParameter(
    const App::Request& request, const std::string& name)
{
    auto text = scalarParameter(request, name);
    if(!text)
    {
        return std::unexpected(std::move(text.error()));
    }
    auto value = parsePositiveId(*text);
    if(!value)
    {
        return std::unexpected(mw::httpError(
            400, "The form field '" + name + "' must be a positive integer"));
    }
    return *value;
}

mw::E<GameVisibility> visibilityParameter(const App::Request& request)
{
    auto value = scalarParameter(request, "visibility");
    if(!value)
    {
        return std::unexpected(std::move(value.error()));
    }
    if(*value == "PUBLIC")
    {
        return GameVisibility::PUBLIC;
    }
    if(*value == "INTERNAL")
    {
        return GameVisibility::INTERNAL;
    }
    return std::unexpected(mw::httpError(
        400, "The form field 'visibility' is invalid"));
}

std::optional<std::string> pathParameter(
    const App::Request& request, const std::string& name)
{
    const auto position = request.path_params.find(name);
    if(position == request.path_params.end() || position->second.empty())
    {
        return std::nullopt;
    }
    return position->second;
}

SubmittedGameFields gameFields(
    const std::unordered_map<std::string, std::string>& fields)
{
    SubmittedGameFields result;
    for(const auto& [name, value] : fields)
    {
        if(name.starts_with("game.") && name.size() > 5)
        {
            result.emplace(name.substr(5), value);
        }
    }
    return result;
}

mw::E<std::vector<std::int64_t>> seriesMemberships(
    const std::vector<std::string>& values)
{
    std::vector<std::int64_t> result;
    result.reserve(values.size());
    for(const std::string& value : values)
    {
        auto id = parsePositiveId(value);
        if(!id)
        {
            return std::unexpected(mw::httpError(
                422, "Series IDs must be positive integers"));
        }
        if(std::ranges::find(result, *id) != result.end())
        {
            return std::unexpected(mw::httpError(
                422, "A series was selected more than once"));
        }
        result.push_back(*id);
    }
    return result;
}

std::string fieldValue(
    const std::vector<GameFieldValue>& values, std::int64_t field_id)
{
    const auto position = std::ranges::find_if(
        values,
        [field_id](const GameFieldValue& value)
        {
            return value.field_id == field_id;
        });
    if(position == values.end())
    {
        return {};
    }
    if(const auto integer = std::get_if<std::int64_t>(&position->value))
    {
        return std::to_string(*integer);
    }
    return std::get<std::string>(position->value);
}

inja::json gameFormJson(
    const GameDefinitionSnapshot& definition,
    const std::vector<GameFieldValue>& values,
    const SubmittedGameFields* submitted = nullptr,
    const GameFieldValidationError* error = nullptr)
{
    inja::json fields = inja::json::array();
    for(const GameField& field : definition.fields)
    {
        inja::json choices = inja::json::array();
        for(const GameChoice& choice : field.choices)
        {
            choices.push_back(choice.value);
        }
        std::string displayed_value = fieldValue(values, field.id);
        if(submitted != nullptr)
        {
            const auto submitted_value = submitted->find(field.key);
            if(submitted_value != submitted->end() &&
               validGameText(submitted_value->second))
            {
                displayed_value = submitted_value->second;
            }
        }
        const bool has_error = error != nullptr &&
            error->field_key == field.key;
        const std::string control_id =
            "GameField-" + definition.game.short_name + "-" + field.key;
        fields.push_back({
            {"choices", std::move(choices)},
            {"control_id", control_id},
            {"error_id", control_id + "-error"},
            {"error_message", has_error ? error->msg : std::string()},
            {"has_error", has_error},
            {"is_choice", field.type == GameFieldType::CHOICE},
            {"is_integer", field.type == GameFieldType::INTEGER},
            {"is_string", field.type == GameFieldType::STRING},
            {"key", field.key},
            {"label", field.label},
            {"value", std::move(displayed_value)},
        });
    }
    return {
        {"display_name", definition.game.display_name},
        {"fields", std::move(fields)},
        {"revision", definition.game.revision},
        {"short_name", definition.game.short_name},
    };
}

mw::E<std::vector<GameDefinitionSnapshot>> loadGameDefinitions(
    DataSourceInterface& data_source, GameContentScope scope)
{
    auto games = data_source.getGames(scope);
    if(!games)
    {
        return std::unexpected(std::move(games.error()));
    }
    std::vector<GameDefinitionSnapshot> definitions;
    definitions.reserve(games->size());
    for(const Game& game : *games)
    {
        auto definition = data_source.getGameDefinition(
            game.short_name, scope);
        if(!definition)
        {
            return std::unexpected(std::move(definition.error()));
        }
        if(!*definition)
        {
            return std::unexpected(mw::runtimeError(
                "A listed game disappeared while rendering a form"));
        }
        definitions.push_back(std::move(**definition));
    }
    return definitions;
}

bool isRegularFile(const std::filesystem::path& path, std::int64_t card_id)
{
    std::error_code filesystem_error;
    const bool exists = std::filesystem::is_regular_file(
        path, filesystem_error);
    if(filesystem_error &&
       filesystem_error != std::errc::no_such_file_or_directory)
    {
        spdlog::warn(
            "Failed to inspect an asset for card {}: {}",
            card_id,
            filesystem_error.message());
    }
    return exists;
}

} // namespace

App::App(
    const Config& config,
    std::unique_ptr<DataSourceInterface> data_source,
    std::unique_ptr<NonSecretRandom> random)
        : mw::HTTPServer(config.listen_address),
          config_(config),
          data_source_(std::move(data_source)),
          random_(std::move(random)),
          url_builder_(config.base_url),
          templates_(config.static_root.parent_path() / "templates")
{
    if(!data_source_ || !random_)
    {
        throw std::invalid_argument(
            "App requires data and a random generator");
    }

    clock_ = std::make_unique<SystemClock>();
    crypto_ = std::make_unique<mw::Crypto>();
    email_sender_ = makeEmailSender(config_);
    authentication_service_ = std::make_unique<AuthenticationService>(
        *data_source_,
        *email_sender_,
        *clock_,
        *crypto_,
        config_.base_url,
        config_.email.daily_attempt_limit);
    user_service_ = std::make_unique<UserService>(*data_source_);
    collection_service_ = std::make_unique<CollectionService>(
        *data_source_,
        *clock_,
        *crypto_,
        config_.maximum_accumulated_pulls);

    card_service_ = std::make_unique<CardService>(
        *data_source_,
        *random_,
        ImageProcessor(config_.avif_quality, config_.thumbnail_long_side),
        AssetStore(config_.card_storage_root));
    series_service_ = std::make_unique<SeriesService>(
        *data_source_);
    game_service_ = std::make_unique<GameService>(*data_source_);

    templates_.set_html_autoescape(true);
    templates_.add_callback(
        "url_for",
        [this](inja::Arguments& callback_arguments) -> inja::json
        {
            if(callback_arguments.empty() ||
               !callback_arguments.front()->is_string())
            {
                throw std::invalid_argument(
                    "url_for requires a string route name");
            }

            std::vector<std::string> arguments;
            arguments.reserve(callback_arguments.size() - 1);
            for(std::size_t index = 1;
                index < callback_arguments.size();
                ++index)
            {
                if(!callback_arguments[index]->is_string())
                {
                    throw std::invalid_argument(
                        "url_for arguments must be strings");
                }
                arguments.push_back(
                    callback_arguments[index]->get<std::string>());
            }
            return urlFor(
                callback_arguments.front()->get<std::string>(),
                arguments);
        });
    card_form_template_ = templates_.parse_template("card_form.html");
    card_delete_template_ = templates_.parse_template("card_delete.html");
    card_index_template_ = templates_.parse_template("card_index.html");
    card_view_template_ = templates_.parse_template("card_view.html");
    game_admin_template_ = templates_.parse_template("game_admin.html");
    series_delete_template_ = templates_.parse_template(
        "series_delete.html");
    series_form_template_ = templates_.parse_template("series_form.html");
    series_index_template_ = templates_.parse_template("series_index.html");
}

void App::setCookie(
    Response& response,
    const std::string& name,
    const std::string& value,
    std::int64_t maximum_age,
    bool strict_same_site) const
{
    std::string path = config_.base_url.path();
    if(path.empty())
    {
        path = "/";
    }
    std::string cookie = name + '=' + value + "; Path=" + path +
        "; HttpOnly; SameSite=" +
        (strict_same_site ? "Strict" : "Lax") +
        "; Max-Age=" + std::to_string(maximum_age);
    if(config_.base_url.scheme() == "https")
    {
        cookie += "; Secure";
    }
    response.set_header("Set-Cookie", cookie);
}

std::optional<App::RequestIdentity> App::requireIdentity(
    const Request& request,
    Response& response,
    bool safe_get,
    bool allow_onboarding)
{
    auto raw_token = cookieValue(request, "card_collection_session");
    std::optional<SessionContext> session;
    if(raw_token)
    {
        auto loaded = authentication_service_->session(*raw_token);
        if(!loaded)
        {
            spdlog::error("Failed to load session: {}", loaded.error().msg());
            respondInternalError(response);
            return std::nullopt;
        }
        session = std::move(*loaded);
    }
    if(!raw_token || !session)
    {
        if(raw_token)
        {
            setCookie(
                response, "card_collection_session", "", 0, false);
        }
        response.status = safe_get ? 303 : 401;
        if(safe_get)
        {
            response.set_header("Location", urlFor("authentication"));
        }
        else
        {
            response.set_content(
                "Authentication required\n", "text/plain; charset=utf-8");
        }
        return std::nullopt;
    }
    if(!allow_onboarding && !session->user.username)
    {
        response.status = safe_get ? 303 : 409;
        if(safe_get)
        {
            response.set_header("Location", urlFor("onboarding"));
        }
        else
        {
            response.set_content(
                "Username onboarding required\n",
                "text/plain; charset=utf-8");
        }
        return std::nullopt;
    }
    response.set_header("Cache-Control", "private, no-store");
    return RequestIdentity{std::move(*session), std::move(*raw_token)};
}

bool App::verifyCsrf(
    const Request& request,
    const RequestIdentity& identity,
    Response& response) const
{
    if(request.get_param_value_count("csrf_token") != 1 ||
       !constantTimeEqual(
           request.get_param_value("csrf_token"),
           identity.session.csrf_token))
    {
        response.status = 403;
        response.set_content("Invalid CSRF token\n", "text/plain");
        return false;
    }
    return true;
}

void App::addNavigationData(inja::json& data, const User& user) const
{
    AuthorizationService authorization;
    data["navigation"] = {
        {"cards_url", urlFor(
            user.role == UserRole::ADMINISTRATOR
                ? "card-index"
                : "creator-cards")},
        {"show_cards", authorization.canCreateCard(user)},
        {"show_users", authorization.canAdminister(user)},
        {"games_url", urlFor("admin-games")},
        {"series_url", urlFor("series-index")},
        {"users_url", urlFor("admin-users")},
    };
}

void App::respondMutationError(
    Response& response,
    const mw::Error& error,
    std::string_view operation,
    const std::string& reload_url,
    const User& user,
    bool files_need_reselection)
{
    const mw::HTTPError* http_error = error.as<mw::HTTPError>();
    const GameFieldValidationError* field_error =
        error.as<GameFieldValidationError>();
    const GameDefinitionValidationError* definition_error =
        error.as<GameDefinitionValidationError>();
    if(field_error == nullptr && definition_error == nullptr &&
       (http_error == nullptr || http_error->code >= 500))
    {
        spdlog::error("{}: {}", operation, error.msg());
        respondInternalError(response);
        return;
    }
    const int status = field_error == nullptr && definition_error == nullptr
        ? http_error->code : 422;
    const bool conflict = status == 409;
    inja::json data = {
        {"conflict", conflict},
        {"files_need_reselection", files_need_reselection},
        {"message", error.msg()},
        {"reload_url", reload_url},
        {"title", conflict
            ? "Changes conflicted · Card Collection"
            : "Check your changes · Card Collection"},
    };
    addNavigationData(data, user);
    response.status = status;
    respondTemplate(templates_, "mutation_error.html", data, response);
}

void App::respondCardFieldError(
    Response& response,
    const GameFieldValidationError& error,
    const CardUpload& upload,
    const RequestIdentity& identity,
    const Card* card)
{
    AuthorizationService authorization;
    const GameContentScope scope = authorization.gameContentScope(
        identity.session.user);
    auto definitions = loadGameDefinitions(*data_source_, scope);
    auto all_series = data_source_->getSeries(scope);
    if(!definitions || !all_series)
    {
        respondInternalError(response);
        return;
    }
    const std::string selected_game = card != nullptr
        ? card->identity.game_short_name.value_or("")
        : submittedText(upload.fields, "game");
    const SubmittedGameFields submitted = gameFields(upload.fields);
    inja::json games = inja::json::array();
    std::string error_control_id;
    for(const GameDefinitionSnapshot& definition : *definitions)
    {
        const bool selected =
            definition.game.short_name == selected_game;
        if(selected && hasFieldKey(definition, error.field_key))
        {
            error_control_id = "GameField-" + selected_game + "-" +
                error.field_key;
        }
        games.push_back(gameFormJson(
            definition,
            {},
            selected ? &submitted : nullptr,
            selected ? &error : nullptr));
    }

    inja::json series = inja::json::array();
    for(const Series& item : *all_series)
    {
        const std::string id = std::to_string(item.id);
        series.push_back({
            {"game", item.game_short_name},
            {"id", item.id},
            {"name", item.name},
            {"selected", std::ranges::find(
                upload.series_ids, id) != upload.series_ids.end()},
        });
    }

    const bool editing = card != nullptr;
    std::string public_id;
    std::string front_url = urlFor(
        "static", {"card_placeholder.svg"});
    std::string foil_url;
    if(editing)
    {
        auto formatted = formatPublicId(card->identity);
        if(!formatted)
        {
            respondInternalError(response);
            return;
        }
        public_id = std::move(*formatted);
        front_url = urlFor(
            "card-asset",
            {public_id + "/front-art." + card->front_extension},
            {{"v", std::to_string(card->revision)}});
        if(card->foil_extension)
        {
            foil_url = urlFor(
                "card-asset",
                {public_id + "/foil." + *card->foil_extension},
                {{"v", std::to_string(card->revision)}});
        }
    }

    inja::json data = {
        {"action_url", editing
            ? urlFor("card-edit", {public_id})
            : urlFor("cards")},
        {"back_url", editing
            ? urlFor("card", {public_id})
            : urlFor(identity.session.user.role == UserRole::ADMINISTRATOR
                ? "card-index" : "creator-cards")},
        {"csrf_token", identity.session.csrf_token},
        {"display_id", editing ? uppercaseAscii(public_id) : "New addition"},
        {"error_control_id", error_control_id},
        {"error_message", error.msg},
        {"files_need_reselection", true},
        {"foil_action", "keep"},
        {"foil_url", foil_url},
        {"front_url", front_url},
        {"games", std::move(games)},
        {"has_errors", true},
        {"has_foil", editing && card->foil_extension.has_value()},
        {"heading", editing ? "Edit card" : "Create card"},
        {"long_description", submittedText(
            upload.fields,
            "long_description",
            editing ? card->long_description.value_or("") : "")},
        {"mode", editing ? "edit" : "create"},
        {"model_url", urlFor("static", {"foil/model/card.obj"})},
        {"name", submittedText(
            upload.fields, "name", editing ? card->name : "")},
        {"preview_script_url",
         urlFor("static", {"foil/card_preview.js"})},
        {"rarity", submittedText(
            upload.fields,
            "rarity",
            editing ? std::to_string(card->rarity) : "0")},
        {"revision", editing ? card->revision : 0},
        {"selected_game", selected_game},
        {"series", std::move(series)},
        {"shader_fragment_url",
         urlFor("static", {"foil/frag-shader.glsl"})},
        {"shader_vertex_url",
         urlFor("static", {"foil/vert-shader.glsl"})},
        {"short_description", submittedText(
            upload.fields,
            "short_description",
            editing ? card->short_description.value_or("") : "")},
        {"show_rarity",
         identity.session.user.role == UserRole::ADMINISTRATOR},
        {"spectral_lut_url",
         urlFor("static", {"foil/spectral_xyz.bin"})},
        {"submit_label", editing ? "Save changes" : "Create card"},
        {"thumbnail_long_side", config_.thumbnail_long_side},
        {"title", editing
            ? "Edit " + card->name + " · Card Collection"
            : "Create card · Card Collection"},
    };
    addNavigationData(data, identity.session.user);
    try
    {
        response.status = 422;
        response.set_content(
            templates_.render(card_form_template_, data),
            "text/html; charset=utf-8");
    }
    catch(const std::exception& render_error)
    {
        spdlog::error(
            "Failed to re-render card validation: {}",
            render_error.what());
        respondInternalError(response);
    }
}

void App::respondGameDefinitionError(
    Response& response,
    const GameDefinitionValidationError& error,
    const RequestIdentity& identity,
    const std::string& short_name,
    const std::string& display_name,
    const std::string& description,
    GameVisibility visibility,
    bool editing)
{
    std::optional<GameDefinitionSnapshot> definition;
    if(editing)
    {
        auto loaded = data_source_->getGameDefinition(
            short_name, GameContentScope::INCLUDE_INTERNAL);
        if(!loaded)
        {
            respondInternalError(response);
            return;
        }
        if(!*loaded)
        {
            respondNotFound(response);
            return;
        }
        definition = std::move(**loaded);
    }

    inja::json fields = inja::json::array();
    if(definition)
    {
        for(std::size_t index = 0;
            index < definition->fields.size();
            ++index)
        {
            const GameField& field = definition->fields[index];
            std::vector<std::int64_t> up_order;
            std::vector<std::int64_t> down_order;
            for(const GameField& candidate : definition->fields)
            {
                up_order.push_back(candidate.id);
                down_order.push_back(candidate.id);
            }
            if(index > 0)
            {
                std::swap(up_order[index], up_order[index - 1]);
            }
            if(index + 1 < down_order.size())
            {
                std::swap(down_order[index], down_order[index + 1]);
            }
            fields.push_back({
                {"choice_count", field.choices.size()},
                {"delete_url", urlFor(
                    "game-field-delete",
                    {short_name, std::to_string(field.id)})},
                {"down_order", down_order},
                {"edit_url", urlFor(
                    "game-field-edit",
                    {short_name, std::to_string(field.id)})},
                {"id", field.id},
                {"is_first", index == 0},
                {"is_last", index + 1 == definition->fields.size()},
                {"key", field.key},
                {"label", field.label},
                {"type", std::string(gameFieldTypeName(field.type))},
                {"up_order", up_order},
            });
        }
    }

    const std::int64_t revision = definition
        ? definition->game.revision : 0;
    inja::json data = {
        {"action_url", editing
            ? urlFor("game-update", {short_name})
            : urlFor("games")},
        {"add_field_url", editing
            ? urlFor("game-field-new", {short_name}) : ""},
        {"back_url", urlFor("admin-games")},
        {"csrf_token", identity.session.csrf_token},
        {"delete_url", editing
            ? urlFor("game-delete", {short_name}) : ""},
        {"description", renderableGameText(description)},
        {"display_name", renderableGameText(display_name)},
        {"error_control_id", gameControlId(error.field_name)},
        {"error_field", error.field_name},
        {"error_message", error.msg},
        {"field_order_url", editing
            ? urlFor("game-field-order", {short_name}) : ""},
        {"fields", std::move(fields)},
        {"game_revision", revision},
        {"has_errors", true},
        {"heading", editing
            ? "Edit " + definition->game.display_name
            : "Create game"},
        {"mode", editing ? "edit" : "create"},
        {"visibility_internal", visibility == GameVisibility::INTERNAL},
        {"visibility_public", visibility == GameVisibility::PUBLIC},
        {"short_name", renderableGameText(short_name)},
        {"submit_label", editing ? "Save game" : "Create game"},
        {"title", editing
            ? "Edit " + definition->game.display_name +
                " · Card Collection"
            : "Create game · Card Collection"},
    };
    addNavigationData(data, identity.session.user);
    response.status = 422;
    respondTemplate(templates_, "game_form.html", data, response);
}

void App::respondGameFieldDefinitionError(
    Response& response,
    const GameDefinitionValidationError& error,
    const RequestIdentity& identity,
    const std::string& short_name,
    std::optional<std::int64_t> field_id,
    const std::string& key,
    const std::string& label,
    const std::string& type,
    const std::vector<std::string>& choices)
{
    auto loaded = data_source_->getGameDefinition(
        short_name, GameContentScope::INCLUDE_INTERNAL);
    if(!loaded)
    {
        respondInternalError(response);
        return;
    }
    if(!*loaded)
    {
        respondNotFound(response);
        return;
    }
    const GameDefinitionSnapshot& definition = **loaded;
    const GameField* stored_field = nullptr;
    if(field_id)
    {
        const auto position = std::ranges::find_if(
            definition.fields,
            [field_id](const GameField& field)
            {
                return field.id == *field_id;
            });
        if(position == definition.fields.end())
        {
            respondNotFound(response);
            return;
        }
        stored_field = &*position;
    }

    inja::json choice_data = inja::json::array();
    for(const std::string& value : choices)
    {
        const bool existing = stored_field != nullptr &&
            std::ranges::any_of(
                stored_field->choices,
                [&value](const GameChoice& choice)
                {
                    return choice.value == value;
                });
        choice_data.push_back({
            {"existing", existing},
            {"value", renderableGameText(value)},
        });
    }
    const bool editing = field_id.has_value();
    const std::string id = editing ? std::to_string(*field_id) : "";
    const std::string displayed_key = stored_field == nullptr
        ? renderableGameText(key) : stored_field->key;
    const std::string displayed_type = stored_field == nullptr
        ? type : std::string(gameFieldTypeName(stored_field->type));
    inja::json data = {
        {"action_url", editing
            ? urlFor("game-field-update", {short_name, id})
            : urlFor("game-fields", {short_name})},
        {"back_url", urlFor("game-edit", {short_name})},
        {"choices", std::move(choice_data)},
        {"csrf_token", identity.session.csrf_token},
        {"delete_url", editing
            ? urlFor("game-field-delete", {short_name, id}) : ""},
        {"error_control_id", fieldControlId(error.field_name)},
        {"error_field", error.field_name},
        {"error_message", error.msg},
        {"field_type", displayed_type},
        {"game_name", definition.game.display_name},
        {"game_revision", definition.game.revision},
        {"has_errors", true},
        {"heading", editing
            ? "Edit " + stored_field->label : "Add custom field"},
        {"key", displayed_key},
        {"label", renderableGameText(label)},
        {"mode", editing ? "edit" : "create"},
        {"submit_label", editing ? "Save field" : "Add field"},
        {"title", editing
            ? "Edit field · Card Collection"
            : "Add field · Card Collection"},
    };
    addNavigationData(data, identity.session.user);
    response.status = 422;
    respondTemplate(templates_, "game_field_form.html", data, response);
}

void App::handleWelcome(const Request& request, Response& response)
{
    auto token = cookieValue(request, "card_collection_session");
    if(token)
    {
        auto session = authentication_service_->session(*token);
        if(!session)
        {
            respondInternalError(response);
            return;
        }
        if(*session)
        {
            response.status = 303;
            response.set_header(
                "Location",
                (**session).user.username
                    ? urlFor("collection")
                    : urlFor("onboarding"));
            return;
        }
        setCookie(response, "card_collection_session", "", 0, false);
    }
    respondTemplate(templates_, "welcome.html", {
        {"authentication_url", urlFor("authentication")},
        {"example_url", urlFor("static", {"card_placeholder.svg"})},
        {"title", "Card Collection"},
    }, response);
}

void App::handleAuthentication(
    [[maybe_unused]] const Request& request,
    Response& response)
{
    auto nonce = generateSecretToken(*crypto_);
    if(!nonce)
    {
        respondInternalError(response);
        return;
    }
    setCookie(
        response, "card_collection_auth_form", nonce->value, 600, true);
    response.set_header("Cache-Control", "no-store");
    respondTemplate(templates_, "authentication.html", {
        {"action_url", urlFor("authentication-email")},
        {"form_nonce", nonce->value},
        {"title", "Sign in · Card Collection"},
    }, response);
}

void App::handleAuthenticationEmail(
    const Request& request, Response& response)
{
    const auto nonce = cookieValue(request, "card_collection_auth_form");
    if(!nonce || request.get_param_value_count("form_nonce") != 1 ||
       !constantTimeEqual(*nonce, request.get_param_value("form_nonce")))
    {
        response.status = 403;
        response.set_content("Invalid form token\n", "text/plain");
        return;
    }
    if(request.get_param_value_count("email") != 1)
    {
        respondBadRequest(response, "Email is required");
        return;
    }
    auto sent = authentication_service_->requestEmail(
        request.get_param_value("email"));
    if(!sent)
    {
        const auto* limited = sent.error().as<AuthenticationRateLimitError>();
        if(limited != nullptr)
        {
            response.status = 429;
            response.set_header(
                "Retry-After", std::to_string(limited->retry_after));
            response.set_content(limited->msg + "\n", "text/plain");
            return;
        }
        respondOperationError(
            response, sent.error(), "Failed to send authentication email");
        return;
    }
    setCookie(response, "card_collection_auth_form", "", 0, true);
    response.status = 303;
    response.set_header("Location", urlFor("authentication-sent"));
}

void App::handleAuthenticationSent(
    [[maybe_unused]] const Request& request,
    Response& response)
{
    response.set_header("Cache-Control", "no-store");
    respondTemplate(templates_, "authentication_sent.html", {
        {"title", "Check your email · Card Collection"},
    }, response);
}

void App::handleAuthenticationConfirm(
    const Request& request, Response& response)
{
    const auto parameter = request.path_params.find("token");
    if(parameter == request.path_params.end())
    {
        respondBadRequest(response, "Invalid authentication link");
        return;
    }
    auto challenge = authentication_service_->validate(parameter->second);
    if(!challenge)
    {
        const auto* http_error = challenge.error().as<mw::HTTPError>();
        if(http_error != nullptr && http_error->code == 400)
        {
            respondBadRequest(response, "Invalid authentication link");
        }
        else
        {
            spdlog::error(
                "Failed to validate authentication link: {}",
                challenge.error().msg());
            respondInternalError(response);
        }
        return;
    }
    if(!*challenge)
    {
        respondBadRequest(response, "Invalid or expired authentication link");
        return;
    }
    auto nonce = generateSecretToken(*crypto_);
    if(!nonce)
    {
        respondInternalError(response);
        return;
    }
    setCookie(
        response, "card_collection_confirm_form", nonce->value, 600, true);
    response.set_header("Cache-Control", "no-store");
    response.set_header("Referrer-Policy", "no-referrer");
    response.set_header(
        "Content-Security-Policy", "default-src 'none'; style-src 'self'");
    respondTemplate(templates_, "authentication_confirm.html", {
        {"action_url",
         urlFor("authentication-confirm", {parameter->second})},
        {"email", (**challenge).email},
        {"form_nonce", nonce->value},
        {"title", "Confirm sign in · Card Collection"},
    }, response);
}

void App::handleAuthenticationConfirmPost(
    const Request& request, Response& response)
{
    const auto parameter = request.path_params.find("token");
    const auto nonce = cookieValue(request, "card_collection_confirm_form");
    if(parameter == request.path_params.end() || !nonce ||
       request.get_param_value_count("form_nonce") != 1 ||
       !constantTimeEqual(*nonce, request.get_param_value("form_nonce")))
    {
        response.status = 403;
        response.set_content("Invalid confirmation token\n", "text/plain");
        return;
    }
    auto current = cookieValue(request, "card_collection_session");
    auto established = authentication_service_->confirm(
        parameter->second, current);
    if(!established)
    {
        respondOperationError(
            response, established.error(), "Failed to confirm authentication");
        return;
    }
    setCookie(
        response,
        "card_collection_session",
        established->token,
        28 * 24 * 60 * 60,
        false);
    setCookie(response, "card_collection_confirm_form", "", 0, true);
    response.status = 303;
    response.set_header(
        "Location",
        established->user.username
            ? urlFor("collection")
            : urlFor("onboarding"));
}

void App::handleLogout(const Request& request, Response& response)
{
    auto identity = requireIdentity(request, response, false, true);
    if(!identity || !verifyCsrf(request, *identity, response))
    {
        return;
    }
    auto logged_out = authentication_service_->logout(identity->raw_token);
    if(!logged_out)
    {
        respondInternalError(response);
        return;
    }
    setCookie(response, "card_collection_session", "", 0, false);
    response.status = 303;
    response.set_header("Location", urlFor("welcome"));
}

void App::handleOnboarding(const Request& request, Response& response)
{
    auto identity = requireIdentity(request, response, true, true);
    if(!identity)
    {
        return;
    }
    if(identity->session.user.username)
    {
        response.status = 303;
        response.set_header("Location", urlFor("collection"));
        return;
    }
    respondTemplate(templates_, "onboarding_username.html", {
        {"action_url", urlFor("onboarding")},
        {"csrf_token", identity->session.csrf_token},
        {"heading", "Choose a username"},
        {"submit_label", "Continue"},
        {"title", "Choose username · Card Collection"},
        {"username", ""},
    }, response);
}

void App::handleOnboardingPost(const Request& request, Response& response)
{
    auto identity = requireIdentity(request, response, false, true);
    if(!identity || !verifyCsrf(request, *identity, response))
    {
        return;
    }
    if(identity->session.user.username ||
       request.get_param_value_count("username") != 1)
    {
        respondBadRequest(response, "Username is required");
        return;
    }
    auto updated = user_service_->setUsername(
        identity->session.user.id, request.get_param_value("username"));
    if(!updated)
    {
        respondOperationError(response, updated.error(), "Failed username");
        return;
    }
    response.status = 303;
    response.set_header("Location", urlFor("collection"));
}

void App::handleAccount(const Request& request, Response& response)
{
    auto identity = requireIdentity(request, response, true);
    if(!identity)
    {
        return;
    }
    inja::json template_data = {
        {"csrf_token", identity->session.csrf_token},
        {"email", identity->session.user.email},
        {"logout_url", urlFor("logout")},
        {"title", "Account · Card Collection"},
        {"username", *identity->session.user.username},
        {"username_url", urlFor("account-username")},
    };
    addNavigationData(template_data, identity->session.user);
    respondTemplate(
        templates_, "account.html", template_data, response);
}

void App::handleAccountUsername(const Request& request, Response& response)
{
    auto identity = requireIdentity(request, response, true);
    if(!identity)
    {
        return;
    }
    respondTemplate(templates_, "onboarding_username.html", {
        {"action_url", urlFor("account-username")},
        {"csrf_token", identity->session.csrf_token},
        {"heading", "Change username"},
        {"submit_label", "Save"},
        {"title", "Change username · Card Collection"},
        {"username", *identity->session.user.username},
    }, response);
}

void App::handleAccountUsernamePost(
    const Request& request, Response& response)
{
    auto identity = requireIdentity(request, response, false);
    if(!identity || !verifyCsrf(request, *identity, response))
    {
        return;
    }
    if(request.get_param_value_count("username") != 1)
    {
        respondBadRequest(response, "Username is required");
        return;
    }
    auto updated = user_service_->setUsername(
        identity->session.user.id, request.get_param_value("username"));
    if(!updated)
    {
        respondOperationError(response, updated.error(), "Failed username");
        return;
    }
    response.status = 303;
    response.set_header("Location", urlFor("account"));
}

void App::handleCollection(const Request& request, Response& response)
{
    auto identity = requireIdentity(request, response, true);
    if(!identity)
    {
        return;
    }
    auto user = collection_service_->refresh(identity->session.user.id);
    AuthorizationService authorization;
    auto collection = data_source_->getCollection(
        identity->session.user.id,
        authorization.gameContentScope(identity->session.user));
    auto pool = data_source_->getPoolCards();
    if(!user || !collection || !pool)
    {
        respondInternalError(response);
        return;
    }
    inja::json entries = inja::json::array();
    for(const CollectionEntry& entry : *collection)
    {
        auto public_id_result = formatPublicId(entry.card.identity);
        if(!public_id_result)
        {
            respondInternalError(response);
            return;
        }
        const std::string& public_id = *public_id_result;
        const std::string thumbnail_name =
            "thumb." + entry.card.thumbnail_extension;
        const std::filesystem::path thumbnail_path =
            config_.card_storage_root / "published" / public_id /
            thumbnail_name;
        std::error_code image_error;
        const bool thumbnail_exists = std::filesystem::is_regular_file(
            thumbnail_path, image_error);
        const std::string thumbnail_url = thumbnail_exists
            ? urlFor(
                "card-asset",
                {public_id + '/' + thumbnail_name},
                {{"v", std::to_string(entry.card.revision)}})
            : urlFor("static", {"card_placeholder.svg"});
        entries.push_back({
            {"name", entry.card.name},
            {"quantity", entry.quantity},
            {"thumbnail_url", thumbnail_url},
            {"url", urlFor("card", {public_id})},
        });
    }
    const bool pull_disabled = pool->empty() || user->stored_pulls == 0;
    inja::json template_data = {
        {"available_pulls", user->stored_pulls},
        {"csrf_token", identity->session.csrf_token},
        {"entries", std::move(entries)},
        {"pool_empty", pool->empty()},
        {"pull_disabled", pull_disabled},
        {"pull_url", urlFor("collection-pull")},
        {"title", "Collection · Card Collection"},
    };
    addNavigationData(template_data, identity->session.user);
    respondTemplate(
        templates_, "collection.html", template_data, response);
}

void App::handleCollectionPull(const Request& request, Response& response)
{
    auto identity = requireIdentity(request, response, false);
    if(!identity || !verifyCsrf(request, *identity, response))
    {
        return;
    }
    auto pulled = collection_service_->pull(identity->session.user.id);
    if(!pulled)
    {
        respondOperationError(response, pulled.error(), "Failed card pull");
        return;
    }
    response.status = 303;
    auto public_id = formatPublicId(pulled->card.identity);
    if(!public_id)
    {
        respondInternalError(response);
        return;
    }
    response.set_header(
        "Location", urlFor("card", {*public_id}));
}

void App::handleAdminUsers(const Request& request, Response& response)
{
    auto identity = requireIdentity(request, response, true);
    if(!identity)
    {
        return;
    }
    AuthorizationService authorization;
    if(!authorization.canAdminister(identity->session.user))
    {
        response.status = 403;
        return;
    }
    auto users = data_source_->getUsers();
    if(!users)
    {
        respondInternalError(response);
        return;
    }
    inja::json template_users = inja::json::array();
    for(const User& user : *users)
    {
        template_users.push_back({
            {"can_promote", user.role == UserRole::PLAYER},
            {"email", user.email},
            {"promote_url", urlFor(
                "admin-promote", {std::to_string(user.id)})},
            {"role", roleName(user.role)},
            {"username", user.username.value_or("Onboarding")},
        });
    }
    inja::json template_data = {
        {"csrf_token", identity->session.csrf_token},
        {"title", "Users · Card Collection"},
        {"users", std::move(template_users)},
    };
    addNavigationData(template_data, identity->session.user);
    respondTemplate(
        templates_, "user_admin.html", template_data, response);
}

void App::handleAdminGames(const Request& request, Response& response)
{
    auto identity = requireIdentity(request, response, true);
    if(!identity)
    {
        return;
    }
    AuthorizationService authorization;
    if(!authorization.canAdminister(identity->session.user))
    {
        response.status = 403;
        return;
    }

    auto games = data_source_->getGames(
        GameContentScope::INCLUDE_INTERNAL);
    if(!games)
    {
        respondInternalError(response);
        return;
    }
    inja::json template_games = inja::json::array();
    std::vector<HtmlSubstitution> html_substitutions;
    for(const Game& game : *games)
    {
        auto definition = data_source_->getGameDefinition(
            game.short_name, GameContentScope::INCLUDE_INTERNAL);
        if(!definition || !*definition)
        {
            respondInternalError(response);
            return;
        }
        inja::json fields = inja::json::array();
        for(const GameField& field : (**definition).fields)
        {
            fields.push_back({
                {"input_type", std::string(gameFieldTypeName(field.type))},
                {"key", field.key},
                {"label", field.label},
            });
        }
        const Game& current_game = (**definition).game;
        std::string description_marker;
        if(!current_game.description.empty())
        {
            auto rendered = MarkdownRenderer().render(
                current_game.description);
            if(!rendered)
            {
                respondInternalError(response);
                return;
            }
            description_marker =
                "CARD_COLLECTION_HTML_" + random_->hex(16);
            html_substitutions.push_back({
                description_marker, std::move(*rendered)});
        }
        template_games.push_back({
            {"description", description_marker},
            {"display_name", current_game.display_name},
            {"edit_url", urlFor(
                "game-edit", {current_game.short_name})},
            {"field_count", (**definition).fields.size()},
            {"fields", std::move(fields)},
            {"has_description", !current_game.description.empty()},
            {"initial", current_game.short_name.substr(0, 1)},
            {"internal",
             current_game.visibility == GameVisibility::INTERNAL},
            {"short_name", current_game.short_name},
        });
    }
    inja::json template_data = {
        {"create_card_url", urlFor("card-new")},
        {"create_url", urlFor("game-new")},
        {"games", std::move(template_games)},
        {"series_url", urlFor("series-index")},
        {"title", "Games · Card Collection"},
    };
    addNavigationData(template_data, identity->session.user);
    auto content = renderWithHtml(
        templates_,
        game_admin_template_,
        template_data,
        html_substitutions);
    if(!content)
    {
        spdlog::error(
            "Failed to render game administration: {}",
            content.error().msg());
        respondInternalError(response);
        return;
    }
    response.status = 200;
    response.set_content(*content, "text/html; charset=utf-8");
}

void App::handleGameNew(const Request& request, Response& response)
{
    auto identity = requireIdentity(request, response, true);
    AuthorizationService authorization;
    if(!identity)
    {
        return;
    }
    if(!authorization.canAdminister(identity->session.user))
    {
        response.status = 403;
        return;
    }
    inja::json data = {
        {"action_url", urlFor("games")},
        {"back_url", urlFor("admin-games")},
        {"csrf_token", identity->session.csrf_token},
        {"description", ""},
        {"display_name", ""},
        {"heading", "Create game"},
        {"error_control_id", ""},
        {"error_field", ""},
        {"error_message", ""},
        {"has_errors", false},
        {"mode", "create"},
        {"visibility_internal", false},
        {"visibility_public", true},
        {"short_name", ""},
        {"submit_label", "Create game"},
        {"title", "Create game · Card Collection"},
    };
    addNavigationData(data, identity->session.user);
    respondTemplate(templates_, "game_form.html", data, response);
}

void App::handleGameCreate(const Request& request, Response& response)
{
    auto identity = requireIdentity(request, response, false);
    if(!identity || !verifyCsrf(request, *identity, response))
    {
        return;
    }
    if(!enforceTextBudget(request, response))
    {
        return;
    }
    auto short_name = scalarParameter(request, "short_name");
    auto display_name = scalarParameter(request, "display_name");
    auto description = scalarParameter(request, "description", false);
    auto visibility = visibilityParameter(request);
    if(!short_name || !display_name || !description || !visibility)
    {
        const mw::Error& error = !short_name ? short_name.error()
            : !display_name ? display_name.error()
            : !description ? description.error() : visibility.error();
        respondOperationError(response, error, "Invalid game form");
        return;
    }
    auto created = game_service_->createGame(
        identity->session.user.id, *short_name,
        *display_name, *description, *visibility);
    if(!created)
    {
        const auto* validation =
            created.error().as<GameDefinitionValidationError>();
        if(validation != nullptr)
        {
            respondGameDefinitionError(
                response,
                *validation,
                *identity,
                *short_name,
                *display_name,
                *description,
                *visibility,
                false);
            return;
        }
        respondMutationError(
            response,
            created.error(),
            "Failed game create",
            urlFor("game-new"),
            identity->session.user);
        return;
    }
    response.status = 303;
    response.set_header("Location", urlFor("game-edit", {*created}));
}

void App::handleGameEdit(const Request& request, Response& response)
{
    auto identity = requireIdentity(request, response, true);
    AuthorizationService authorization;
    if(!identity)
    {
        return;
    }
    if(!authorization.canAdminister(identity->session.user))
    {
        response.status = 403;
        return;
    }
    auto short_name = pathParameter(request, "short");
    if(!short_name)
    {
        respondNotFound(response);
        return;
    }
    auto definition = data_source_->getGameDefinition(
        *short_name, GameContentScope::INCLUDE_INTERNAL);
    if(!definition)
    {
        respondInternalError(response);
        return;
    }
    if(!*definition)
    {
        respondNotFound(response);
        return;
    }
    const GameDefinitionSnapshot& snapshot = **definition;
    inja::json fields = inja::json::array();
    for(std::size_t index = 0; index < snapshot.fields.size(); ++index)
    {
        const GameField& field = snapshot.fields[index];
        std::vector<std::int64_t> up_order;
        std::vector<std::int64_t> down_order;
        for(const GameField& candidate : snapshot.fields)
        {
            up_order.push_back(candidate.id);
            down_order.push_back(candidate.id);
        }
        if(index > 0)
        {
            std::swap(up_order[index], up_order[index - 1]);
        }
        if(index + 1 < down_order.size())
        {
            std::swap(down_order[index], down_order[index + 1]);
        }
        fields.push_back({
            {"choice_count", field.choices.size()},
            {"delete_url", urlFor(
                "game-field-delete",
                {*short_name, std::to_string(field.id)})},
            {"down_order", down_order},
            {"edit_url", urlFor(
                "game-field-edit",
                {*short_name, std::to_string(field.id)})},
            {"id", field.id},
            {"is_first", index == 0},
            {"is_last", index + 1 == snapshot.fields.size()},
            {"key", field.key},
            {"label", field.label},
            {"type", std::string(gameFieldTypeName(field.type))},
            {"up_order", up_order},
        });
    }
    inja::json data = {
        {"action_url", urlFor("game-update", {*short_name})},
        {"add_field_url", urlFor("game-field-new", {*short_name})},
        {"back_url", urlFor("admin-games")},
        {"csrf_token", identity->session.csrf_token},
        {"delete_url", urlFor("game-delete", {*short_name})},
        {"description", snapshot.game.description},
        {"display_name", snapshot.game.display_name},
        {"field_order_url", urlFor("game-field-order", {*short_name})},
        {"fields", std::move(fields)},
        {"game_revision", snapshot.game.revision},
        {"heading", "Edit " + snapshot.game.display_name},
        {"error_control_id", ""},
        {"error_field", ""},
        {"error_message", ""},
        {"has_errors", false},
        {"mode", "edit"},
        {"visibility_internal",
         snapshot.game.visibility == GameVisibility::INTERNAL},
        {"visibility_public",
         snapshot.game.visibility == GameVisibility::PUBLIC},
        {"short_name", snapshot.game.short_name},
        {"submit_label", "Save game"},
        {"title", "Edit " + snapshot.game.display_name +
            " · Card Collection"},
    };
    addNavigationData(data, identity->session.user);
    respondTemplate(templates_, "game_form.html", data, response);
}

void App::handleGameUpdate(const Request& request, Response& response)
{
    auto identity = requireIdentity(request, response, false);
    if(!identity || !verifyCsrf(request, *identity, response))
    {
        return;
    }
    if(!enforceTextBudget(request, response))
    {
        return;
    }
    auto short_name = pathParameter(request, "short");
    auto revision = positiveParameter(request, "game_revision");
    auto display_name = scalarParameter(request, "display_name");
    auto description = scalarParameter(request, "description", false);
    auto visibility = visibilityParameter(request);
    if(!short_name || !revision || !display_name || !description ||
       !visibility)
    {
        if(!short_name)
        {
            respondNotFound(response);
        }
        else
        {
            const mw::Error& error = !revision ? revision.error()
                : !display_name ? display_name.error()
                : !description ? description.error()
                               : visibility.error();
            respondOperationError(response, error, "Invalid game form");
        }
        return;
    }
    auto updated = game_service_->updateGame(
        identity->session.user.id, *short_name, *revision,
        *display_name, *description, *visibility);
    if(!updated)
    {
        const auto* validation =
            updated.error().as<GameDefinitionValidationError>();
        if(validation != nullptr)
        {
            respondGameDefinitionError(
                response,
                *validation,
                *identity,
                *short_name,
                *display_name,
                *description,
                *visibility,
                true);
            return;
        }
        respondMutationError(
            response,
            updated.error(),
            "Failed game update",
            urlFor("game-edit", {*short_name}),
            identity->session.user);
        return;
    }
    response.status = 303;
    response.set_header("Location", urlFor("game-edit", {*short_name}));
}

void App::handleGameDeleteConfirm(
    const Request& request, Response& response)
{
    auto identity = requireIdentity(request, response, true);
    AuthorizationService authorization;
    if(!identity)
    {
        return;
    }
    if(!authorization.canAdminister(identity->session.user))
    {
        response.status = 403;
        return;
    }
    auto short_name = pathParameter(request, "short");
    auto definition = short_name
        ? data_source_->getGameDefinition(
              *short_name, GameContentScope::INCLUDE_INTERNAL)
        : mw::E<std::optional<GameDefinitionSnapshot>>(
            std::optional<GameDefinitionSnapshot>{});
    if(!definition || !*definition)
    {
        if(!definition)
        {
            respondInternalError(response);
        }
        else
        {
            respondNotFound(response);
        }
        return;
    }
    inja::json data = {
        {"action_url", urlFor("game-delete", {*short_name})},
        {"back_url", urlFor("game-edit", {*short_name})},
        {"csrf_token", identity->session.csrf_token},
        {"display_name", (**definition).game.display_name},
        {"game_revision", (**definition).game.revision},
        {"title", "Delete " + (**definition).game.display_name +
            " · Card Collection"},
    };
    addNavigationData(data, identity->session.user);
    respondTemplate(templates_, "game_delete.html", data, response);
}

void App::handleGameDelete(const Request& request, Response& response)
{
    auto identity = requireIdentity(request, response, false);
    if(!identity || !verifyCsrf(request, *identity, response))
    {
        return;
    }
    if(!enforceTextBudget(request, response))
    {
        return;
    }
    auto short_name = pathParameter(request, "short");
    auto revision = positiveParameter(request, "game_revision");
    if(!short_name || !revision)
    {
        if(!short_name)
        {
            respondNotFound(response);
        }
        else
        {
            respondOperationError(
                response, revision.error(), "Invalid game deletion");
        }
        return;
    }
    auto deleted = game_service_->removeGame(
        identity->session.user.id, *short_name, *revision);
    if(!deleted)
    {
        respondMutationError(
            response,
            deleted.error(),
            "Failed game delete",
            urlFor("game-edit", {*short_name}),
            identity->session.user);
        return;
    }
    response.status = 303;
    response.set_header("Location", urlFor("admin-games"));
}

void App::handleGameFieldNew(const Request& request, Response& response)
{
    auto identity = requireIdentity(request, response, true);
    AuthorizationService authorization;
    if(!identity)
    {
        return;
    }
    if(!authorization.canAdminister(identity->session.user))
    {
        response.status = 403;
        return;
    }
    auto short_name = pathParameter(request, "short");
    auto definition = short_name
        ? data_source_->getGameDefinition(
              *short_name, GameContentScope::INCLUDE_INTERNAL)
        : mw::E<std::optional<GameDefinitionSnapshot>>(
            std::optional<GameDefinitionSnapshot>{});
    if(!definition || !*definition)
    {
        if(!definition)
        {
            respondInternalError(response);
        }
        else
        {
            respondNotFound(response);
        }
        return;
    }
    inja::json data = {
        {"action_url", urlFor("game-fields", {*short_name})},
        {"back_url", urlFor("game-edit", {*short_name})},
        {"choices", inja::json::array()},
        {"csrf_token", identity->session.csrf_token},
        {"field_type", "STRING"},
        {"game_name", (**definition).game.display_name},
        {"game_revision", (**definition).game.revision},
        {"heading", "Add custom field"},
        {"error_control_id", ""},
        {"error_field", ""},
        {"error_message", ""},
        {"has_errors", false},
        {"key", ""},
        {"label", ""},
        {"mode", "create"},
        {"submit_label", "Add field"},
        {"title", "Add field · Card Collection"},
    };
    addNavigationData(data, identity->session.user);
    respondTemplate(templates_, "game_field_form.html", data, response);
}

void App::handleGameFieldCreate(
    const Request& request, Response& response)
{
    auto identity = requireIdentity(request, response, false);
    if(!identity || !verifyCsrf(request, *identity, response))
    {
        return;
    }
    if(!enforceTextBudget(request, response))
    {
        return;
    }
    auto short_name = pathParameter(request, "short");
    auto revision = positiveParameter(request, "game_revision");
    auto key = scalarParameter(request, "key");
    auto label = scalarParameter(request, "label");
    auto type_text = scalarParameter(request, "type");
    if(!short_name || !revision || !key || !label || !type_text)
    {
        if(!short_name)
        {
            respondNotFound(response);
        }
        else
        {
            const mw::Error& error = !revision ? revision.error()
                : !key ? key.error() : !label ? label.error()
                : type_text.error();
            respondOperationError(response, error, "Invalid field form");
        }
        return;
    }
    auto type = parseGameFieldType(*type_text);
    if(!type)
    {
        respondGameFieldDefinitionError(
            response,
            {"type", "Unknown field type"},
            *identity,
            *short_name,
            std::nullopt,
            *key,
            *label,
            *type_text,
            request.get_param_values("choice"));
        return;
    }
    const std::vector<std::string> choices =
        request.get_param_values("choice");
    auto created = game_service_->createField(
        identity->session.user.id, *short_name, *revision,
        *key, *label, *type, choices);
    if(!created)
    {
        const auto* validation =
            created.error().as<GameDefinitionValidationError>();
        if(validation != nullptr)
        {
            respondGameFieldDefinitionError(
                response,
                *validation,
                *identity,
                *short_name,
                std::nullopt,
                *key,
                *label,
                *type_text,
                choices);
            return;
        }
        respondMutationError(
            response,
            created.error(),
            "Failed field create",
            urlFor("game-field-new", {*short_name}),
            identity->session.user);
        return;
    }
    response.status = 303;
    response.set_header("Location", urlFor("game-edit", {*short_name}));
}

void App::handleGameFieldEdit(
    const Request& request, Response& response)
{
    auto identity = requireIdentity(request, response, true);
    AuthorizationService authorization;
    if(!identity)
    {
        return;
    }
    if(!authorization.canAdminister(identity->session.user))
    {
        response.status = 403;
        return;
    }
    auto short_name = pathParameter(request, "short");
    auto id_text = pathParameter(request, "id");
    auto field_id = id_text ? parsePositiveId(*id_text) : std::nullopt;
    if(!short_name || !field_id)
    {
        respondNotFound(response);
        return;
    }
    auto definition = data_source_->getGameDefinition(
        *short_name, GameContentScope::INCLUDE_INTERNAL);
    if(!definition || !*definition)
    {
        if(!definition)
        {
            respondInternalError(response);
        }
        else
        {
            respondNotFound(response);
        }
        return;
    }
    const auto field = std::ranges::find_if(
        (**definition).fields,
        [field_id](const GameField& candidate)
        {
            return candidate.id == *field_id;
        });
    if(field == (**definition).fields.end())
    {
        respondNotFound(response);
        return;
    }
    inja::json choices = inja::json::array();
    for(const GameChoice& choice : field->choices)
    {
        choices.push_back({{"existing", true}, {"value", choice.value}});
    }
    inja::json data = {
        {"action_url", urlFor(
            "game-field-update", {*short_name, std::to_string(*field_id)})},
        {"back_url", urlFor("game-edit", {*short_name})},
        {"choices", std::move(choices)},
        {"csrf_token", identity->session.csrf_token},
        {"delete_url", urlFor(
            "game-field-delete", {*short_name, std::to_string(*field_id)})},
        {"field_type", std::string(gameFieldTypeName(field->type))},
        {"game_name", (**definition).game.display_name},
        {"game_revision", (**definition).game.revision},
        {"heading", "Edit " + field->label},
        {"error_control_id", ""},
        {"error_field", ""},
        {"error_message", ""},
        {"has_errors", false},
        {"key", field->key},
        {"label", field->label},
        {"mode", "edit"},
        {"submit_label", "Save field"},
        {"title", "Edit field · Card Collection"},
    };
    addNavigationData(data, identity->session.user);
    respondTemplate(templates_, "game_field_form.html", data, response);
}

void App::handleGameFieldUpdate(
    const Request& request, Response& response)
{
    auto identity = requireIdentity(request, response, false);
    if(!identity || !verifyCsrf(request, *identity, response))
    {
        return;
    }
    if(!enforceTextBudget(request, response))
    {
        return;
    }
    auto short_name = pathParameter(request, "short");
    auto id_text = pathParameter(request, "id");
    auto field_id = id_text ? parsePositiveId(*id_text) : std::nullopt;
    auto revision = positiveParameter(request, "game_revision");
    auto label = scalarParameter(request, "label");
    if(!short_name || !field_id || !revision || !label)
    {
        if(!short_name || !field_id)
        {
            respondNotFound(response);
        }
        else
        {
            respondOperationError(
                response, !revision ? revision.error() : label.error(),
                "Invalid field form");
        }
        return;
    }
    const std::vector<std::string> choices =
        request.get_param_values("choice");
    auto updated = game_service_->updateField(
        identity->session.user.id, *short_name, *field_id, *revision,
        *label, choices);
    if(!updated)
    {
        const auto* validation =
            updated.error().as<GameDefinitionValidationError>();
        if(validation != nullptr)
        {
            respondGameFieldDefinitionError(
                response,
                *validation,
                *identity,
                *short_name,
                *field_id,
                "",
                *label,
                "",
                choices);
            return;
        }
        respondMutationError(
            response,
            updated.error(),
            "Failed field update",
            urlFor(
                "game-field-edit",
                {*short_name, std::to_string(*field_id)}),
            identity->session.user);
        return;
    }
    response.status = 303;
    response.set_header("Location", urlFor("game-edit", {*short_name}));
}

void App::handleGameFieldDeleteConfirm(
    const Request& request, Response& response)
{
    auto identity = requireIdentity(request, response, true);
    AuthorizationService authorization;
    if(!identity)
    {
        return;
    }
    if(!authorization.canAdminister(identity->session.user))
    {
        response.status = 403;
        return;
    }
    auto short_name = pathParameter(request, "short");
    auto id_text = pathParameter(request, "id");
    auto field_id = id_text ? parsePositiveId(*id_text) : std::nullopt;
    if(!short_name || !field_id)
    {
        respondNotFound(response);
        return;
    }
    auto definition = data_source_->getGameDefinition(
        *short_name, GameContentScope::INCLUDE_INTERNAL);
    if(!definition || !*definition)
    {
        if(!definition)
        {
            respondInternalError(response);
        }
        else
        {
            respondNotFound(response);
        }
        return;
    }
    const auto field = std::ranges::find_if(
        (**definition).fields,
        [field_id](const GameField& candidate)
        {
            return candidate.id == *field_id;
        });
    if(field == (**definition).fields.end())
    {
        respondNotFound(response);
        return;
    }
    inja::json data = {
        {"action_url", urlFor(
            "game-field-delete", {*short_name, std::to_string(*field_id)})},
        {"back_url", urlFor(
            "game-field-edit", {*short_name, std::to_string(*field_id)})},
        {"csrf_token", identity->session.csrf_token},
        {"field_label", field->label},
        {"game_revision", (**definition).game.revision},
        {"title", "Delete field · Card Collection"},
    };
    addNavigationData(data, identity->session.user);
    respondTemplate(templates_, "game_field_delete.html", data, response);
}

void App::handleGameFieldDelete(
    const Request& request, Response& response)
{
    auto identity = requireIdentity(request, response, false);
    if(!identity || !verifyCsrf(request, *identity, response))
    {
        return;
    }
    if(!enforceTextBudget(request, response))
    {
        return;
    }
    auto short_name = pathParameter(request, "short");
    auto id_text = pathParameter(request, "id");
    auto field_id = id_text ? parsePositiveId(*id_text) : std::nullopt;
    auto revision = positiveParameter(request, "game_revision");
    if(!short_name || !field_id || !revision)
    {
        if(!short_name || !field_id)
        {
            respondNotFound(response);
        }
        else
        {
            respondOperationError(
                response, revision.error(), "Invalid field deletion");
        }
        return;
    }
    auto deleted = game_service_->removeField(
        identity->session.user.id, *short_name, *field_id, *revision);
    if(!deleted)
    {
        respondMutationError(
            response,
            deleted.error(),
            "Failed field delete",
            urlFor(
                "game-field-edit",
                {*short_name, std::to_string(*field_id)}),
            identity->session.user);
        return;
    }
    response.status = 303;
    response.set_header("Location", urlFor("game-edit", {*short_name}));
}

void App::handleGameFieldOrder(
    const Request& request, Response& response)
{
    auto identity = requireIdentity(request, response, false);
    if(!identity || !verifyCsrf(request, *identity, response))
    {
        return;
    }
    if(!enforceTextBudget(request, response))
    {
        return;
    }
    auto short_name = pathParameter(request, "short");
    auto revision = positiveParameter(request, "game_revision");
    std::vector<std::int64_t> field_ids;
    for(const std::string& text : request.get_param_values("field_id"))
    {
        auto field_id = parsePositiveId(text);
        if(!field_id)
        {
            response.status = 422;
            response.set_content("Invalid field order\n", "text/plain");
            return;
        }
        field_ids.push_back(*field_id);
    }
    if(!short_name || !revision)
    {
        if(!short_name)
        {
            respondNotFound(response);
        }
        else
        {
            respondOperationError(
                response, revision.error(), "Invalid field order");
        }
        return;
    }
    auto reordered = game_service_->reorderFields(
        identity->session.user.id, *short_name, *revision, field_ids);
    if(!reordered)
    {
        respondMutationError(
            response,
            reordered.error(),
            "Failed reorder",
            urlFor("game-edit", {*short_name}),
            identity->session.user);
        return;
    }
    response.status = 303;
    response.set_header("Location", urlFor("game-edit", {*short_name}));
}

void App::handleAdminPromote(const Request& request, Response& response)
{
    auto identity = requireIdentity(request, response, false);
    if(!identity || !verifyCsrf(request, *identity, response))
    {
        return;
    }
    const auto parameter = request.path_params.find("id");
    const auto target = parameter == request.path_params.end()
        ? std::nullopt
        : parsePositiveId(parameter->second);
    if(!target)
    {
        respondNotFound(response);
        return;
    }
    auto promoted = user_service_->promote(
        identity->session.user.id, *target);
    if(!promoted)
    {
        respondOperationError(response, promoted.error(), "Failed promotion");
        return;
    }
    response.status = 303;
    response.set_header("Location", urlFor("admin-users"));
}

void App::handleCardNew(
    const Request& request,
    Response& response)
{
    auto identity = requireIdentity(request, response, true);
    AuthorizationService authorization;
    if(!identity)
    {
        return;
    }
    if(!authorization.canCreateCard(identity->session.user))
    {
        response.status = 403;
        return;
    }
    inja::json games = inja::json::array();
    const GameContentScope scope = authorization.gameContentScope(
        identity->session.user);
    auto definitions = loadGameDefinitions(*data_source_, scope);
    if(!definitions)
    {
        spdlog::error(
            "Failed to load games for card form: {}",
            definitions.error().msg());
        respondInternalError(response);
        return;
    }
    for(const GameDefinitionSnapshot& definition : *definitions)
    {
        games.push_back(gameFormJson(definition, {}));
    }
    inja::json series = inja::json::array();
    auto series_result = data_source_->getSeries(scope);
    if(!series_result)
    {
        spdlog::error(
            "Failed to load series for card form: {}",
            series_result.error().msg());
        respondInternalError(response);
        return;
    }
    for(const Series& item : *series_result)
    {
        series.push_back({
            {"game", item.game_short_name},
            {"id", item.id},
            {"name", item.name},
            {"selected", false},
        });
    }
    inja::json template_data = {
        {"action_url", urlFor("cards")},
        {"back_url",
         urlFor(identity->session.user.role == UserRole::ADMINISTRATOR
             ? "card-index"
             : "creator-cards")},
        {"csrf_token", identity->session.csrf_token},
        {"display_id", "New addition"},
        {"foil_action", "keep"},
        {"foil_url", ""},
        {"front_url", urlFor("static", {"card_placeholder.svg"})},
        {"error_control_id", ""},
        {"error_message", ""},
        {"files_need_reselection", false},
        {"game_revision", 0},
        {"games", std::move(games)},
        {"has_errors", false},
        {"has_foil", false},
        {"heading", "Create card"},
        {"long_description", ""},
        {"mode", "create"},
        {"model_url", urlFor("static", {"foil/model/card.obj"})},
        {"name", ""},
        {"preview_script_url",
         urlFor("static", {"foil/card_preview.js"})},
        {"shader_fragment_url",
         urlFor("static", {"foil/frag-shader.glsl"})},
        {"shader_vertex_url",
         urlFor("static", {"foil/vert-shader.glsl"})},
        {"spectral_lut_url",
         urlFor("static", {"foil/spectral_xyz.bin"})},
        {"rarity", 0},
        {"show_rarity",
         identity->session.user.role == UserRole::ADMINISTRATOR},
        {"revision", 0},
        {"selected_game", ""},
        {"series", std::move(series)},
        {"short_description", ""},
        {"submit_label", "Create card"},
        {"thumbnail_long_side", config_.thumbnail_long_side},
        {"title", "Create card · Card Collection"},
    };
    addNavigationData(template_data, identity->session.user);

    try
    {
        response.status = 200;
        response.set_content(
            templates_.render(card_form_template_, template_data),
            "text/html; charset=utf-8");
    }
    catch(const std::exception& error)
    {
        spdlog::error(
            "Failed to render the create-card form: {}",
            error.what());
        respondInternalError(response);
    }
}

void App::handleCardEdit(
    const Request& request,
    Response& response)
{
    auto actor = requireIdentity(request, response, true);
    if(!actor)
    {
        return;
    }
    const auto id_parameter = request.path_params.find("id");
    if(id_parameter == request.path_params.end())
    {
        respondNotFound(response);
        return;
    }
    auto identity = parsePublicId(id_parameter->second);
    if(!identity)
    {
        respondNotFound(response);
        return;
    }
    AuthorizationService authorization;
    const GameContentScope scope = authorization.gameContentScope(
        actor->session.user);
    auto card_result = data_source_->getCard(*identity, scope);
    if(!card_result)
    {
        spdlog::error(
            "Failed to load card {} for editing: {}",
            id_parameter->second,
            card_result.error().msg());
        respondInternalError(response);
        return;
    }
    if(!*card_result)
    {
        respondNotFound(response);
        return;
    }
    const Card& card = **card_result;
    if(!authorization.canEditCard(actor->session.user, card))
    {
        respondNotFound(response);
        return;
    }
    auto public_id_result = formatPublicId(card.identity);
    if(!public_id_result)
    {
        respondInternalError(response);
        return;
    }
    const std::string& public_id = *public_id_result;
    const std::string front_name =
        "front-art." + card.front_extension;
    const std::string front_url = urlFor(
        "card-asset",
        {public_id + "/" + front_name},
        {{"v", std::to_string(card.revision)}});
    std::string foil_url;
    if(card.foil_extension)
    {
        foil_url = urlFor(
            "card-asset",
            {public_id + "/foil." + *card.foil_extension},
            {{"v", std::to_string(card.revision)}});
    }

    std::vector<GameFieldValue> current_game_values;
    std::optional<GameDefinitionSnapshot> selected_game;
    if(card.identity.game_short_name)
    {
        auto values = data_source_->getCardFieldValues(card.id, scope);
        if(!values)
        {
            spdlog::error(
                "Failed to load game fields for card {}: {}",
                card.id,
                values.error().msg());
            respondInternalError(response);
            return;
        }
        if(!*values)
        {
            respondInternalError(response);
            return;
        }
        selected_game = std::move((**values).definition);
        current_game_values = std::move((**values).values);
    }

    inja::json games = inja::json::array();
    auto definitions = loadGameDefinitions(*data_source_, scope);
    if(!definitions)
    {
        respondInternalError(response);
        return;
    }
    for(const GameDefinitionSnapshot& definition : *definitions)
    {
        const bool selected = card.identity.game_short_name &&
            definition.game.short_name == *card.identity.game_short_name;
        const GameDefinitionSnapshot& displayed_definition =
            selected && selected_game ? *selected_game : definition;
        games.push_back(gameFormJson(
            displayed_definition,
            selected ? current_game_values : std::vector<GameFieldValue>{}));
    }

    auto all_series = data_source_->getSeries(scope);
    auto memberships = data_source_->getCardSeries(card.id);
    if(!all_series || !memberships)
    {
        spdlog::error("Failed to load series for card {} edit form", card.id);
        respondInternalError(response);
        return;
    }
    inja::json series = inja::json::array();
    for(const Series& item : *all_series)
    {
        series.push_back({
            {"game", item.game_short_name},
            {"id", item.id},
            {"name", item.name},
            {"selected", std::ranges::find(*memberships, item.id) !=
                memberships->end()},
        });
    }

    inja::json template_data = {
        {"action_url", urlFor("card-edit", {public_id})},
        {"back_url", urlFor("card", {public_id})},
        {"csrf_token", actor->session.csrf_token},
        {"display_id", uppercaseAscii(public_id)},
        {"foil_action", "keep"},
        {"foil_url", foil_url},
        {"front_url", front_url},
        {"error_control_id", ""},
        {"error_message", ""},
        {"files_need_reselection", false},
        {"games", std::move(games)},
        {"has_errors", false},
        {"has_foil", card.foil_extension.has_value()},
        {"heading", "Edit card"},
        {"long_description", card.long_description.value_or("")},
        {"mode", "edit"},
        {"model_url", urlFor("static", {"foil/model/card.obj"})},
        {"name", card.name},
        {"preview_script_url",
         urlFor("static", {"foil/card_preview.js"})},
        {"rarity", card.rarity},
        {"show_rarity",
         actor->session.user.role == UserRole::ADMINISTRATOR},
        {"revision", card.revision},
        {"game_revision", selected_game
             ? selected_game->game.revision
             : 0},
        {"selected_game", card.identity.game_short_name.value_or("")},
        {"series", std::move(series)},
        {"shader_fragment_url",
         urlFor("static", {"foil/frag-shader.glsl"})},
        {"shader_vertex_url",
         urlFor("static", {"foil/vert-shader.glsl"})},
        {"short_description", card.short_description.value_or("")},
        {"spectral_lut_url",
         urlFor("static", {"foil/spectral_xyz.bin"})},
        {"submit_label", "Save changes"},
        {"thumbnail_long_side", config_.thumbnail_long_side},
        {"title", "Edit " + card.name + " · Card Collection"},
    };
    addNavigationData(template_data, actor->session.user);

    try
    {
        response.status = 200;
        response.set_content(
            templates_.render(card_form_template_, template_data),
            "text/html; charset=utf-8");
    }
    catch(const std::exception& error)
    {
        spdlog::error(
            "Failed to render the edit form for card {}: {}",
            card.id,
            error.what());
        respondInternalError(response);
    }
}

void App::handleCardCreate(
    const Request& request,
    Response& response,
    const ContentReader& content_reader)
{
    auto actor = requireIdentity(request, response, false);
    AuthorizationService authorization;
    if(!actor)
    {
        return;
    }
    if(!authorization.canCreateCard(actor->session.user))
    {
        response.status = 403;
        return;
    }
    if(!request.is_multipart_form_data())
    {
        respondBadRequest(response, "Expected a multipart form upload");
        return;
    }

    MultipartReader multipart_reader(config_.card_storage_root);
    auto upload = multipart_reader.read(content_reader);
    if(!upload)
    {
        respondMutationError(
            response,
            upload.error(),
            "Failed to receive a card upload",
            urlFor("card-new"),
            actor->session.user,
            true);
        return;
    }
    const auto csrf = upload->fields.find("csrf_token");
    if(csrf == upload->fields.end() ||
       !constantTimeEqual(csrf->second, actor->session.csrf_token))
    {
        response.status = 403;
        response.set_content("Invalid CSRF token\n", "text/plain");
        return;
    }

    const auto game = upload->fields.find("game");
    const std::string game_short_name = game == upload->fields.end()
        ? std::string()
        : std::string(mw::strip(game->second));
    const auto source_mode = upload->fields.find("source_mode");
    if(source_mode != upload->fields.end() &&
       source_mode->second != "files" &&
       source_mode->second != "urls")
    {
        respondBadRequest(
            response, "Unknown image source mode");
        return;
    }
    const auto name_position = upload->fields.find("name");
    const std::string name = name_position == upload->fields.end()
        ? std::string()
        : std::string(mw::strip(name_position->second));
    if(name.empty())
    {
        respondBadRequest(response, "Card name is required");
        return;
    }
    if(name.size() > 200)
    {
        respondBadRequest(response, "Card name is too long");
        return;
    }
    if(!upload->front)
    {
        respondBadRequest(response, "Front artwork is required");
        return;
    }
    const bool rarity_was_submitted = upload->fields.contains("rarity");
    mw::E<std::int64_t> rarity = std::int64_t{0};
    if(rarity_was_submitted)
    {
        rarity = parseRarity(upload->fields);
    }
    if(!rarity)
    {
        respondBadRequest(response, rarity.error().msg());
        return;
    }

    CreateCardInput input = {
        name,
        optionalText(upload->fields, "short_description"),
        optionalText(upload->fields, "long_description"),
        *rarity,
        upload->staging_directory,
        *upload->front,
        upload->foil,
        upload->thumbnail,
        rarity_was_submitted,
    };
    mw::E<std::string> created = std::unexpected(
        mw::runtimeError("Card creation was not dispatched"));
    if(game_short_name.empty())
    {
        if(upload->fields.contains("game_revision") ||
           !gameFields(upload->fields).empty() ||
           !upload->series_ids.empty())
        {
            respondMutationError(
                response,
                mw::httpError(
                    422,
                    "Loose cards cannot have game fields or series"),
                "Failed to validate loose card fields",
                urlFor("card-new"),
                actor->session.user,
                true);
            return;
        }
        created = card_service_->createLooseCard(
            actor->session.user.id, std::move(input));
    }
    else
    {
        auto game_revision = parseGameRevision(upload->fields);
        if(!game_revision)
        {
            respondMutationError(
                response,
                game_revision.error(),
                "Failed to parse game revision",
                urlFor("card-new"),
                actor->session.user,
                true);
            return;
        }
        auto memberships = seriesMemberships(upload->series_ids);
        if(!memberships)
        {
            respondMutationError(
                response,
                memberships.error(),
                "Failed to validate series memberships",
                urlFor("card-new"),
                actor->session.user,
                true);
            return;
        }
        created = card_service_->createGameCard(
            actor->session.user.id,
            std::move(input),
            game_short_name,
            *game_revision,
            gameFields(upload->fields),
            *memberships);
    }
    if(!created)
    {
        const auto* field_error =
            created.error().as<GameFieldValidationError>();
        if(field_error != nullptr)
        {
            respondCardFieldError(
                response, *field_error, *upload, *actor);
            return;
        }
        respondMutationError(
            response,
            created.error(),
            "Failed to create a card",
            urlFor("card-new"),
            actor->session.user,
            true);
        return;
    }

    response.status = 303;
    response.set_header("Location", urlFor("card", {*created}));
}

void App::handleCardUpdate(
    const Request& request,
    Response& response,
    const ContentReader& content_reader)
{
    auto actor = requireIdentity(request, response, false);
    if(!actor)
    {
        return;
    }
    const auto id_parameter = request.path_params.find("id");
    if(id_parameter == request.path_params.end())
    {
        respondNotFound(response);
        return;
    }
    auto identity = parsePublicId(id_parameter->second);
    if(!identity)
    {
        respondNotFound(response);
        return;
    }
    AuthorizationService authorization;
    const GameContentScope scope = authorization.gameContentScope(
        actor->session.user);
    auto card_result = data_source_->getCard(*identity, scope);
    if(!card_result)
    {
        spdlog::error(
            "Failed to load card {} for update: {}",
            id_parameter->second,
            card_result.error().msg());
        respondInternalError(response);
        return;
    }
    if(!*card_result)
    {
        respondNotFound(response);
        return;
    }
    if(!authorization.canEditCard(actor->session.user, **card_result))
    {
        respondNotFound(response);
        return;
    }
    const Card form_card = **card_result;
    if(!request.is_multipart_form_data())
    {
        respondBadRequest(response, "Expected a multipart form upload");
        return;
    }

    MultipartReader multipart_reader(config_.card_storage_root);
    auto upload = multipart_reader.read(content_reader);
    if(!upload)
    {
        respondMutationError(
            response,
            upload.error(),
            "Failed to receive a card edit",
            urlFor("card-edit", {id_parameter->second}),
            actor->session.user,
            true);
        return;
    }
    const auto csrf = upload->fields.find("csrf_token");
    if(csrf == upload->fields.end() ||
       !constantTimeEqual(csrf->second, actor->session.csrf_token))
    {
        response.status = 403;
        response.set_content("Invalid CSRF token\n", "text/plain");
        return;
    }
    if(upload->fields.contains("game"))
    {
        respondBadRequest(response, "Card identity cannot be edited");
        return;
    }
    const auto source_mode = upload->fields.find("source_mode");
    if(source_mode != upload->fields.end() &&
       source_mode->second != "files" &&
       source_mode->second != "urls")
    {
        respondBadRequest(response, "Unknown image source mode");
        return;
    }
    const auto name_position = upload->fields.find("name");
    const std::string name = name_position == upload->fields.end()
        ? std::string()
        : std::string(mw::strip(name_position->second));
    if(name.empty())
    {
        respondBadRequest(response, "Card name is required");
        return;
    }
    if(name.size() > 200)
    {
        respondBadRequest(response, "Card name is too long");
        return;
    }
    const bool rarity_was_submitted = upload->fields.contains("rarity");
    mw::E<std::int64_t> rarity = (**card_result).rarity;
    if(rarity_was_submitted)
    {
        rarity = parseRarity(upload->fields);
    }
    if(!rarity)
    {
        respondBadRequest(response, rarity.error().msg());
        return;
    }
    auto revision = parseRevision(upload->fields);
    if(!revision)
    {
        respondBadRequest(response, revision.error().msg());
        return;
    }

    const auto front_position = upload->fields.find("front_action");
    FrontAssetAction front_action;
    if(front_position == upload->fields.end())
    {
        respondBadRequest(response, "Front artwork action is required");
        return;
    }
    if(front_position->second == "keep")
    {
        front_action = FrontAssetAction::KEEP;
    }
    else if(front_position->second == "replace")
    {
        front_action = FrontAssetAction::REPLACE;
    }
    else
    {
        respondBadRequest(response, "Unknown front artwork action");
        return;
    }

    const auto foil_position = upload->fields.find("foil_action");
    FoilAssetAction foil_action;
    if(foil_position == upload->fields.end())
    {
        respondBadRequest(response, "Foil control action is required");
        return;
    }
    if(foil_position->second == "keep")
    {
        foil_action = FoilAssetAction::KEEP;
    }
    else if(foil_position->second == "replace")
    {
        foil_action = FoilAssetAction::REPLACE;
    }
    else if(foil_position->second == "remove")
    {
        foil_action = FoilAssetAction::REMOVE;
    }
    else
    {
        respondBadRequest(response, "Unknown foil control action");
        return;
    }

    UpdateLooseCardInput input = {
        std::move(**card_result),
        *revision,
        name,
        optionalText(upload->fields, "short_description"),
        optionalText(upload->fields, "long_description"),
        *rarity,
        upload->staging_directory,
        front_action,
        foil_action,
        upload->front,
        upload->foil,
        upload->thumbnail,
        rarity_was_submitted,
    };
    mw::E<std::string> updated = std::unexpected(
        mw::runtimeError("Card update was not dispatched"));
    if(!input.current_card.identity.game_short_name)
    {
        if(upload->fields.contains("game_revision") ||
           !gameFields(upload->fields).empty() ||
           !upload->series_ids.empty())
        {
            respondMutationError(
                response,
                mw::httpError(
                    422,
                    "Loose cards cannot have game fields or series"),
                "Failed to validate loose card fields",
                urlFor("card-edit", {id_parameter->second}),
                actor->session.user,
                true);
            return;
        }
        updated = card_service_->updateLooseCard(
            actor->session.user.id, std::move(input));
    }
    else
    {
        auto game_revision = parseGameRevision(upload->fields);
        if(!game_revision)
        {
            respondMutationError(
                response,
                game_revision.error(),
                "Failed to parse game revision",
                urlFor("card-edit", {id_parameter->second}),
                actor->session.user,
                true);
            return;
        }
        auto membership_ids = seriesMemberships(upload->series_ids);
        if(!membership_ids)
        {
            respondMutationError(
                response,
                membership_ids.error(),
                "Failed to validate series memberships",
                urlFor("card-edit", {id_parameter->second}),
                actor->session.user,
                true);
            return;
        }
        updated = card_service_->updateGameCard(
            actor->session.user.id,
            std::move(input),
            *game_revision,
            gameFields(upload->fields),
            *membership_ids);
    }
    if(!updated)
    {
        const auto* field_error =
            updated.error().as<GameFieldValidationError>();
        if(field_error != nullptr)
        {
            respondCardFieldError(
                response, *field_error, *upload, *actor, &form_card);
            return;
        }
        respondMutationError(
            response,
            updated.error(),
            "Failed to update a card",
            urlFor("card-edit", {id_parameter->second}),
            actor->session.user,
            true);
        return;
    }

    response.status = 303;
    response.set_header("Location", urlFor("card", {*updated}));
}

void App::handleCardView(
    const Request& request,
    Response& response)
{
    auto actor = requireIdentity(request, response, true);
    if(!actor)
    {
        return;
    }
    const auto id_parameter = request.path_params.find("id");
    if(id_parameter == request.path_params.end())
    {
        respondNotFound(response);
        return;
    }

    auto identity = parsePublicId(id_parameter->second);
    if(!identity)
    {
        respondNotFound(response);
        return;
    }
    AuthorizationService authorization;
    const GameContentScope scope = authorization.gameContentScope(
        actor->session.user);
    auto card_result = data_source_->getCard(*identity, scope);
    if(!card_result)
    {
        spdlog::error(
            "Failed to load card {}: {}",
            id_parameter->second,
            card_result.error().msg());
        respondInternalError(response);
        return;
    }
    if(!*card_result)
    {
        respondNotFound(response);
        return;
    }
    const Card& card = **card_result;
    auto owns = data_source_->userOwnsCard(actor->session.user.id, card.id);
    if(!owns)
    {
        respondInternalError(response);
        return;
    }
    if(!authorization.canViewCard(actor->session.user, card, *owns))
    {
        respondNotFound(response);
        return;
    }

    auto pool_cards = data_source_->getPoolCards();
    if(!pool_cards)
    {
        respondInternalError(response);
        return;
    }
    CardPoolService pool_service;
    const auto pool = pool_service.calculate(*pool_cards);
    std::string probability = "Not currently in the pull pool";
    const auto pool_entry = std::ranges::find_if(
        pool,
        [&card](const CardPoolEntry& entry)
        {
            return entry.card.id == card.id;
        });
    if(pool_entry != pool.end())
    {
        probability = formatProbability(pool_entry->probability);
    }

    auto public_id_result = formatPublicId(card.identity);
    if(!public_id_result)
    {
        spdlog::error(
            "Failed to format card {}: {}",
            card.id,
            public_id_result.error().msg());
        respondInternalError(response);
        return;
    }
    const std::string& public_id = *public_id_result;
    const std::filesystem::path asset_root =
        config_.card_storage_root / "published" / public_id;
    const std::string front_name = "front-art." + card.front_extension;
    const bool front_exists = isRegularFile(
        asset_root / front_name, card.id);
    const std::string front_url = front_exists
        ? urlFor(
            "card-asset",
            {public_id + "/" + front_name},
            {{"v", std::to_string(card.revision)}})
        : urlFor("static", {"card_placeholder.svg"});

    std::optional<std::string> foil_url;
    bool foil_exists = false;
    if(card.foil_extension)
    {
        const std::string foil_name = "foil." + *card.foil_extension;
        foil_exists = isRegularFile(asset_root / foil_name, card.id);
        if(foil_exists)
        {
            foil_url = urlFor(
                "card-asset",
                {public_id + "/" + foil_name},
                {{"v", std::to_string(card.revision)}});
        }
    }

    inja::json game_fields = inja::json::array();
    std::string game_name = "Loose card";
    if(card.identity.game_short_name)
    {
        auto fields = data_source_->getCardFieldValues(card.id, scope);
        if(!fields)
        {
            spdlog::error(
                "Failed to load game fields for card {}: {}",
                card.id, fields.error().msg());
            respondInternalError(response);
            return;
        }
        if(!*fields)
        {
            respondInternalError(response);
            return;
        }
        game_name = (**fields).definition.game.display_name;
        for(const GameFieldValue& value : (**fields).values)
        {
            const auto definition = std::ranges::find_if(
                (**fields).definition.fields,
                [&value](const GameField& field)
                {
                    return field.id == value.field_id;
                });
            if(definition == (**fields).definition.fields.end())
            {
                respondInternalError(response);
                return;
            }
            game_fields.push_back({
                {"label", definition->label},
                {"value", fieldValue({value}, value.field_id)},
            });
        }
    }

    auto membership_result = data_source_->getCardSeries(card.id);
    if(!membership_result)
    {
        spdlog::error(
            "Failed to load series for card {}: {}",
            card.id,
            membership_result.error().msg());
        respondInternalError(response);
        return;
    }
    inja::json series = inja::json::array();
    for(std::int64_t series_id : *membership_result)
    {
        auto series_result = data_source_->getSeries(series_id, scope);
        if(!series_result)
        {
            spdlog::error(
                "Failed to load series {} for card {}: {}",
                series_id,
                card.id,
                series_result.error().msg());
            respondInternalError(response);
            return;
        }
        if(*series_result)
        {
            series.push_back((**series_result).name);
        }
    }

    std::vector<std::string> missing_assets;
    if(!front_exists)
    {
        missing_assets.emplace_back("front artwork");
    }
    if(card.foil_extension && !foil_exists)
    {
        missing_assets.emplace_back("foil control");
    }

    std::vector<HtmlSubstitution> html_substitutions;
    std::string short_description_marker;
    if(card.short_description)
    {
        auto rendered = MarkdownRenderer().render(*card.short_description);
        if(!rendered)
        {
            spdlog::error(
                "Failed to render short description for card {}: {}",
                card.id,
                rendered.error().msg());
            respondInternalError(response);
            return;
        }
        short_description_marker =
            "CARD_COLLECTION_HTML_" + random_->hex(16);
        html_substitutions.push_back({
            short_description_marker, std::move(*rendered)});
    }
    std::string long_description_marker;
    if(card.long_description)
    {
        auto rendered = MarkdownRenderer().render(*card.long_description);
        if(!rendered)
        {
            spdlog::error(
                "Failed to render long description for card {}: {}",
                card.id,
                rendered.error().msg());
            respondInternalError(response);
            return;
        }
        long_description_marker =
            "CARD_COLLECTION_HTML_" + random_->hex(16);
        html_substitutions.push_back({
            long_description_marker, std::move(*rendered)});
    }

    inja::json template_data = {
        {"asset_warning", !missing_assets.empty()},
        {"back_url", urlFor("card-index")},
        {"display_id", uppercaseAscii(public_id)},
        {"delete_url", urlFor("card-delete", {public_id})},
        {"edit_url", urlFor("card-edit", {public_id})},
        {"foil_url", foil_url.value_or("")},
        {"front_url", front_url},
        {"game", game_name},
        {"game_fields", std::move(game_fields)},
        {"has_foil", card.foil_extension.has_value()},
        {"has_long_description", card.long_description.has_value()},
        {"has_short_description", card.short_description.has_value()},
        {"long_description", long_description_marker},
        {"missing_assets", missing_assets},
        {"model_url", urlFor("static", {"foil/model/card.obj"})},
        {"name", card.name},
        {"preview_script_url",
         urlFor("static", {"foil/card_preview.js"})},
        {"probability", probability},
        {"rarity", card.rarity},
        {"series", std::move(series)},
        {"shader_fragment_url",
         urlFor("static", {"foil/frag-shader.glsl"})},
        {"shader_vertex_url",
         urlFor("static", {"foil/vert-shader.glsl"})},
        {"short_description", short_description_marker},
        {"spectral_lut_url",
         urlFor("static", {"foil/spectral_xyz.bin"})},
        {"title", card.name + " · Card Collection"},
    };
    addNavigationData(template_data, actor->session.user);

    try
    {
        response.status = 200;
        auto content = renderWithHtml(
            templates_,
            card_view_template_,
            template_data,
            html_substitutions);
        if(!content)
        {
            throw std::runtime_error(content.error().msg());
        }
        response.set_content(*content, "text/html; charset=utf-8");
    }
    catch(const std::exception& error)
    {
        spdlog::error("Failed to render card {}: {}", card.id, error.what());
        respondInternalError(response);
    }
}

void App::handleCardDeleteConfirm(
    const Request& request,
    Response& response)
{
    auto actor = requireIdentity(request, response, true);
    AuthorizationService authorization;
    if(!actor)
    {
        return;
    }
    if(!authorization.canDeleteCard(actor->session.user))
    {
        response.status = 403;
        return;
    }
    const auto parameter = request.path_params.find("id");
    if(parameter == request.path_params.end())
    {
        respondNotFound(response);
        return;
    }
    auto identity = parsePublicId(parameter->second);
    if(!identity)
    {
        respondNotFound(response);
        return;
    }
    auto card = data_source_->getCard(
        *identity, GameContentScope::INCLUDE_INTERNAL);
    if(!card || !*card)
    {
        if(!card)
        {
            spdlog::error(
                "Failed to load card for deletion: {}",
                card.error().msg());
            respondInternalError(response);
        }
        else
        {
            respondNotFound(response);
        }
        return;
    }
    inja::json template_data = {
        {"action_url", urlFor("card-delete", {parameter->second})},
        {"back_url", urlFor("card", {parameter->second})},
        {"csrf_token", actor->session.csrf_token},
        {"name", (**card).name},
        {"title", "Delete card · Card Collection"},
    };
    addNavigationData(template_data, actor->session.user);
    try
    {
        response.status = 200;
        response.set_content(
            templates_.render(card_delete_template_, template_data),
            "text/html; charset=utf-8");
    }
    catch(const std::exception& error)
    {
        spdlog::error("Failed to render card deletion: {}", error.what());
        respondInternalError(response);
    }
}

void App::handleCardDelete(
    const Request& request,
    Response& response)
{
    auto actor = requireIdentity(request, response, false);
    if(!actor || !verifyCsrf(request, *actor, response))
    {
        return;
    }
    const auto parameter = request.path_params.find("id");
    if(parameter == request.path_params.end())
    {
        respondNotFound(response);
        return;
    }
    auto identity = parsePublicId(parameter->second);
    if(!identity)
    {
        respondNotFound(response);
        return;
    }
    auto card = data_source_->getCard(
        *identity, GameContentScope::INCLUDE_INTERNAL);
    if(!card || !*card)
    {
        if(!card)
        {
            spdlog::error(
                "Failed to load card for deletion: {}",
                card.error().msg());
            respondInternalError(response);
        }
        else
        {
            respondNotFound(response);
        }
        return;
    }
    auto deleted = card_service_->deleteCard(
        actor->session.user.id, **card);
    if(!deleted)
    {
        respondOperationError(
            response, deleted.error(), "Failed to delete a card");
        return;
    }
    response.status = 303;
    response.set_header("Location", urlFor("card-index"));
}

std::string App::urlFor(
    const std::string& name,
    const std::vector<std::string>& arguments,
    const QueryParameters& query) const
{
    const RouteDefinition& route = routeDefinition(name);
    if(route.kind == RouteKind::STATIC_MOUNT)
    {
        if(arguments.size() != 1)
        {
            throw std::invalid_argument(
                "Incorrect static route argument count");
        }
        return url_builder_.absoluteFromRelativePath(
            route.segments, arguments.front(), query);
    }

    return url_builder_.absolute(
        resolveRoute(route, arguments, RouteSegmentKind::DYNAMIC), query);
}

void App::handleCardIndex(
    const Request& request,
    Response& response)
{
    auto actor = requireIdentity(request, response, true);
    AuthorizationService authorization;
    if(!actor)
    {
        return;
    }
    const bool creator_route = request.path == getPath("creator-cards");
    if((creator_route &&
        !authorization.canCreateCard(actor->session.user)) ||
       (!creator_route &&
        !authorization.canAdminister(actor->session.user)))
    {
        response.status = 403;
        return;
    }
    auto cards_result = creator_route
        ? data_source_->getCardsByCreator(
              actor->session.user.id, GameContentScope::PUBLIC_ONLY)
        : data_source_->getCards(GameContentScope::INCLUDE_INTERNAL);
    if(!cards_result)
    {
        spdlog::error(
            "Failed to load the card index: {}",
            cards_result.error().msg());
        respondInternalError(response);
        return;
    }

    std::vector<IndexCard> cards;
    cards.reserve(cards_result->size());
    for(const Card& card : *cards_result)
    {
        auto public_id = formatPublicId(card.identity);
        if(!public_id)
        {
            spdlog::error(
                "Failed to format card {} for the index: {}",
                card.id,
                public_id.error().msg());
            respondInternalError(response);
            return;
        }
        cards.push_back({&card, std::move(*public_id)});
    }

    const bool descending =
        request.has_param("direction") &&
        request.get_param_value("direction") == "desc";
    sortIndexCards(cards, descending);

    std::unordered_map<std::string, GameVisibility> game_visibility;
    if(!creator_route)
    {
        auto games = data_source_->getGames(
            GameContentScope::INCLUDE_INTERNAL);
        if(!games)
        {
            respondInternalError(response);
            return;
        }
        for(const Game& game : *games)
        {
            game_visibility.emplace(game.short_name, game.visibility);
        }
    }

    inja::json template_cards = inja::json::array();
    for(const IndexCard& index_card : cards)
    {
        const Card& card = *index_card.card;
        const std::string thumbnail_name =
            "thumb." + card.thumbnail_extension;
        const std::filesystem::path thumbnail_path =
            config_.card_storage_root / "published" /
            index_card.public_id / thumbnail_name;

        std::string thumbnail_url;
        std::error_code filesystem_error;
        const bool thumbnail_exists = std::filesystem::is_regular_file(
            thumbnail_path, filesystem_error);
        if(filesystem_error &&
           filesystem_error != std::errc::no_such_file_or_directory)
        {
            spdlog::warn(
                "Failed to inspect the thumbnail for card {}: {}",
                card.id,
                filesystem_error.message());
        }

        if(thumbnail_exists)
        {
            thumbnail_url = urlFor(
                "card-asset",
                {index_card.public_id + "/" + thumbnail_name},
                {{"v", std::to_string(card.revision)}});
        }
        else
        {
            thumbnail_url = urlFor("static", {"card_placeholder.svg"});
        }

        bool internal = false;
        if(!creator_route && card.identity.game_short_name)
        {
            const auto visibility = game_visibility.find(
                *card.identity.game_short_name);
            if(visibility == game_visibility.end())
            {
                respondInternalError(response);
                return;
            }
            internal = visibility->second == GameVisibility::INTERNAL;
        }
        template_cards.push_back({
            {"display_id", uppercaseAscii(index_card.public_id)},
            {"internal", internal},
            {"name", card.name},
            {"thumbnail_url", std::move(thumbnail_url)},
            {"url", urlFor("card", {index_card.public_id})},
        });
    }

    const std::string index_route = creator_route
        ? "creator-cards"
        : "card-index";
    inja::json template_data = {
        {"ascending_url",
         urlFor(index_route, {}, {{"sort", "id"}, {"direction", "asc"}})},
        {"administrator", !creator_route},
        {"cards", std::move(template_cards)},
        {"create_url", urlFor("card-new")},
        {"descending", descending},
        {"descending_url",
         urlFor(index_route, {}, {{"sort", "id"}, {"direction", "desc"}})},
        {"games_url", urlFor("admin-games")},
        {"series_url", urlFor("series-index")},
        {"title", "Card Collection"},
        {"users_url", urlFor("admin-users")},
    };
    addNavigationData(template_data, actor->session.user);

    try
    {
        response.status = 200;
        response.set_content(
            templates_.render(card_index_template_, template_data),
            "text/html; charset=utf-8");
    }
    catch(const std::exception& error)
    {
        spdlog::error("Failed to render the card index: {}", error.what());
        respondInternalError(response);
    }
}

void App::handleSeriesIndex(
    const Request& request,
    Response& response)
{
    auto actor = requireIdentity(request, response, true);
    AuthorizationService authorization;
    if(!actor)
    {
        return;
    }
    if(!authorization.canAdminister(actor->session.user))
    {
        response.status = 403;
        return;
    }
    auto series_result = data_source_->getSeries(
        GameContentScope::INCLUDE_INTERNAL);
    if(!series_result)
    {
        spdlog::error(
            "Failed to load the series index: {}",
            series_result.error().msg());
        respondInternalError(response);
        return;
    }
    inja::json series = inja::json::array();
    std::vector<HtmlSubstitution> html_substitutions;
    auto games_result = data_source_->getGames(
        GameContentScope::INCLUDE_INTERNAL);
    if(!games_result)
    {
        respondInternalError(response);
        return;
    }
    for(const Series& item : *series_result)
    {
        const auto game = std::ranges::find_if(
            *games_result,
            [&item](const Game& candidate)
            {
                return candidate.short_name == item.game_short_name;
            });
        auto rendered = MarkdownRenderer().render(item.description);
        if(!rendered)
        {
            spdlog::error(
                "Failed to render description for series {}: {}",
                item.id,
                rendered.error().msg());
            respondInternalError(response);
            return;
        }
        const std::string marker =
            "CARD_COLLECTION_HTML_" + random_->hex(16);
        html_substitutions.push_back({marker, std::move(*rendered)});
        series.push_back({
            {"delete_url",
             urlFor("series-delete", {std::to_string(item.id)})},
            {"description", marker},
            {"has_description", !item.description.empty()},
            {"edit_url",
             urlFor("series-edit", {std::to_string(item.id)})},
            {"game", game == games_result->end()
                 ? uppercaseAscii(item.game_short_name)
                 : game->display_name},
            {"internal", game != games_result->end() &&
                 game->visibility == GameVisibility::INTERNAL},
            {"name", item.name},
        });
    }
    inja::json template_data = {
        {"can_create", !games_result->empty()},
        {"create_url", urlFor("series-new")},
        {"series", std::move(series)},
        {"title", "Series · Card Collection"},
    };
    addNavigationData(template_data, actor->session.user);
    try
    {
        response.status = 200;
        auto content = renderWithHtml(
            templates_,
            series_index_template_,
            template_data,
            html_substitutions);
        if(!content)
        {
            throw std::runtime_error(content.error().msg());
        }
        response.set_content(*content, "text/html; charset=utf-8");
    }
    catch(const std::exception& error)
    {
        spdlog::error("Failed to render the series index: {}", error.what());
        respondInternalError(response);
    }
}

void App::handleSeriesNew(
    const Request& request,
    Response& response)
{
    auto actor = requireIdentity(request, response, true);
    AuthorizationService authorization;
    if(!actor)
    {
        return;
    }
    if(!authorization.canAdminister(actor->session.user))
    {
        response.status = 403;
        return;
    }
    inja::json games = inja::json::array();
    auto games_result = data_source_->getGames(
        GameContentScope::INCLUDE_INTERNAL);
    if(!games_result)
    {
        respondInternalError(response);
        return;
    }
    for(const Game& game : *games_result)
    {
        games.push_back({
            {"display_name", game.display_name},
            {"internal", game.visibility == GameVisibility::INTERNAL},
            {"short_name", game.short_name},
        });
    }
    inja::json template_data = {
        {"action_url", urlFor("series")},
        {"back_url", urlFor("series-index")},
        {"csrf_token", actor->session.csrf_token},
        {"description", ""},
        {"game", ""},
        {"games", std::move(games)},
        {"heading", "Create series"},
        {"mode", "create"},
        {"name", ""},
        {"submit_label", "Create series"},
        {"title", "Create series · Card Collection"},
    };
    addNavigationData(template_data, actor->session.user);
    try
    {
        response.status = 200;
        response.set_content(
            templates_.render(series_form_template_, template_data),
            "text/html; charset=utf-8");
    }
    catch(const std::exception& error)
    {
        spdlog::error("Failed to render the series form: {}", error.what());
        respondInternalError(response);
    }
}

void App::handleSeriesCreate(
    const Request& request,
    Response& response)
{
    auto actor = requireIdentity(request, response, false);
    if(!actor || !verifyCsrf(request, *actor, response))
    {
        return;
    }
    if(!request.has_param("game") || !request.has_param("name"))
    {
        respondBadRequest(response, "Game and series name are required");
        return;
    }
    auto created = series_service_->create(
        actor->session.user.id,
        request.get_param_value("game"),
        request.get_param_value("name"),
        request.has_param("description")
            ? request.get_param_value("description")
            : std::string());
    if(!created)
    {
        respondOperationError(
            response, created.error(), "Failed to create a series");
        return;
    }
    response.status = 303;
    response.set_header("Location", urlFor("series-index"));
}

void App::handleSeriesEdit(
    const Request& request,
    Response& response)
{
    auto actor = requireIdentity(request, response, true);
    AuthorizationService authorization;
    if(!actor)
    {
        return;
    }
    if(!authorization.canAdminister(actor->session.user))
    {
        response.status = 403;
        return;
    }
    const auto parameter = request.path_params.find("id");
    const auto id = parameter == request.path_params.end()
        ? std::nullopt
        : parsePositiveId(parameter->second);
    if(!id)
    {
        respondNotFound(response);
        return;
    }
    auto series_result = data_source_->getSeries(
        *id, GameContentScope::INCLUDE_INTERNAL);
    if(!series_result)
    {
        spdlog::error(
            "Failed to load series {}: {}",
            *id,
            series_result.error().msg());
        respondInternalError(response);
        return;
    }
    if(!*series_result)
    {
        respondNotFound(response);
        return;
    }
    const Series& series = **series_result;
    auto game = data_source_->getGameDefinition(
        series.game_short_name, GameContentScope::INCLUDE_INTERNAL);
    if(!game || !*game)
    {
        respondInternalError(response);
        return;
    }
    inja::json template_data = {
        {"action_url", urlFor("series-item", {std::to_string(*id)})},
        {"back_url", urlFor("series-index")},
        {"csrf_token", actor->session.csrf_token},
        {"description", series.description},
        {"game", (**game).game.display_name},
        {"games", inja::json::array()},
        {"heading", "Edit series"},
        {"mode", "edit"},
        {"name", series.name},
        {"submit_label", "Save changes"},
        {"title", "Edit " + series.name + " · Card Collection"},
    };
    addNavigationData(template_data, actor->session.user);
    try
    {
        response.status = 200;
        response.set_content(
            templates_.render(series_form_template_, template_data),
            "text/html; charset=utf-8");
    }
    catch(const std::exception& error)
    {
        spdlog::error("Failed to render series {}: {}", *id, error.what());
        respondInternalError(response);
    }
}

void App::handleSeriesUpdate(
    const Request& request,
    Response& response)
{
    auto actor = requireIdentity(request, response, false);
    if(!actor || !verifyCsrf(request, *actor, response))
    {
        return;
    }
    const auto parameter = request.path_params.find("id");
    const auto id = parameter == request.path_params.end()
        ? std::nullopt
        : parsePositiveId(parameter->second);
    if(!id || !request.has_param("name"))
    {
        respondBadRequest(response, "Valid series ID and name are required");
        return;
    }
    auto updated = series_service_->update(
        actor->session.user.id,
        *id,
        request.get_param_value("name"),
        request.has_param("description")
            ? request.get_param_value("description")
            : std::string());
    if(!updated)
    {
        respondOperationError(
            response, updated.error(), "Failed to update a series");
        return;
    }
    response.status = 303;
    response.set_header("Location", urlFor("series-index"));
}

void App::handleSeriesDeleteConfirm(
    const Request& request,
    Response& response)
{
    auto actor = requireIdentity(request, response, true);
    AuthorizationService authorization;
    if(!actor)
    {
        return;
    }
    if(!authorization.canAdminister(actor->session.user))
    {
        response.status = 403;
        return;
    }
    const auto parameter = request.path_params.find("id");
    const auto id = parameter == request.path_params.end()
        ? std::nullopt
        : parsePositiveId(parameter->second);
    if(!id)
    {
        respondNotFound(response);
        return;
    }
    auto series_result = data_source_->getSeries(
        *id, GameContentScope::INCLUDE_INTERNAL);
    if(!series_result || !*series_result)
    {
        if(!series_result)
        {
            spdlog::error(
                "Failed to load series {} for deletion: {}",
                *id,
                series_result.error().msg());
            respondInternalError(response);
        }
        else
        {
            respondNotFound(response);
        }
        return;
    }
    inja::json template_data = {
        {"action_url", urlFor("series-delete", {std::to_string(*id)})},
        {"back_url", urlFor("series-index")},
        {"csrf_token", actor->session.csrf_token},
        {"name", (**series_result).name},
        {"title", "Delete series · Card Collection"},
    };
    addNavigationData(template_data, actor->session.user);
    try
    {
        response.status = 200;
        response.set_content(
            templates_.render(series_delete_template_, template_data),
            "text/html; charset=utf-8");
    }
    catch(const std::exception& error)
    {
        spdlog::error(
            "Failed to render series deletion: {}", error.what());
        respondInternalError(response);
    }
}

void App::handleSeriesDelete(
    const Request& request,
    Response& response)
{
    auto actor = requireIdentity(request, response, false);
    if(!actor || !verifyCsrf(request, *actor, response))
    {
        return;
    }
    const auto parameter = request.path_params.find("id");
    const auto id = parameter == request.path_params.end()
        ? std::nullopt
        : parsePositiveId(parameter->second);
    if(!id)
    {
        respondBadRequest(response, "A valid series ID is required");
        return;
    }
    auto removed = series_service_->remove(actor->session.user.id, *id);
    if(!removed)
    {
        respondOperationError(
            response, removed.error(), "Failed to delete a series");
        return;
    }
    response.status = 303;
    response.set_header("Location", urlFor("series-index"));
}

void App::setup()
{
    server.set_post_routing_handler(
        []([[maybe_unused]] const httplib::Request& request,
           httplib::Response& response)
        {
            if(!response.has_header("Content-Security-Policy"))
            {
                response.set_header(
                    "Content-Security-Policy",
                    "default-src 'self'; img-src 'self' data:; "
                    "script-src 'self' 'unsafe-inline'; style-src 'self'; "
                    "object-src 'none'; base-uri 'self'; "
                    "frame-ancestors 'none'");
            }
            if(!response.has_header("Referrer-Policy"))
            {
                response.set_header("Referrer-Policy", "same-origin");
            }
            response.set_header("X-Content-Type-Options", "nosniff");
        });
    const std::filesystem::path published_cards =
        config_.card_storage_root / "published";
    if(!server.set_mount_point(
           getMountPath("static"), config_.static_root.string()))
    {
        spdlog::error("Failed to mount application static files");
    }
    if(!server.set_mount_point(
           getMountPath("card-asset"), published_cards.string()))
    {
        spdlog::error("Failed to mount published card assets");
    }

    server.Get(
        getPath("welcome"),
        std::bind_front(&App::handleWelcome, this));
    server.Get(
        getPath("authentication"),
        std::bind_front(&App::handleAuthentication, this));
    server.Post(
        getPath("authentication-email"),
        std::bind_front(&App::handleAuthenticationEmail, this));
    server.Get(
        getPath("authentication-sent"),
        std::bind_front(&App::handleAuthenticationSent, this));
    server.Get(
        getPath("authentication-confirm", {"token"}),
        std::bind_front(&App::handleAuthenticationConfirm, this));
    server.Post(
        getPath("authentication-confirm", {"token"}),
        std::bind_front(&App::handleAuthenticationConfirmPost, this));
    server.Post(
        getPath("logout"),
        std::bind_front(&App::handleLogout, this));
    server.Get(
        getPath("onboarding"),
        std::bind_front(&App::handleOnboarding, this));
    server.Post(
        getPath("onboarding"),
        std::bind_front(&App::handleOnboardingPost, this));
    server.Get(
        getPath("account"),
        std::bind_front(&App::handleAccount, this));
    server.Get(
        getPath("account-username"),
        std::bind_front(&App::handleAccountUsername, this));
    server.Post(
        getPath("account-username"),
        std::bind_front(&App::handleAccountUsernamePost, this));
    server.Get(
        getPath("collection"),
        std::bind_front(&App::handleCollection, this));
    server.Post(
        getPath("collection-pull"),
        std::bind_front(&App::handleCollectionPull, this));
    server.Get(
        getPath("admin-users"),
        std::bind_front(&App::handleAdminUsers, this));
    server.Get(
        getPath("admin-games"),
        std::bind_front(&App::handleAdminGames, this));
    server.Get(
        getPath("game-new"),
        std::bind_front(&App::handleGameNew, this));
    server.Post(
        getPath("games"),
        std::bind_front(&App::handleGameCreate, this));
    server.Get(
        getPath("game-edit", {"short"}),
        std::bind_front(&App::handleGameEdit, this));
    server.Post(
        getPath("game-update", {"short"}),
        std::bind_front(&App::handleGameUpdate, this));
    server.Get(
        getPath("game-delete", {"short"}),
        std::bind_front(&App::handleGameDeleteConfirm, this));
    server.Post(
        getPath("game-delete", {"short"}),
        std::bind_front(&App::handleGameDelete, this));
    server.Get(
        getPath("game-field-new", {"short"}),
        std::bind_front(&App::handleGameFieldNew, this));
    server.Post(
        getPath("game-fields", {"short"}),
        std::bind_front(&App::handleGameFieldCreate, this));
    server.Get(
        getPath("game-field-edit", {"short", "id"}),
        std::bind_front(&App::handleGameFieldEdit, this));
    server.Post(
        getPath("game-field-update", {"short", "id"}),
        std::bind_front(&App::handleGameFieldUpdate, this));
    server.Get(
        getPath("game-field-delete", {"short", "id"}),
        std::bind_front(&App::handleGameFieldDeleteConfirm, this));
    server.Post(
        getPath("game-field-delete", {"short", "id"}),
        std::bind_front(&App::handleGameFieldDelete, this));
    server.Post(
        getPath("game-field-order", {"short"}),
        std::bind_front(&App::handleGameFieldOrder, this));
    server.Post(
        getPath("admin-promote", {"id"}),
        std::bind_front(&App::handleAdminPromote, this));
    server.Get(
        getPath("card-index"),
        std::bind_front(&App::handleCardIndex, this));
    server.Get(
        getPath("creator-cards"),
        std::bind_front(&App::handleCardIndex, this));
    server.Get(
        getPath("card-new"),
        std::bind_front(&App::handleCardNew, this));
    server.Get(
        getPath("card-edit", {"id"}),
        std::bind_front(&App::handleCardEdit, this));
    server.Get(
        getPath("card", {"id"}),
        std::bind_front(&App::handleCardView, this));
    server.Post(
        getPath("cards"),
        std::bind_front(&App::handleCardCreate, this));
    server.Post(
        getPath("card-edit", {"id"}),
        std::bind_front(&App::handleCardUpdate, this));
    server.Get(
        getPath("card-delete", {"id"}),
        std::bind_front(&App::handleCardDeleteConfirm, this));
    server.Post(
        getPath("card-delete", {"id"}),
        std::bind_front(&App::handleCardDelete, this));
    server.Get(
        getPath("series-index"),
        std::bind_front(&App::handleSeriesIndex, this));
    server.Get(
        getPath("series-new"),
        std::bind_front(&App::handleSeriesNew, this));
    server.Post(
        getPath("series"),
        std::bind_front(&App::handleSeriesCreate, this));
    server.Get(
        getPath("series-edit", {"id"}),
        std::bind_front(&App::handleSeriesEdit, this));
    server.Post(
        getPath("series-item", {"id"}),
        std::bind_front(&App::handleSeriesUpdate, this));
    server.Get(
        getPath("series-delete", {"id"}),
        std::bind_front(&App::handleSeriesDeleteConfirm, this));
    server.Post(
        getPath("series-delete", {"id"}),
        std::bind_front(&App::handleSeriesDelete, this));
}

std::string App::getPath(
    const std::string& name,
    const std::vector<std::string>& argument_names) const
{
    const RouteDefinition& route = routeDefinition(name);
    if(route.kind != RouteKind::DYNAMIC)
    {
        throw std::invalid_argument("Static mount has no handler path");
    }
    return url_builder_.requestPath(resolveRoute(
        route, argument_names, RouteSegmentKind::PLACEHOLDER));
}

std::string App::getMountPath(const std::string& name) const
{
    const RouteDefinition& route = routeDefinition(name);
    if(route.kind != RouteKind::STATIC_MOUNT)
    {
        throw std::invalid_argument("Dynamic route has no mount path");
    }
    return url_builder_.requestPath(route.segments);
}