BareGit
#include "card_service.h"
#include "data_sqlite.h"
#include "multipart_reader.h"
#include "series_service.h"
#include "startup.h"

#include <chrono>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <memory>
#include <optional>
#include <string>
#include <system_error>

#include <Magick++.h>
#include <gtest/gtest.h>

namespace
{

class TemporaryCardRoot
{
public:
    /// Allocate a unique temporary root for a card-service test.
    TemporaryCardRoot()
            : path_(
                std::filesystem::path(testing::TempDir()) /
                ("card_service_" + std::to_string(
                    std::chrono::steady_clock::now()
                        .time_since_epoch().count())))
    {
        std::filesystem::create_directories(path_ / ".staging/upload");
    }

    /// Remove all temporary database and asset files.
    ~TemporaryCardRoot()
    {
        std::error_code error;
        std::filesystem::remove_all(path_, error);
    }

    /// Return the temporary root.
    const std::filesystem::path& path() const
    {
        return path_;
    }

private:
    std::filesystem::path path_;
};

void initializeImageMagick()
{
    static const bool initialized = []
    {
        Magick::InitializeMagick(nullptr);
        Magick::ResourceLimits::listLength(2);
        Magick::ResourceLimits::width(2048);
        Magick::ResourceLimits::height(2048);
        return true;
    }();
    ASSERT_TRUE(initialized);
}

void writePng(const std::filesystem::path& path)
{
    Magick::Image image(Magick::Geometry(350, 490), Magick::Color("navy"));
    image.write("PNG:" + path.string());
}

void writeJpeg(const std::filesystem::path& path)
{
    Magick::Image image(Magick::Geometry(350, 490), Magick::Color("navy"));
    image.write("JPEG:" + path.string());
}

std::string readFile(const std::filesystem::path& path)
{
    std::ifstream input(path, std::ios::binary);
    return std::string(
        std::istreambuf_iterator<char>(input),
        std::istreambuf_iterator<char>());
}

mw::E<std::unique_ptr<DataSourceInterface>> prepareTestDataSource(
    const std::filesystem::path& database)
{
    auto data_source = prepareDataSource(database);
    if(!data_source)
    {
        return std::unexpected(std::move(data_source.error()));
    }
    auto transaction = (*data_source)->beginTransaction();
    if(!transaction)
    {
        return std::unexpected(std::move(transaction.error()));
    }
    auto updated = (*transaction)->updateUsername(
        1, "Administrator", "administrator");
    if(!updated)
    {
        return std::unexpected(std::move(updated.error()));
    }
    auto committed = (*transaction)->commit();
    if(!committed)
    {
        return std::unexpected(std::move(committed.error()));
    }
    return std::move(*data_source);
}

} // namespace

/// Verify card creation publishes normalized assets with its committed row.
TEST(CardServiceTest, CreatesLooseCard)
{
    initializeImageMagick();
    TemporaryCardRoot temporary;
    const std::filesystem::path database = temporary.path() / "cards.sqlite3";
    auto data_source = prepareTestDataSource(database);
    ASSERT_TRUE(data_source);

    const std::filesystem::path staging =
        temporary.path() / ".staging/upload";
    const std::filesystem::path front = staging / "upload_front";
    writePng(front);

    NonSecretRandom random(1);
    CardService service(
        **data_source,
        random,
        ImageProcessor(75, 256),
        AssetStore(temporary.path()));
    auto public_id = service.createLooseCard(1, {
        "Moon card",
        std::optional<std::string>("Short"),
        std::nullopt,
        4,
        staging,
        front,
        std::nullopt,
        std::nullopt,
    });

    ASSERT_TRUE(public_id) << public_id.error().msg();
    auto cards = (*data_source)->getCards(
        GameContentScope::INCLUDE_INTERNAL);
    ASSERT_TRUE(cards);
    ASSERT_EQ(cards->size(), 1);
    EXPECT_EQ(cards->front().name, "Moon card");
    EXPECT_EQ(cards->front().front_extension, "avif");
    EXPECT_EQ(cards->front().thumbnail_extension, "avif");
    auto stored_card = (*data_source)->getCard(
        cards->front().identity, GameContentScope::INCLUDE_INTERNAL);
    ASSERT_TRUE(stored_card);
    ASSERT_TRUE(*stored_card);
    EXPECT_EQ((**stored_card).id, cards->front().id);
    auto memberships = (*data_source)->getCardSeries(cards->front().id);
    ASSERT_TRUE(memberships);
    EXPECT_TRUE(memberships->empty());
    const std::filesystem::path published =
        temporary.path() / "published" / *public_id;
    EXPECT_TRUE(
        std::filesystem::is_regular_file(published / "front-art.avif"));
    EXPECT_TRUE(std::filesystem::is_regular_file(published / "thumb.avif"));
    EXPECT_FALSE(std::filesystem::exists(staging));
}

/// Verify invalid image bytes leave both persistence domains unchanged.
TEST(CardServiceTest, RejectsInvalidImage)
{
    initializeImageMagick();
    TemporaryCardRoot temporary;
    const std::filesystem::path database = temporary.path() / "cards.sqlite3";
    auto data_source = prepareTestDataSource(database);
    ASSERT_TRUE(data_source);

    const std::filesystem::path staging =
        temporary.path() / ".staging/upload";
    const std::filesystem::path front = staging / "upload_front";
    {
        std::ofstream output(front, std::ios::binary);
        output << "not an image";
    }
    NonSecretRandom random(1);
    CardService service(
        **data_source,
        random,
        ImageProcessor(75, 256),
        AssetStore(temporary.path()));
    auto public_id = service.createLooseCard(1, {
        "Bad card",
        std::nullopt,
        std::nullopt,
        0,
        staging,
        front,
        std::nullopt,
        std::nullopt,
    });

    EXPECT_FALSE(public_id);
    auto cards = (*data_source)->getCards(
        GameContentScope::INCLUDE_INTERNAL);
    ASSERT_TRUE(cards);
    EXPECT_TRUE(cards->empty());
    EXPECT_FALSE(std::filesystem::exists(temporary.path() / "published"));
}

/// Verify an opaque JPEG is accepted as a foil-control texture.
TEST(CardServiceTest, CreatesOpaqueJpegFoilCard)
{
    initializeImageMagick();
    TemporaryCardRoot temporary;
    const std::filesystem::path database = temporary.path() / "cards.sqlite3";
    auto data_source = prepareTestDataSource(database);
    ASSERT_TRUE(data_source);

    const std::filesystem::path staging =
        temporary.path() / ".staging/upload";
    const std::filesystem::path front = staging / "upload_front";
    const std::filesystem::path foil = staging / "upload_foil";
    const std::filesystem::path thumbnail = staging / "upload_thumbnail";
    writeJpeg(front);
    writeJpeg(foil);
    writePng(thumbnail);

    NonSecretRandom random(1);
    CardService service(
        **data_source,
        random,
        ImageProcessor(75, 256),
        AssetStore(temporary.path()));
    auto public_id = service.createLooseCard(1, {
        "Opaque foil",
        std::nullopt,
        std::nullopt,
        0,
        staging,
        front,
        foil,
        thumbnail,
    });

    ASSERT_TRUE(public_id) << public_id.error().msg();
    auto cards = (*data_source)->getCards(
        GameContentScope::INCLUDE_INTERNAL);
    ASSERT_TRUE(cards);
    ASSERT_EQ(cards->size(), 1);
    EXPECT_EQ(cards->front().foil_extension, "jpg");
    const std::filesystem::path published =
        temporary.path() / "published" / *public_id;
    EXPECT_TRUE(std::filesystem::is_regular_file(published / "foil.jpg"));
}

/// Verify dynamic-game creation allocates a sequence and memberships.
TEST(CardServiceTest, CreatesDynamicGameCard)
{
    initializeImageMagick();
    TemporaryCardRoot temporary;
    const std::filesystem::path database = temporary.path() / "cards.sqlite3";
    auto data_source = prepareTestDataSource(database);
    ASSERT_TRUE(data_source);

    auto series_transaction = (*data_source)->beginTransaction();
    ASSERT_TRUE(series_transaction);
    ASSERT_TRUE((*series_transaction)->insertGame(
        {"test", "Test Game", "", GameVisibility::PUBLIC, 1}));
    auto hp_id = (*series_transaction)->insertGameField(
        {0, "test", "hp", "HP", GameFieldType::INTEGER, 0, {}}, {});
    auto attack_id = (*series_transaction)->insertGameField(
        {0, "test", "attack", "Attack", GameFieldType::INTEGER, 1, {}},
        {});
    ASSERT_TRUE(hp_id);
    ASSERT_TRUE(attack_id);
    auto series_id = (*series_transaction)->insertSeries(
        {0, "test", "Core", "Core cards"});
    ASSERT_TRUE(series_id);
    ASSERT_TRUE((*series_transaction)->commit());
    const std::filesystem::path staging =
        temporary.path() / ".staging/upload";
    const std::filesystem::path front = staging / "upload_front";
    writePng(front);
    NonSecretRandom random(1);
    CardService service(
        **data_source,
        random,
        ImageProcessor(75, 256),
        AssetStore(temporary.path()));
    auto public_id = service.createGameCard(
        1,
        {
            "Game card",
            std::nullopt,
            std::nullopt,
            2,
            staging,
            front,
            std::nullopt,
            std::nullopt,
        },
        "test",
        1,
        {{"hp", "50"}, {"attack", "10"}},
        {*series_id});

    ASSERT_TRUE(public_id) << public_id.error().msg();
    EXPECT_EQ(*public_id, "test-1");
    auto stored = (*data_source)->getCard(
        {"test", 1}, GameContentScope::INCLUDE_INTERNAL);
    ASSERT_TRUE(stored);
    ASSERT_TRUE(*stored);
    auto memberships = (*data_source)->getCardSeries((**stored).id);
    ASSERT_TRUE(memberships);
    EXPECT_EQ(*memberships, std::vector<std::int64_t>{*series_id});
    auto values = (*data_source)->getCardFieldValues(
        (**stored).id, GameContentScope::INCLUDE_INTERNAL);
    ASSERT_TRUE(values);
    ASSERT_TRUE(*values);
    EXPECT_EQ(std::get<std::int64_t>((**values).values.front().value), 50);
    const std::filesystem::path edit_staging =
        temporary.path() / ".staging/edit";
    std::filesystem::create_directory(edit_staging);
    auto updated = service.updateGameCard(
        1,
        {
            **stored,
            1,
            "Updated game card",
            std::nullopt,
            std::nullopt,
            3,
            edit_staging,
            FrontAssetAction::KEEP,
            FoilAssetAction::KEEP,
            std::nullopt,
            std::nullopt,
            std::nullopt,
        },
        1,
        {{"hp", "80"}, {"attack", "40"}},
        {});
    ASSERT_TRUE(updated) << updated.error().msg();
    stored = (*data_source)->getCard(
        {"test", 1}, GameContentScope::INCLUDE_INTERNAL);
    ASSERT_TRUE(stored);
    ASSERT_TRUE(*stored);
    EXPECT_EQ((**stored).name, "Updated game card");
    values = (*data_source)->getCardFieldValues(
        (**stored).id, GameContentScope::INCLUDE_INTERNAL);
    ASSERT_TRUE(values);
    ASSERT_TRUE(*values);
    EXPECT_EQ(std::get<std::int64_t>((**values).values.front().value), 80);
    memberships = (*data_source)->getCardSeries((**stored).id);
    ASSERT_TRUE(memberships);
    EXPECT_TRUE(memberships->empty());
}

/// Verify generic metadata failures roll back every card side effect.
TEST(CardServiceTest, RollsBackDynamicMetadataFailures)
{
    initializeImageMagick();
    TemporaryCardRoot temporary;
    const std::filesystem::path database = temporary.path() / "cards.sqlite3";
    auto data_source = prepareTestDataSource(database);
    ASSERT_TRUE(data_source);

    auto setup = (*data_source)->beginTransaction();
    ASSERT_TRUE(setup);
    ASSERT_TRUE((*setup)->insertGame(
        {"test", "Test Game", "", GameVisibility::PUBLIC, 1}));
    auto hp_id = (*setup)->insertGameField(
        {0, "test", "hp", "HP", GameFieldType::INTEGER, 0, {}}, {});
    ASSERT_TRUE(hp_id);
    auto series_id = (*setup)->insertSeries(
        {0, "test", "Core", "Core cards"});
    ASSERT_TRUE(series_id);
    ASSERT_TRUE((*setup)->commit());

    auto injection = mw::SQLite::connectFile(database.string());
    ASSERT_TRUE(injection);
    ASSERT_TRUE((*injection)->execute(
        "CREATE TRIGGER fail_card_values BEFORE INSERT "
        "ON card_field_values BEGIN "
        "SELECT RAISE(ABORT, 'forced metadata failure'); END;"));

    const std::filesystem::path staging =
        temporary.path() / ".staging/upload";
    const std::filesystem::path front = staging / "upload_front";
    writePng(front);
    NonSecretRandom random(1);
    CardService service(
        **data_source,
        random,
        ImageProcessor(75, 256),
        AssetStore(temporary.path()));
    auto failed_create = service.createGameCard(
        1,
        {
            "Failed card",
            std::nullopt,
            std::nullopt,
            2,
            staging,
            front,
            std::nullopt,
            std::nullopt,
        },
        "test",
        1,
        {{"hp", "50"}},
        {*series_id});
    ASSERT_FALSE(failed_create);
    auto cards = (*data_source)->getCards(
        GameContentScope::INCLUDE_INTERNAL);
    ASSERT_TRUE(cards);
    EXPECT_TRUE(cards->empty());
    EXPECT_FALSE(std::filesystem::exists(
        temporary.path() / "published/test-1"));
    EXPECT_TRUE(std::filesystem::is_regular_file(staging / "front-art.avif"));
    ASSERT_TRUE((*injection)->execute("DROP TRIGGER fail_card_values;"));

    writePng(front);
    auto public_id = service.createGameCard(
        1,
        {
            "Committed card",
            std::nullopt,
            std::nullopt,
            2,
            staging,
            front,
            std::nullopt,
            std::nullopt,
        },
        "test",
        1,
        {{"hp", "50"}},
        {*series_id});
    ASSERT_TRUE(public_id) << public_id.error().msg();
    EXPECT_EQ(*public_id, "test-1");
    auto stored = (*data_source)->getCard(
        {"test", 1}, GameContentScope::INCLUDE_INTERNAL);
    ASSERT_TRUE(stored);
    ASSERT_TRUE(*stored);
    const std::filesystem::path published =
        temporary.path() / "published/test-1";
    const std::string original_art = readFile(published / "front-art.avif");

    ASSERT_TRUE((*injection)->execute(
        "CREATE TRIGGER fail_card_values BEFORE INSERT "
        "ON card_field_values BEGIN "
        "SELECT RAISE(ABORT, 'forced metadata failure'); END;"));
    const std::filesystem::path edit_staging =
        temporary.path() / ".staging/edit";
    std::filesystem::create_directory(edit_staging);
    const std::filesystem::path replacement =
        edit_staging / "upload_front";
    writeJpeg(replacement);
    auto failed_update = service.updateGameCard(
        1,
        {
            **stored,
            1,
            "Failed update",
            std::nullopt,
            std::nullopt,
            3,
            edit_staging,
            FrontAssetAction::REPLACE,
            FoilAssetAction::KEEP,
            replacement,
            std::nullopt,
            std::nullopt,
        },
        1,
        {{"hp", "80"}},
        {});
    ASSERT_FALSE(failed_update);
    stored = (*data_source)->getCard(
        {"test", 1}, GameContentScope::INCLUDE_INTERNAL);
    ASSERT_TRUE(stored);
    ASSERT_TRUE(*stored);
    EXPECT_EQ((**stored).name, "Committed card");
    EXPECT_EQ((**stored).revision, 1);
    auto values = (*data_source)->getCardFieldValues(
        (**stored).id, GameContentScope::INCLUDE_INTERNAL);
    ASSERT_TRUE(values);
    ASSERT_TRUE(*values);
    ASSERT_EQ((**values).values.size(), 1);
    EXPECT_EQ(std::get<std::int64_t>((**values).values.front().value), 50);
    auto memberships = (*data_source)->getCardSeries((**stored).id);
    ASSERT_TRUE(memberships);
    EXPECT_EQ(*memberships, std::vector<std::int64_t>{*series_id});
    EXPECT_EQ(readFile(published / "front-art.avif"), original_art);
    EXPECT_FALSE(std::filesystem::exists(published / "front-art.jpg"));
    EXPECT_TRUE(std::filesystem::is_regular_file(
        edit_staging / "front-art.jpg"));
}

/// Verify metadata and artwork edits increment revisions and replace assets.
TEST(CardServiceTest, UpdatesLooseCard)
{
    initializeImageMagick();
    TemporaryCardRoot temporary;
    const std::filesystem::path database = temporary.path() / "cards.sqlite3";
    auto data_source = prepareTestDataSource(database);
    ASSERT_TRUE(data_source);

    const std::filesystem::path create_staging =
        temporary.path() / ".staging/upload";
    const std::filesystem::path original_front =
        create_staging / "upload_front";
    writePng(original_front);
    NonSecretRandom random(1);
    CardService service(
        **data_source,
        random,
        ImageProcessor(75, 256),
        AssetStore(temporary.path()));
    auto public_id = service.createLooseCard(1, {
        "Original",
        std::nullopt,
        std::nullopt,
        0,
        create_staging,
        original_front,
        std::nullopt,
        std::nullopt,
    });
    ASSERT_TRUE(public_id);
    auto cards = (*data_source)->getCards(
        GameContentScope::INCLUDE_INTERNAL);
    ASSERT_TRUE(cards);
    ASSERT_EQ(cards->size(), 1);
    const Card original_card = cards->front();

    const std::filesystem::path metadata_staging =
        temporary.path() / ".staging/metadata";
    std::filesystem::create_directory(metadata_staging);
    auto metadata_update = service.updateLooseCard(1, {
        original_card,
        1,
        "Edited metadata",
        std::optional<std::string>("Summary"),
        std::nullopt,
        3,
        metadata_staging,
        FrontAssetAction::KEEP,
        FoilAssetAction::KEEP,
        std::nullopt,
        std::nullopt,
        std::nullopt,
    });
    ASSERT_TRUE(metadata_update) << metadata_update.error().msg();
    EXPECT_EQ(*metadata_update, *public_id);
    cards = (*data_source)->getCards(
        GameContentScope::INCLUDE_INTERNAL);
    ASSERT_TRUE(cards);
    ASSERT_EQ(cards->size(), 1);
    EXPECT_EQ(cards->front().name, "Edited metadata");
    EXPECT_EQ(cards->front().revision, 2);
    const std::filesystem::path published =
        temporary.path() / "published" / *public_id;
    EXPECT_TRUE(
        std::filesystem::is_regular_file(published / "front-art.avif"));

    const std::filesystem::path stale_staging =
        temporary.path() / ".staging/stale";
    std::filesystem::create_directory(stale_staging);
    auto stale_update = service.updateLooseCard(1, {
        original_card,
        1,
        "Stale edit",
        std::nullopt,
        std::nullopt,
        0,
        stale_staging,
        FrontAssetAction::KEEP,
        FoilAssetAction::KEEP,
        std::nullopt,
        std::nullopt,
        std::nullopt,
    });
    ASSERT_FALSE(stale_update);
    const mw::HTTPError* stale_error =
        stale_update.error().as<mw::HTTPError>();
    ASSERT_NE(stale_error, nullptr);
    EXPECT_EQ(stale_error->code, 409);

    const std::filesystem::path artwork_staging =
        temporary.path() / ".staging/artwork";
    std::filesystem::create_directory(artwork_staging);
    const std::filesystem::path replacement_front =
        artwork_staging / "upload_front";
    writeJpeg(replacement_front);
    auto artwork_update = service.updateLooseCard(1, {
        cards->front(),
        2,
        "Edited artwork",
        std::nullopt,
        std::nullopt,
        1,
        artwork_staging,
        FrontAssetAction::REPLACE,
        FoilAssetAction::KEEP,
        replacement_front,
        std::nullopt,
        std::nullopt,
    });
    ASSERT_TRUE(artwork_update) << artwork_update.error().msg();
    cards = (*data_source)->getCards(
        GameContentScope::INCLUDE_INTERNAL);
    ASSERT_TRUE(cards);
    ASSERT_EQ(cards->size(), 1);
    EXPECT_EQ(cards->front().front_extension, "jpg");
    EXPECT_EQ(cards->front().thumbnail_extension, "avif");
    EXPECT_EQ(cards->front().revision, 3);
    EXPECT_TRUE(
        std::filesystem::is_regular_file(published / "front-art.jpg"));
    EXPECT_FALSE(
        std::filesystem::exists(published / "front-art.avif"));
    EXPECT_TRUE(std::filesystem::is_regular_file(published / "thumb.avif"));

    auto deleted = service.deleteCard(1, cards->front());
    ASSERT_TRUE(deleted) << deleted.error().msg();
    cards = (*data_source)->getCards(
        GameContentScope::INCLUDE_INTERNAL);
    ASSERT_TRUE(cards);
    EXPECT_TRUE(cards->empty());
    EXPECT_FALSE(std::filesystem::exists(published));
}

/// Verify startup reconciliation restores edits/deletes and removes orphans.
TEST(AssetStoreTest, ReconcilesInterruptedTransitions)
{
    initializeImageMagick();
    TemporaryCardRoot temporary;
    const std::filesystem::path database = temporary.path() / "cards.sqlite3";
    auto data_source = prepareTestDataSource(database);
    ASSERT_TRUE(data_source);
    const std::filesystem::path staging =
        temporary.path() / ".staging/upload";
    const std::filesystem::path front = staging / "upload_front";
    writePng(front);
    NonSecretRandom random(1);
    AssetStore assets(temporary.path());
    CardService service(
        **data_source,
        random,
        ImageProcessor(75, 256),
        assets);
    auto public_id = service.createLooseCard(1, {
        "Recovery card",
        std::nullopt,
        std::nullopt,
        0,
        staging,
        front,
        std::nullopt,
        std::nullopt,
    });
    ASSERT_TRUE(public_id);
    const std::filesystem::path published =
        temporary.path() / "published" / *public_id;

    const std::filesystem::path replacement =
        temporary.path() / ".staging/replacement";
    std::filesystem::create_directory(replacement);
    writeJpeg(replacement / "front-art.jpg");
    writeJpeg(replacement / "thumb.jpg");
    auto replaced = assets.replace(replacement, *public_id, 1);
    ASSERT_TRUE(replaced);
    ASSERT_TRUE(assets.reconcile(**data_source));
    EXPECT_TRUE(
        std::filesystem::is_regular_file(published / "front-art.avif"));
    EXPECT_FALSE(
        std::filesystem::is_regular_file(published / "front-art.jpg"));

    auto trashed = assets.trash(*public_id, 1, "abc123");
    ASSERT_TRUE(trashed);
    EXPECT_FALSE(std::filesystem::exists(published));
    ASSERT_TRUE(assets.reconcile(**data_source));
    EXPECT_TRUE(std::filesystem::is_directory(published));

    const std::filesystem::path orphan =
        temporary.path() / "published/1";
    std::filesystem::create_directory(orphan);
    ASSERT_TRUE(assets.reconcile(**data_source));
    EXPECT_FALSE(std::filesystem::exists(orphan));
}

/// Verify multipart binaries use server paths and empty file controls vanish.
TEST(MultipartReaderTest, StreamsExpectedFields)
{
    initializeImageMagick();
    TemporaryCardRoot temporary;
    const std::filesystem::path source = temporary.path() / "source.png";
    writePng(source);
    const std::string image_bytes = readFile(source);

    const httplib::ContentReader content_reader(
        [](httplib::ContentReceiver)
        {
            return false;
        },
        [&](httplib::FormDataHeader header,
            httplib::ContentReceiver receiver)
        {
            httplib::FormData name;
            name.name = "name";
            if(!header(name) || !receiver("Streamed card", 13))
            {
                return false;
            }
            httplib::FormData front;
            front.name = "front";
            front.filename = "../../browser-name.png";
            if(!header(front) ||
               !receiver(image_bytes.data(), image_bytes.size()))
            {
                return false;
            }
            httplib::FormData empty_foil;
            empty_foil.name = "foil";
            return header(empty_foil);
        });

    MultipartReader reader(temporary.path());
    auto upload = reader.read(content_reader);

    ASSERT_TRUE(upload);
    EXPECT_EQ(upload->fields.at("name"), "Streamed card");
    ASSERT_TRUE(upload->front);
    EXPECT_EQ(upload->front->filename(), "upload_front");
    EXPECT_EQ(readFile(*upload->front), image_bytes);
    EXPECT_FALSE(upload->foil);
}

/// Verify multipart custom fields reject duplicates and excessive text.
TEST(MultipartReaderTest, RejectsDuplicateAndOversizedGameFields)
{
    initializeImageMagick();
    TemporaryCardRoot temporary;
    const httplib::ContentReader duplicate_reader(
        [](httplib::ContentReceiver)
        {
            return false;
        },
        [](httplib::FormDataHeader header,
           httplib::ContentReceiver receiver)
        {
            httplib::FormData field;
            field.name = "game.note";
            return header(field) && receiver("first", 5) &&
                   header(field) && receiver("second", 6);
        });
    MultipartReader duplicate_parser(temporary.path());
    auto duplicate = duplicate_parser.read(duplicate_reader);
    ASSERT_FALSE(duplicate);
    const auto* duplicate_error = duplicate.error().as<mw::HTTPError>();
    ASSERT_NE(duplicate_error, nullptr);
    EXPECT_EQ(duplicate_error->code, 400);

    const std::string chunk(1024 * 1024, 'x');
    const httplib::ContentReader oversized_reader(
        [](httplib::ContentReceiver)
        {
            return false;
        },
        [&chunk](httplib::FormDataHeader header,
                 httplib::ContentReceiver receiver)
        {
            for(int index = 0; index < 9; ++index)
            {
                httplib::FormData field;
                field.name = "game.value" + std::to_string(index);
                if(!header(field) ||
                   !receiver(chunk.data(), chunk.size()))
                {
                    return false;
                }
            }
            return true;
        });
    MultipartReader oversized_parser(temporary.path());
    auto oversized = oversized_parser.read(oversized_reader);
    ASSERT_FALSE(oversized);
    const auto* oversized_error = oversized.error().as<mw::HTTPError>();
    ASSERT_NE(oversized_error, nullptr);
    EXPECT_EQ(oversized_error->code, 413);
}

/// Verify creator authorship and rarity rules survive direct service calls.
TEST(CardServiceTest, EnforcesCreatorOwnershipAndRarity)
{
    initializeImageMagick();
    TemporaryCardRoot temporary;
    const std::filesystem::path database = temporary.path() / "cards.sqlite3";
    auto data_source = prepareTestDataSource(database);
    ASSERT_TRUE(data_source);
    auto transaction = (*data_source)->beginTransaction();
    ASSERT_TRUE(transaction);
    User creator = {
        0,
        "creator@example.com",
        "creator@example.com",
        std::nullopt,
        UserRole::CREATOR,
        1,
        0,
        0};
    auto creator_id = (*transaction)->insertUser(creator);
    ASSERT_TRUE(creator_id);
    ASSERT_TRUE((*transaction)->updateUsername(
        *creator_id, "Creator", "creator"));
    ASSERT_TRUE((*transaction)->commit());

    const std::filesystem::path staging =
        temporary.path() / ".staging/creator";
    const std::filesystem::path front = staging / "upload_front";
    std::filesystem::create_directories(staging);
    writePng(front);
    NonSecretRandom random(5);
    CardService service(
        **data_source,
        random,
        ImageProcessor(75, 256),
        AssetStore(temporary.path()));
    CreateLooseCardInput input = {
        "Creator card",
        std::nullopt,
        std::nullopt,
        9,
        staging,
        front,
        std::nullopt,
        std::nullopt,
        false};
    auto public_id = service.createLooseCard(*creator_id, std::move(input));
    ASSERT_TRUE(public_id) << public_id.error().msg();
    auto cards = (*data_source)->getCards(
        GameContentScope::INCLUDE_INTERNAL);
    ASSERT_TRUE(cards);
    ASSERT_EQ(cards->size(), 1);
    EXPECT_EQ(cards->front().creator_user_id, *creator_id);
    EXPECT_EQ(cards->front().rarity, 0);
    EXPECT_FALSE(service.deleteCard(*creator_id, cards->front()));

    const std::filesystem::path tampered_staging =
        temporary.path() / ".staging/tampered";
    const std::filesystem::path tampered_front =
        tampered_staging / "upload_front";
    std::filesystem::create_directories(tampered_staging);
    writePng(tampered_front);
    EXPECT_FALSE(service.createLooseCard(*creator_id, {
        "Tampered card",
        std::nullopt,
        std::nullopt,
        1,
        tampered_staging,
        tampered_front,
        std::nullopt,
        std::nullopt,
        true,
    }));
    SeriesService series_service(**data_source);
    auto forbidden_series = series_service.create(
        *creator_id, "gh", "Creator series", "Not allowed");
    ASSERT_FALSE(forbidden_series);
    const auto* http_error = forbidden_series.error().as<mw::HTTPError>();
    ASSERT_NE(http_error, nullptr);
    EXPECT_EQ(http_error->code, 403);
}

TEST(CardServiceTest, RejectsCreatorMutationsForInternalGames)
{
    initializeImageMagick();
    TemporaryCardRoot temporary;
    const std::filesystem::path database = temporary.path() / "cards.sqlite3";
    auto data_source = prepareTestDataSource(database);
    ASSERT_TRUE(data_source);
    auto transaction = (*data_source)->beginTransaction();
    ASSERT_TRUE(transaction);
    User creator = {
        0,
        "creator@example.com",
        "creator@example.com",
        std::nullopt,
        UserRole::CREATOR,
        1,
        0,
        0};
    auto creator_id = (*transaction)->insertUser(creator);
    ASSERT_TRUE(creator_id);
    ASSERT_TRUE((*transaction)->updateUsername(
        *creator_id, "Creator", "creator"));
    ASSERT_TRUE((*transaction)->insertGame(
        {"internal", "Internal", "", GameVisibility::INTERNAL, 1}));
    ASSERT_TRUE((*transaction)->insertGame(
        {"public", "Public", "", GameVisibility::PUBLIC, 1}));
    ASSERT_TRUE((*transaction)->commit());

    NonSecretRandom random(7);
    CardService service(
        **data_source,
        random,
        ImageProcessor(75, 256),
        AssetStore(temporary.path()));
    const std::filesystem::path rejected_staging =
        temporary.path() / ".staging/rejected";
    std::filesystem::create_directories(rejected_staging);
    const std::filesystem::path rejected_front =
        rejected_staging / "upload_front";
    writePng(rejected_front);
    auto rejected = service.createGameCard(
        *creator_id,
        {"Rejected", std::nullopt, std::nullopt, 0, rejected_staging,
         rejected_front, std::nullopt, std::nullopt, false},
        "internal",
        1,
        {},
        {});
    ASSERT_FALSE(rejected);
    const auto* create_error = rejected.error().as<mw::HTTPError>();
    ASSERT_NE(create_error, nullptr);
    EXPECT_EQ(create_error->code, 422);
    EXPECT_EQ(create_error->msg, "Unknown game");

    const std::filesystem::path public_staging =
        temporary.path() / ".staging/public";
    std::filesystem::create_directories(public_staging);
    const std::filesystem::path public_front =
        public_staging / "upload_front";
    writePng(public_front);
    auto created = service.createGameCard(
        *creator_id,
        {"Creator card", std::nullopt, std::nullopt, 0, public_staging,
         public_front, std::nullopt, std::nullopt, false},
        "public",
        1,
        {},
        {});
    ASSERT_TRUE(created) << created.error().msg();
    auto stored = (*data_source)->getCard(
        {"public", 1}, GameContentScope::INCLUDE_INTERNAL);
    ASSERT_TRUE(stored);
    ASSERT_TRUE(*stored);

    transaction = (*data_source)->beginTransaction();
    ASSERT_TRUE(transaction);
    auto hidden = (*transaction)->updateGame(
        "public", "Public", "", GameVisibility::INTERNAL, 1);
    ASSERT_TRUE(hidden);
    ASSERT_TRUE(*hidden);
    ASSERT_TRUE((*transaction)->commit());

    const std::filesystem::path edit_staging =
        temporary.path() / ".staging/edit";
    std::filesystem::create_directories(edit_staging);
    auto updated = service.updateGameCard(
        *creator_id,
        {**stored, 1, "Hidden edit", std::nullopt, std::nullopt, 0,
         edit_staging, FrontAssetAction::KEEP, FoilAssetAction::KEEP,
         std::nullopt, std::nullopt, std::nullopt, false},
        2,
        {},
        {});
    ASSERT_FALSE(updated);
    const auto* update_error = updated.error().as<mw::HTTPError>();
    ASSERT_NE(update_error, nullptr);
    EXPECT_EQ(update_error->code, 404);
    stored = (*data_source)->getCard(
        {"public", 1}, GameContentScope::INCLUDE_INTERNAL);
    ASSERT_TRUE(stored);
    ASSERT_TRUE(*stored);
    EXPECT_EQ((**stored).name, "Creator card");
}