using System.Text;

namespace RpgRoller.Tests;

public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
{
    [Fact]
    public async Task CampaignCharacterAndSkillFlow_EnforcesRulesetValidation()
    {
        using var factory = CreateFactory(6, 6, 6);
        using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });

        await RegisterAsync(gmClient, "gm", "Password123", "Game Master");
        await LoginAsync(gmClient, "gm", "Password123");

        var campaign =
            await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
                new("Alpha Campaign", "dnd5e"));

        var gmCharacter =
            await PostAsync<CreateCharacterRequest, CharacterSummary>(gmClient, "/api/characters",
                new("Arin", campaign.Id));
        Assert.Equal("Game Master", gmCharacter.OwnerDisplayName);

        var activateResponse = await gmClient.PostAsync($"/api/characters/{gmCharacter.Id}/activate", null);
        Assert.Equal(HttpStatusCode.OK, activateResponse.StatusCode);

        var createdSkill = await PostAsync<CreateSkillRequest, SkillSummary>(gmClient,
            $"/api/characters/{gmCharacter.Id}/skills", new("Arcana", "2d12+2", 0, false));
        Assert.Equal("2d12+2", createdSkill.DiceRollDefinition);
        Assert.Equal(0, createdSkill.WildDice);
        Assert.False(createdSkill.AllowFumble);

        var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(gmClient, $"/api/skills/{createdSkill.Id}",
            new("Arcana Mastery", "2d12+3", 0, false));
        Assert.Equal("Arcana Mastery", updatedSkill.Name);
        Assert.Equal("2d12+3", updatedSkill.DiceRollDefinition);
        Assert.Equal(0, updatedSkill.WildDice);
        Assert.False(updatedSkill.AllowFumble);

        var invalidSkill = await gmClient.PostAsJsonAsync($"/api/characters/{gmCharacter.Id}/skills",
            new CreateSkillRequest("Broken", "5D+4", 0, false));
        Assert.Equal(HttpStatusCode.BadRequest, invalidSkill.StatusCode);

        var details = await GetAsync<CampaignRoster>(gmClient, $"/api/campaigns/{campaign.Id}");
        Assert.Equal(campaign.Id, details.Id);
        Assert.Single(details.Characters);
        Assert.Equal("Game Master", details.Characters[0].OwnerDisplayName);

        var summaries = await GetAsync<IReadOnlyList<CampaignSummary>>(gmClient, "/api/campaigns");
        Assert.Single(summaries);
        Assert.Equal(1, summaries[0].CharacterCount);

        var sheet = await GetAsync<CharacterSheet>(gmClient, $"/api/characters/{gmCharacter.Id}/sheet");
        Assert.Single(sheet.Skills);
        Assert.Equal(updatedSkill.Id, sheet.Skills[0].Id);

        var currentCampaignCharacters = await GetAsync<IReadOnlyList<CharacterSummary>>(gmClient, "/api/characters");
        Assert.Single(currentCampaignCharacters);
        Assert.Equal(gmCharacter.Id, currentCampaignCharacters[0].Id);
        Assert.Equal("Game Master", currentCampaignCharacters[0].OwnerDisplayName);

        var otherCampaign =
            await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
                new("Beta Campaign", "d6"));

        var updatedCharacter = await PutAsync<UpdateCharacterRequest, CharacterSummary>(gmClient,
            $"/api/characters/{gmCharacter.Id}", new("Arin Updated", otherCampaign.Id));

        Assert.Equal("Arin Updated", updatedCharacter.Name);
        Assert.Equal(otherCampaign.Id, updatedCharacter.CampaignId);
    }

    [Fact]
    public async Task GmCanActivateAnotherPlayersCharacter_AndMeReflectsCampaignContext()
    {
        using var factory = CreateFactory(3, 3, 3);
        using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
        using var playerClient = factory.CreateClient(new() { AllowAutoRedirect = false });
        using var outsiderClient = factory.CreateClient(new() { AllowAutoRedirect = false });

        await RegisterAsync(gmClient, "gm-activate", "Password123", "GM");
        await RegisterAsync(playerClient, "player-activate", "Password123", "Player");
        await RegisterAsync(outsiderClient, "outsider-activate", "Password123", "Outsider");

        await LoginAsync(gmClient, "gm-activate", "Password123");
        await LoginAsync(playerClient, "player-activate", "Password123");
        await LoginAsync(outsiderClient, "outsider-activate", "Password123");

        var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
            new("Activation Campaign", "d6"));
        var playerCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters",
            new("Scout", campaign.Id));

        var gmActivate = await gmClient.PostAsync($"/api/characters/{playerCharacter.Id}/activate", null);
        Assert.Equal(HttpStatusCode.OK, gmActivate.StatusCode);

        var gmMe = await GetAsync<MeResponse>(gmClient, "/api/me");
        Assert.Equal(playerCharacter.Id, gmMe.ActiveCharacterId);
        Assert.Equal(campaign.Id, gmMe.CurrentCampaignId);

        var outsiderActivate = await outsiderClient.PostAsync($"/api/characters/{playerCharacter.Id}/activate", null);
        Assert.Equal(HttpStatusCode.BadRequest, outsiderActivate.StatusCode);
    }

    [Fact]
    public async Task CampaignCreation_AcceptsRolemasterRuleset()
    {
        using var factory = CreateFactory(2, 2, 2);
        using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });

        await RegisterAsync(gmClient, "gm-rm-api", "Password123", "Game Master");
        await LoginAsync(gmClient, "gm-rm-api", "Password123");

        var campaign =
            await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
                new("Shadow World", "rolemaster"));

        Assert.Equal("rolemaster", campaign.RulesetId);
    }

    [Fact]
    public async Task RolemasterSkillDefinitions_RoundTripRetryAndFumbleOptionsThroughApi()
    {
        using var factory = CreateFactory(88, 42, 17);
        using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });

        await RegisterAsync(gmClient, "gm-rm-skill", "Password123", "Game Master");
        await LoginAsync(gmClient, "gm-rm-skill", "Password123");

        var campaign =
            await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
                new("Shadow World", "rolemaster"));
        var character =
            await PostAsync<CreateCharacterRequest, CharacterSummary>(gmClient, "/api/characters",
                new("Kalen", campaign.Id));

        var missingFumbleRange = await gmClient.PostAsJsonAsync($"/api/characters/{character.Id}/skills",
            new CreateSkillRequest("Bad Open Ended", "d100!+35", 0, false));
        Assert.Equal(HttpStatusCode.BadRequest, missingFumbleRange.StatusCode);

        var group = await PostAsync<CreateSkillGroupRequest, SkillGroupSummary>(gmClient,
            $"/api/characters/{character.Id}/skill-groups", new("Perception", "d100!+15", 0, false, 5));
        Assert.Equal(5, group.FumbleRange);

        var invalidRetry = await gmClient.PostAsJsonAsync($"/api/characters/{character.Id}/skills",
            new CreateSkillRequest("Bad Retry", "d100+35", 0, false, group.Id, null, true));
        Assert.Equal(HttpStatusCode.BadRequest, invalidRetry.StatusCode);

        var skill = await PostAsync<CreateSkillRequest, SkillSummary>(gmClient,
            $"/api/characters/{character.Id}/skills", new("Awareness", "d100!+35", 0, false, group.Id, 3, true));
        Assert.Equal(3, skill.FumbleRange);
        Assert.True(skill.RolemasterAutoRetry);

        var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(gmClient, $"/api/skills/{skill.Id}",
            new("Awareness", "d100!+45", 0, false, group.Id, 4, true));
        Assert.Equal(4, updatedSkill.FumbleRange);
        Assert.True(updatedSkill.RolemasterAutoRetry);

        var sheet = await GetAsync<CharacterSheet>(gmClient, $"/api/characters/{character.Id}/sheet");
        Assert.Equal(5, Assert.Single(sheet.SkillGroups).FumbleRange);
        var sheetSkill = Assert.Single(sheet.Skills);
        Assert.Equal(4, sheetSkill.FumbleRange);
        Assert.True(sheetSkill.RolemasterAutoRetry);
    }

    [Fact]
    public async Task SkillGroupsAndOwnerTransfer_WorkThroughApi()
    {
        using var factory = CreateFactory(6, 4, 5, 3);
        using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
        using var ownerClient = factory.CreateClient(new() { AllowAutoRedirect = false });
        using var receiverClient = factory.CreateClient(new() { AllowAutoRedirect = false });

        await RegisterAsync(gmClient, "gm2", "Password123", "GM");
        await LoginAsync(gmClient, "gm2", "Password123");

        await RegisterAsync(ownerClient, "owner2", "Password123", "Owner");
        await LoginAsync(ownerClient, "owner2", "Password123");

        await RegisterAsync(receiverClient, "receiver2", "Password123", "Receiver");
        await LoginAsync(receiverClient, "receiver2", "Password123");

        var campaign =
            await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
                new("Grouped Campaign", "d6"));
        var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(ownerClient, "/api/characters",
            new("Grouped Hero", campaign.Id));

        var createdGroup = await PostAsync<CreateSkillGroupRequest, SkillGroupSummary>(ownerClient,
            $"/api/characters/{character.Id}/skill-groups", new("Combat", "2D+1", 1, true));
        var renamedGroup = await PutAsync<UpdateSkillGroupRequest, SkillGroupSummary>(gmClient,
            $"/api/skill-groups/{createdGroup.Id}", new("Battle", "3D+2", 2, false));
        Assert.Equal("Battle", renamedGroup.Name);
        Assert.Equal("3D+2", renamedGroup.DiceRollDefinition);
        Assert.Equal(2, renamedGroup.WildDice);
        Assert.False(renamedGroup.AllowFumble);

        var groupedSkill = await PostAsync<CreateSkillRequest, SkillSummary>(ownerClient,
            $"/api/characters/{character.Id}/skills", new("Strike", "2D+1", 1, true, renamedGroup.Id));
        Assert.Equal(renamedGroup.Id, groupedSkill.SkillGroupId);

        var ungroupedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(ownerClient,
            $"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true));
        Assert.Null(ungroupedSkill.SkillGroupId);

        var groupedAgainSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(ownerClient,
            $"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true, renamedGroup.Id));
        Assert.Equal(renamedGroup.Id, groupedAgainSkill.SkillGroupId);

        var deleteSkill = await ownerClient.DeleteAsync($"/api/skills/{groupedAgainSkill.Id}");
        Assert.Equal(HttpStatusCode.OK, deleteSkill.StatusCode);

        var deleteGroup = await ownerClient.DeleteAsync($"/api/skill-groups/{renamedGroup.Id}");
        Assert.Equal(HttpStatusCode.OK, deleteGroup.StatusCode);

        var transferResult = await PutAsync<UpdateCharacterRequest, CharacterSummary>(gmClient,
            $"/api/characters/{character.Id}", new("Grouped Hero", campaign.Id, "receiver2"));
        Assert.Equal("Grouped Hero", transferResult.Name);
        Assert.Equal("Receiver", transferResult.OwnerDisplayName);

        var gmCampaignView = await GetAsync<CampaignRoster>(gmClient, $"/api/campaigns/{campaign.Id}");
        var gmViewedCharacter = Assert.Single(gmCampaignView.Characters, c => c.Id == character.Id);
        Assert.Equal("Receiver", gmViewedCharacter.OwnerDisplayName);

        var ownerActivate = await ownerClient.PostAsync($"/api/characters/{character.Id}/activate", null);
        Assert.Equal(HttpStatusCode.BadRequest, ownerActivate.StatusCode);

        var receiverActivate = await receiverClient.PostAsync($"/api/characters/{character.Id}/activate", null);
        Assert.Equal(HttpStatusCode.OK, receiverActivate.StatusCode);
    }

    [Fact]
    public async Task AdminUserManagementAndCampaignDeletion_WorkThroughApi()
    {
        using var factory = CreateFactory(6, 5, 4);
        using var adminClient = factory.CreateClient(new() { AllowAutoRedirect = false });
        using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
        using var playerClient = factory.CreateClient(new() { AllowAutoRedirect = false });

        var admin = await RegisterAsync(adminClient, "admin3", "Password123", "Admin");
        var gm = await RegisterAsync(gmClient, "gm3", "Password123", "GM");
        var player = await RegisterAsync(playerClient, "player3", "Password123", "Player");

        await LoginAsync(adminClient, "admin3", "Password123");
        await LoginAsync(gmClient, "gm3", "Password123");
        await LoginAsync(playerClient, "player3", "Password123");

        var adminUsers = await GetAsync<IReadOnlyList<AdminUserSummary>>(adminClient, "/api/admin/users");
        var adminEntry = adminUsers.Single(user => user.Id == admin.Id);
        var playerEntry = adminUsers.Single(user => user.Id == player.Id);
        Assert.Contains(adminEntry.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase));
        Assert.Empty(playerEntry.Roles);

        var promotedPlayer = await PutAsync<UpdateUserRolesRequest, AdminUserSummary>(adminClient,
            $"/api/admin/users/{player.Id}/roles", new(["admin"]));
        Assert.Contains(promotedPlayer.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase));

        var campaign =
            await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
                new("Disposable Campaign", "d6"));
        var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters",
            new("Disposable Hero", campaign.Id));
        var skill = await PostAsync<CreateSkillRequest, SkillSummary>(playerClient,
            $"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true));
        _ = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public"));

        var deleteCampaign = await adminClient.DeleteAsync($"/api/campaigns/{campaign.Id}");
        Assert.Equal(HttpStatusCode.OK, deleteCampaign.StatusCode);

        var getDeletedCampaign = await gmClient.GetAsync($"/api/campaigns/{campaign.Id}");
        Assert.Equal(HttpStatusCode.BadRequest, getDeletedCampaign.StatusCode);

        var playerCharacters = await GetAsync<IReadOnlyList<CharacterSummary>>(playerClient, "/api/characters");
        Assert.Single(playerCharacters);
        Assert.Null(playerCharacters[0].CampaignId);

        var deleteUser = await adminClient.DeleteAsync($"/api/admin/users/{player.Id}");
        Assert.Equal(HttpStatusCode.OK, deleteUser.StatusCode);

        var usersAfterDelete = await GetAsync<IReadOnlyList<AdminUserSummary>>(adminClient, "/api/admin/users");
        Assert.DoesNotContain(usersAfterDelete, user => user.Id == player.Id);
        Assert.Contains(usersAfterDelete, user => user.Id == gm.Id);
    }

    [Fact]
    public async Task AdminDatabaseDownload_RequiresAdminAndReturnsSqliteFile()
    {
        using var factory = CreateFactory();
        using var anonymousClient = factory.CreateClient(new() { AllowAutoRedirect = false });
        using var adminClient = factory.CreateClient(new() { AllowAutoRedirect = false });
        using var memberClient = factory.CreateClient(new() { AllowAutoRedirect = false });

        await RegisterAsync(adminClient, "admin-download", "Password123", "Admin Download");
        await LoginAsync(adminClient, "admin-download", "Password123");

        await RegisterAsync(memberClient, "member-download", "Password123", "Member Download");
        await LoginAsync(memberClient, "member-download", "Password123");

        var unauthorized = await anonymousClient.GetAsync("/api/admin/database");
        Assert.Equal(HttpStatusCode.Unauthorized, unauthorized.StatusCode);

        var forbidden = await memberClient.GetAsync("/api/admin/database");
        Assert.Equal(HttpStatusCode.BadRequest, forbidden.StatusCode);

        var response = await adminClient.GetAsync("/api/admin/database");
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Equal("application/octet-stream", response.Content.Headers.ContentType?.MediaType);

        var disposition = response.Content.Headers.ContentDisposition;
        Assert.NotNull(disposition);
        Assert.Equal("attachment", disposition.DispositionType);
        Assert.EndsWith(".db", disposition.FileName?.Trim('"'), StringComparison.OrdinalIgnoreCase);

        var bytes = await response.Content.ReadAsByteArrayAsync();
        Assert.True(bytes.Length >= 16);
        Assert.Equal("SQLite format 3\0", Encoding.ASCII.GetString(bytes, 0, 16));
    }

    [Fact]
    public async Task CampaignOptionsEndpoint_ReturnsCampaignsBeyondVisibleCampaignList()
    {
        using var factory = CreateFactory(6, 5, 4);
        using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
        using var otherGmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
        using var playerClient = factory.CreateClient(new() { AllowAutoRedirect = false });

        await RegisterAsync(gmClient, "gm-options-1", "Password123", "GM One");
        await LoginAsync(gmClient, "gm-options-1", "Password123");

        await RegisterAsync(otherGmClient, "gm-options-2", "Password123", "GM Two");
        await LoginAsync(otherGmClient, "gm-options-2", "Password123");

        await RegisterAsync(playerClient, "player-options", "Password123", "Player");
        await LoginAsync(playerClient, "player-options", "Password123");

        var firstCampaign =
            await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
                new("Alpha Visible", "d6"));
        var secondCampaign =
            await PostAsync<CreateCampaignRequest, CampaignSummary>(otherGmClient, "/api/campaigns",
                new("Beta Available", "d6"));

        var playerVisibleCampaigns = await GetAsync<IReadOnlyList<CampaignSummary>>(playerClient, "/api/campaigns");
        Assert.Empty(playerVisibleCampaigns);

        var playerCampaignOptions =
            await GetAsync<IReadOnlyList<CampaignOption>>(playerClient, "/api/campaigns/options");
        Assert.Equal(2, playerCampaignOptions.Count);
        Assert.Contains(playerCampaignOptions, option => option.Id == firstCampaign.Id);
        Assert.Contains(playerCampaignOptions, option => option.Id == secondCampaign.Id);
    }

    [Fact]
    public async Task CharacterDelete_RequiresOwnerOrAdmin()
    {
        using var factory = CreateFactory(6, 5, 4);
        using var adminClient = factory.CreateClient(new() { AllowAutoRedirect = false });
        using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
        using var ownerClient = factory.CreateClient(new() { AllowAutoRedirect = false });
        using var otherClient = factory.CreateClient(new() { AllowAutoRedirect = false });

        await RegisterAsync(adminClient, "admin-delete", "Password123", "Admin");
        await LoginAsync(adminClient, "admin-delete", "Password123");

        await RegisterAsync(gmClient, "gm-delete", "Password123", "GM");
        await LoginAsync(gmClient, "gm-delete", "Password123");

        await RegisterAsync(ownerClient, "owner-delete", "Password123", "Owner");
        await LoginAsync(ownerClient, "owner-delete", "Password123");

        await RegisterAsync(otherClient, "other-delete", "Password123", "Other");
        await LoginAsync(otherClient, "other-delete", "Password123");

        var campaign =
            await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
                new("Deletion Campaign", "d6"));
        var ownerCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(ownerClient, "/api/characters",
            new("Owner Character", campaign.Id));
        var otherCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(otherClient, "/api/characters",
            new("Other Character", campaign.Id));

        var gmDeleteAttempt = await gmClient.DeleteAsync($"/api/characters/{ownerCharacter.Id}");
        Assert.Equal(HttpStatusCode.BadRequest, gmDeleteAttempt.StatusCode);

        var otherDeleteAttempt = await otherClient.DeleteAsync($"/api/characters/{ownerCharacter.Id}");
        Assert.Equal(HttpStatusCode.BadRequest, otherDeleteAttempt.StatusCode);

        var ownerDelete = await ownerClient.DeleteAsync($"/api/characters/{ownerCharacter.Id}");
        Assert.Equal(HttpStatusCode.OK, ownerDelete.StatusCode);

        var adminDelete = await adminClient.DeleteAsync($"/api/characters/{otherCharacter.Id}");
        Assert.Equal(HttpStatusCode.OK, adminDelete.StatusCode);

        var campaignAfterDeletes = await GetAsync<CampaignRoster>(gmClient, $"/api/campaigns/{campaign.Id}");
        Assert.Empty(campaignAfterDeletes.Characters);
    }

    [Fact]
    public async Task CampaignLog_ReturnsMostRecentHundredEntries()
    {
        using var factory = CreateFactory();
        using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
        using var playerClient = factory.CreateClient(new() { AllowAutoRedirect = false });

        await RegisterAsync(gmClient, "gm-log-cap", "Password123", "GM");
        await LoginAsync(gmClient, "gm-log-cap", "Password123");

        await RegisterAsync(playerClient, "player-log-cap", "Password123", "Player");
        await LoginAsync(playerClient, "player-log-cap", "Password123");

        var campaign =
            await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Log Cap", "d6"));
        var character =
            await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters",
                new("Roller", campaign.Id));
        var skill = await PostAsync<CreateSkillRequest, SkillSummary>(playerClient,
            $"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true));

        var rollIds = new List<Guid>();
        for (var i = 0; i < 105; i++)
        {
            var roll = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll",
                new("public"));
            rollIds.Add(roll.RollId);
        }

        var log = await GetAsync<IReadOnlyList<CampaignLogEntry>>(gmClient, $"/api/campaigns/{campaign.Id}/log");
        Assert.Equal(100, log.Count);
        Assert.Equal(rollIds[5], log[0].RollId);
        Assert.Equal(rollIds[^1], log[^1].RollId);
        Assert.All(log, entry =>
        {
            Assert.False(string.IsNullOrWhiteSpace(entry.CharacterName));
            Assert.False(string.IsNullOrWhiteSpace(entry.SkillName));
            Assert.False(string.IsNullOrWhiteSpace(entry.RollerDisplayName));
        });
    }

    [Fact]
    public async Task CampaignLogPage_ReturnsInitialAndIncrementalResults()
    {
        using var factory = CreateFactory();
        using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
        using var playerClient = factory.CreateClient(new() { AllowAutoRedirect = false });

        await RegisterAsync(gmClient, "gm-log-page", "Password123", "GM");
        await LoginAsync(gmClient, "gm-log-page", "Password123");

        await RegisterAsync(playerClient, "player-log-page", "Password123", "Player");
        await LoginAsync(playerClient, "player-log-page", "Password123");

        var campaign =
            await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Log Page", "d6"));
        var character =
            await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters",
                new("Roller", campaign.Id));
        var skill = await PostAsync<CreateSkillRequest, SkillSummary>(playerClient,
            $"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true));

        var rollIds = new List<Guid>();
        for (var i = 0; i < 5; i++)
        {
            var roll = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll",
                new("public"));
            rollIds.Add(roll.RollId);
        }

        var initialPage = await GetAsync<CampaignLogPage>(gmClient, $"/api/campaigns/{campaign.Id}/log/page?limit=3");
        Assert.Equal(3, initialPage.Entries.Length);
        Assert.Equal(rollIds[2], initialPage.Entries[0].RollId);
        Assert.Equal(rollIds[^1], initialPage.Entries[^1].RollId);
        Assert.Equal(rollIds[^1], initialPage.Cursor);
        Assert.True(initialPage.HasMore);
        Assert.False(initialPage.ResetRequired);
        Assert.All(initialPage.Entries, entry =>
        {
            Assert.False(string.IsNullOrWhiteSpace(entry.SummaryText));
            Assert.False(string.IsNullOrWhiteSpace(entry.VisibilityLabel));
        });

        var latestRoll =
            await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public"));
        var incrementalPage = await GetAsync<CampaignLogPage>(gmClient,
            $"/api/campaigns/{campaign.Id}/log/page?afterRollId={initialPage.Cursor}&limit=3");

        Assert.Single(incrementalPage.Entries);
        Assert.Equal(latestRoll.RollId, incrementalPage.Entries[0].RollId);
        Assert.Equal(latestRoll.RollId, incrementalPage.Cursor);
        Assert.False(incrementalPage.HasMore);
        Assert.False(incrementalPage.ResetRequired);

        var detail = await GetAsync<CampaignRollDetail>(gmClient, $"/api/rolls/{latestRoll.RollId}");
        Assert.Equal(latestRoll.RollId, detail.RollId);
        Assert.Equal(latestRoll.Breakdown, detail.Breakdown);
        Assert.NotEmpty(detail.Dice);
    }
}