using GmRelay.Web.Services;
using GmRelay.Shared.Domain;

namespace GmRelay.Bot.Tests.Web;

public sealed class AuthorizedSessionServiceTests
{
    [Fact]
    public async Task GetUpcomingSessionsForGmAsync_ReturnsSessions_WhenGroupBelongsToGm()
    {
        var gmId = 1001L;
        var groupId = Guid.NewGuid();
        var store = new FakeSessionStore(
            groups:
            [
                new(groupId, 42, "Alpha", gmId)
            ],
            sessions:
            [
                new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
            ]);
        var service = new AuthorizedSessionService(store);

        var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, gmId);

        Assert.NotNull(sessions);
        Assert.Single(sessions);
        Assert.Equal("Session A", sessions[0].Title);
    }

    [Fact]
    public async Task GetUpcomingSessionsForGmAsync_ReturnsSessions_WhenUserIsCoGm()
    {
        var ownerId = 1001L;
        var coGmId = 2002L;
        var groupId = Guid.NewGuid();
        var store = new FakeSessionStore(
            groups:
            [
                new(groupId, 42, "Alpha", ownerId)
            ],
            sessions:
            [
                new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
            ],
            managers:
            [
                new(groupId, coGmId, GroupManagerRole.CoGm)
            ]);
        var service = new AuthorizedSessionService(store);

        var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, coGmId);

        Assert.NotNull(sessions);
        Assert.Single(sessions);
        Assert.Equal("Session A", sessions[0].Title);
    }

    [Fact]
    public async Task GetUpcomingSessionsForGmAsync_ReturnsNull_WhenGroupBelongsToAnotherGm()
    {
        var groupId = Guid.NewGuid();
        var store = new FakeSessionStore(
            groups:
            [
                new(groupId, 42, "Alpha", 2002L)
            ]);
        var service = new AuthorizedSessionService(store);

        var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, 1001L);

        Assert.Null(sessions);
    }

    [Fact]
    public async Task GetSessionForGmAsync_ReturnsSession_WhenSessionBelongsToOwnedGroup()
    {
        var gmId = 1001L;
        var groupId = Guid.NewGuid();
        var sessionId = Guid.NewGuid();
        var store = new FakeSessionStore(
            groups:
            [
                new(groupId, 42, "Alpha", gmId)
            ],
            sessions:
            [
                new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
            ]);
        var service = new AuthorizedSessionService(store);

        var session = await service.GetSessionForGmAsync(sessionId, gmId);

        Assert.NotNull(session);
        Assert.Equal(sessionId, session.Id);
    }

    [Fact]
    public async Task GetSessionForGmAsync_ReturnsNull_WhenSessionBelongsToAnotherGm()
    {
        var groupId = Guid.NewGuid();
        var sessionId = Guid.NewGuid();
        var store = new FakeSessionStore(
            groups:
            [
                new(groupId, 42, "Alpha", 2002L)
            ],
            sessions:
            [
                new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
            ]);
        var service = new AuthorizedSessionService(store);

        var session = await service.GetSessionForGmAsync(sessionId, 1001L);

        Assert.Null(session);
    }

    [Fact]
    public async Task UpdateSessionForGmAsync_Throws_WhenSessionBelongsToAnotherGm()
    {
        var groupId = Guid.NewGuid();
        var sessionId = Guid.NewGuid();
        var store = new FakeSessionStore(
            groups:
            [
                new(groupId, 42, "Alpha", 2002L)
            ],
            sessions:
            [
                new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
            ]);
        var service = new AuthorizedSessionService(store);

        var action = () => service.UpdateSessionForGmAsync(sessionId, 1001L, "Updated", DateTime.UtcNow.AddDays(1), "https://example.test/b", 5);

        await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
        Assert.False(store.UpdateCalled);
    }

    [Fact]
    public async Task UpdateSessionForGmAsync_UpdatesOwnedSession()
    {
        var gmId = 1001L;
        var groupId = Guid.NewGuid();
        var sessionId = Guid.NewGuid();
        var scheduledAt = DateTime.UtcNow.AddDays(1);
        var store = new FakeSessionStore(
            groups:
            [
                new(groupId, 42, "Alpha", gmId)
            ],
            sessions:
            [
                new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
            ]);
        var service = new AuthorizedSessionService(store);

        await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated", scheduledAt, "https://example.test/b", 5);

        Assert.True(store.UpdateCalled);
        Assert.Equal(groupId, store.LastUpdatedGroupId);
        Assert.Equal(sessionId, store.LastUpdatedSessionId);
        Assert.Equal("Updated", store.LastUpdatedTitle);
        Assert.Equal(scheduledAt, store.LastUpdatedScheduledAt);
        Assert.Equal("https://example.test/b", store.LastUpdatedJoinLink);
        Assert.Equal(5, store.LastUpdatedMaxPlayers);
    }

    [Fact]
    public async Task UpdateSessionForGmAsync_LogsAudit_WhenTitleChanges()
    {
        var gmId = 1001L;
        var groupId = Guid.NewGuid();
        var sessionId = Guid.NewGuid();
        var originalTime = DateTime.UtcNow;
        var store = new FakeSessionStore(
            groups:
            [
                new(groupId, 42, "Alpha", gmId)
            ],
            sessions:
            [
                new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
            ]);
        var service = new AuthorizedSessionService(store);

        await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated Title", originalTime, "https://example.test/a", 4);

        Assert.Single(store.LogEntries);
        Assert.Equal("Title", store.LogEntries[0].ChangeType);
        Assert.Equal("Session A", store.LogEntries[0].OldValue);
        Assert.Equal("Updated Title", store.LogEntries[0].NewValue);
    }

    [Fact]
    public async Task UpdateSessionForGmAsync_LogsMultipleAudits_WhenMultipleFieldsChange()
    {
        var gmId = 1001L;
        var groupId = Guid.NewGuid();
        var sessionId = Guid.NewGuid();
        var originalTime = DateTime.UtcNow;
        var store = new FakeSessionStore(
            groups:
            [
                new(groupId, 42, "Alpha", gmId)
            ],
            sessions:
            [
                new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
            ]);
        var service = new AuthorizedSessionService(store);

        var newTime = originalTime.AddDays(1);
        await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated Title", newTime, "https://example.test/b", 5);

        Assert.Equal(4, store.LogEntries.Count);
        Assert.Contains(store.LogEntries, e => e.ChangeType == "Title");
        Assert.Contains(store.LogEntries, e => e.ChangeType == "Time");
        Assert.Contains(store.LogEntries, e => e.ChangeType == "Link");
        Assert.Contains(store.LogEntries, e => e.ChangeType == "MaxPlayers");
    }

    [Fact]
    public async Task UpdateSessionForGmAsync_DoesNotLogAudit_WhenNothingChanges()
    {
        var gmId = 1001L;
        var groupId = Guid.NewGuid();
        var sessionId = Guid.NewGuid();
        var originalTime = DateTime.UtcNow;
        var store = new FakeSessionStore(
            groups:
            [
                new(groupId, 42, "Alpha", gmId)
            ],
            sessions:
            [
                new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
            ]);
        var service = new AuthorizedSessionService(store);

        await service.UpdateSessionForGmAsync(sessionId, gmId, "Session A", originalTime, "https://example.test/a", 4);

        Assert.Empty(store.LogEntries);
    }

    [Fact]
    public async Task GetSessionHistoryForGmAsync_ReturnsHistory_WhenSessionBelongsToOwnedGroup()
    {
        var gmId = 1001L;
        var groupId = Guid.NewGuid();
        var sessionId = Guid.NewGuid();
        var store = new FakeSessionStore(
            groups:
            [
                new(groupId, 42, "Alpha", gmId)
            ],
            sessions:
            [
                new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
            ]);
        var service = new AuthorizedSessionService(store);

        var history = await service.GetSessionHistoryForGmAsync(sessionId, gmId);

        Assert.NotNull(history);
        Assert.Empty(history);
    }

    [Fact]
    public async Task GetSessionHistoryForGmAsync_ReturnsNull_WhenSessionBelongsToAnotherGm()
    {
        var groupId = Guid.NewGuid();
        var sessionId = Guid.NewGuid();
        var store = new FakeSessionStore(
            groups:
            [
                new(groupId, 42, "Alpha", 2002L)
            ],
            sessions:
            [
                new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
            ]);
        var service = new AuthorizedSessionService(store);

        var history = await service.GetSessionHistoryForGmAsync(sessionId, 1001L);

        Assert.Null(history);
    }

    [Fact]
    public async Task PromoteWaitlistedPlayerForGmAsync_PromotesOwnedSession()
    {
        var gmId = 1001L;
        var groupId = Guid.NewGuid();
        var sessionId = Guid.NewGuid();
        var store = new FakeSessionStore(
            groups:
            [
                new(groupId, 42, "Alpha", gmId)
            ],
            sessions:
            [
                new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 3, 1)
            ]);
        var service = new AuthorizedSessionService(store);

        await service.PromoteWaitlistedPlayerForGmAsync(sessionId, gmId);

        Assert.True(store.PromoteCalled);
        Assert.Equal(groupId, store.LastPromotedGroupId);
        Assert.Equal(sessionId, store.LastPromotedSessionId);
    }

    [Fact]
    public async Task UpdateBatchDetailsForGmAsync_Throws_WhenBatchBelongsToAnotherGm()
    {
        var batchId = Guid.NewGuid();
        var groupId = Guid.NewGuid();
        var store = new FakeSessionStore(
            groups:
            [
                new(groupId, 42, "Alpha", 2002L)
            ],
            sessions:
            [
                new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
            ]);
        var service = new AuthorizedSessionService(store);

        var action = () => service.UpdateBatchDetailsForGmAsync(batchId, 1001L, "Updated", "https://example.test/b");

        await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
        Assert.False(store.UpdateBatchDetailsCalled);
    }

    [Fact]
    public async Task UpdateBatchDetailsForGmAsync_UpdatesOwnedBatch()
    {
        var gmId = 1001L;
        var groupId = Guid.NewGuid();
        var batchId = Guid.NewGuid();
        var store = new FakeSessionStore(
            groups:
            [
                new(groupId, 42, "Alpha", gmId)
            ],
            sessions:
            [
                new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
            ]);
        var service = new AuthorizedSessionService(store);

        await service.UpdateBatchDetailsForGmAsync(batchId, gmId, "Updated", "https://example.test/b");

        Assert.True(store.UpdateBatchDetailsCalled);
        Assert.Equal(batchId, store.LastUpdatedBatchId);
        Assert.Equal(groupId, store.LastUpdatedBatchGroupId);
        Assert.Equal("Updated", store.LastUpdatedBatchTitle);
        Assert.Equal("https://example.test/b", store.LastUpdatedBatchJoinLink);
    }

    [Fact]
    public async Task UpdateBatchDetailsForGmAsync_UpdatesBatch_WhenUserIsCoGm()
    {
        var ownerId = 1001L;
        var coGmId = 2002L;
        var groupId = Guid.NewGuid();
        var batchId = Guid.NewGuid();
        var store = new FakeSessionStore(
            groups:
            [
                new(groupId, 42, "Alpha", ownerId)
            ],
            sessions:
            [
                new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
            ],
            managers:
            [
                new(groupId, coGmId, GroupManagerRole.CoGm)
            ]);
        var service = new AuthorizedSessionService(store);

        await service.UpdateBatchDetailsForGmAsync(batchId, coGmId, "Updated", "https://example.test/b");

        Assert.True(store.UpdateBatchDetailsCalled);
        Assert.Equal(batchId, store.LastUpdatedBatchId);
        Assert.Equal(groupId, store.LastUpdatedBatchGroupId);
    }

    [Fact]
    public async Task AddCoGmForOwnerAsync_AddsCoGm_WhenUserIsOwner()
    {
        var ownerId = 1001L;
        var coGmId = 2002L;
        var groupId = Guid.NewGuid();
        var store = new FakeSessionStore(
            groups:
            [
                new(groupId, 42, "Alpha", ownerId)
            ]);
        var service = new AuthorizedSessionService(store);

        await service.AddCoGmForOwnerAsync(groupId, ownerId, coGmId, "Assistant GM", "assistant");

        Assert.True(store.AddCoGmCalled);
        Assert.Equal(groupId, store.LastAddedCoGmGroupId);
        Assert.Equal(coGmId, store.LastAddedCoGmTelegramId);
        Assert.Equal("Assistant GM", store.LastAddedCoGmDisplayName);
        Assert.Equal("assistant", store.LastAddedCoGmUsername);
    }

    [Fact]
    public async Task AddCoGmForOwnerAsync_Throws_WhenUserIsCoGm()
    {
        var ownerId = 1001L;
        var coGmId = 2002L;
        var newCoGmId = 3003L;
        var groupId = Guid.NewGuid();
        var store = new FakeSessionStore(
            groups:
            [
                new(groupId, 42, "Alpha", ownerId)
            ],
            managers:
            [
                new(groupId, coGmId, GroupManagerRole.CoGm)
            ]);
        var service = new AuthorizedSessionService(store);

        var action = () => service.AddCoGmForOwnerAsync(groupId, coGmId, newCoGmId, "Second Assistant", null);

        await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
        Assert.False(store.AddCoGmCalled);
    }

    [Fact]
    public async Task RemoveCoGmForOwnerAsync_RemovesCoGm_WhenUserIsOwner()
    {
        var ownerId = 1001L;
        var coGmId = 2002L;
        var groupId = Guid.NewGuid();
        var store = new FakeSessionStore(
            groups:
            [
                new(groupId, 42, "Alpha", ownerId)
            ],
            managers:
            [
                new(groupId, coGmId, GroupManagerRole.CoGm)
            ]);
        var service = new AuthorizedSessionService(store);

        await service.RemoveCoGmForOwnerAsync(groupId, ownerId, coGmId);

        Assert.True(store.RemoveCoGmCalled);
        Assert.Equal(groupId, store.LastRemovedCoGmGroupId);
        Assert.Equal(coGmId, store.LastRemovedCoGmTelegramId);
    }

    [Fact]
    public async Task UpdateBatchNotificationModeForGmAsync_Throws_WhenBatchBelongsToAnotherGm()
    {
        var batchId = Guid.NewGuid();
        var groupId = Guid.NewGuid();
        var store = new FakeSessionStore(
            groups:
            [
                new(groupId, 42, "Alpha", 2002L)
            ],
            sessions:
            [
                new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
            ]);
        var service = new AuthorizedSessionService(store);

        var action = () => service.UpdateBatchNotificationModeForGmAsync(batchId, 1001L, SessionNotificationMode.GroupOnly);

        await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
        Assert.False(store.UpdateBatchNotificationModeCalled);
    }

    [Fact]
    public async Task UpdateBatchNotificationModeForGmAsync_UpdatesOwnedBatch()
    {
        var gmId = 1001L;
        var groupId = Guid.NewGuid();
        var batchId = Guid.NewGuid();
        var store = new FakeSessionStore(
            groups:
            [
                new(groupId, 42, "Alpha", gmId)
            ],
            sessions:
            [
                new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
            ]);
        var service = new AuthorizedSessionService(store);

        await service.UpdateBatchNotificationModeForGmAsync(batchId, gmId, SessionNotificationMode.GroupOnly);

        Assert.True(store.UpdateBatchNotificationModeCalled);
        Assert.Equal(batchId, store.LastUpdatedNotificationBatchId);
        Assert.Equal(groupId, store.LastUpdatedNotificationGroupId);
        Assert.Equal(SessionNotificationMode.GroupOnly, store.LastUpdatedNotificationMode);
    }

    [Fact]
    public async Task RescheduleBatchForGmAsync_RejectsNonPositiveInterval()
    {
        var gmId = 1001L;
        var groupId = Guid.NewGuid();
        var batchId = Guid.NewGuid();
        var store = new FakeSessionStore(
            groups:
            [
                new(groupId, 42, "Alpha", gmId)
            ],
            sessions:
            [
                new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
            ]);
        var service = new AuthorizedSessionService(store);

        var action = () => service.RescheduleBatchForGmAsync(batchId, gmId, DateTime.UtcNow.AddDays(7), intervalDays: 0);

        await Assert.ThrowsAsync<ArgumentOutOfRangeException>(action);
        Assert.False(store.RescheduleBatchCalled);
    }

    [Fact]
    public async Task RescheduleBatchForGmAsync_ReschedulesOwnedBatch()
    {
        var gmId = 1001L;
        var groupId = Guid.NewGuid();
        var batchId = Guid.NewGuid();
        var firstScheduledAt = DateTime.UtcNow.AddDays(7);
        var store = new FakeSessionStore(
            groups:
            [
                new(groupId, 42, "Alpha", gmId)
            ],
            sessions:
            [
                new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
            ]);
        var service = new AuthorizedSessionService(store);

        await service.RescheduleBatchForGmAsync(batchId, gmId, firstScheduledAt, intervalDays: 14);

        Assert.True(store.RescheduleBatchCalled);
        Assert.Equal(batchId, store.LastRescheduledBatchId);
        Assert.Equal(groupId, store.LastRescheduledBatchGroupId);
        Assert.Equal(firstScheduledAt, store.LastRescheduledFirstScheduledAt);
        Assert.Equal(14, store.LastRescheduledIntervalDays);
    }

    [Fact]
    public async Task CloneBatchForGmAsync_ClonesOwnedBatch()
    {
        var gmId = 1001L;
        var groupId = Guid.NewGuid();
        var batchId = Guid.NewGuid();
        var store = new FakeSessionStore(
            groups:
            [
                new(groupId, 42, "Alpha", gmId)
            ],
            sessions:
            [
                new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
            ]);
        var service = new AuthorizedSessionService(store);

        await service.CloneBatchForGmAsync(batchId, gmId, BatchCloneInterval.NextWeek);

        Assert.True(store.CloneBatchCalled);
        Assert.Equal(batchId, store.LastClonedBatchId);
        Assert.Equal(groupId, store.LastClonedBatchGroupId);
        Assert.Equal(BatchCloneInterval.NextWeek, store.LastCloneInterval);
    }

    [Fact]
    public async Task CreateCampaignTemplateForGmAsync_CreatesTemplate_WhenGroupBelongsToGm()
    {
        var gmId = 1001L;
        var groupId = Guid.NewGuid();
        var store = new FakeSessionStore(
            groups:
            [
                new(groupId, 42, "Alpha", gmId)
            ]);
        var service = new AuthorizedSessionService(store);

        await service.CreateCampaignTemplateForGmAsync(
            groupId,
            gmId,
            new CreateCampaignTemplateRequest(
                "  Weekly arc  ",
                "  Kingmaker  ",
                "  https://example.test/kingmaker  ",
                SessionCount: 6,
                IntervalDays: 7,
                MaxPlayers: 5,
                SessionNotificationMode.GroupOnly));

        Assert.True(store.CreateCampaignTemplateCalled);
        Assert.Equal(groupId, store.LastCreatedCampaignTemplateGroupId);
        Assert.Equal("Weekly arc", store.LastCreatedCampaignTemplateRequest?.Name);
        Assert.Equal("Kingmaker", store.LastCreatedCampaignTemplateRequest?.Title);
        Assert.Equal("https://example.test/kingmaker", store.LastCreatedCampaignTemplateRequest?.JoinLink);
        Assert.Equal(6, store.LastCreatedCampaignTemplateRequest?.SessionCount);
        Assert.Equal(7, store.LastCreatedCampaignTemplateRequest?.IntervalDays);
        Assert.Equal(5, store.LastCreatedCampaignTemplateRequest?.MaxPlayers);
        Assert.Equal(SessionNotificationMode.GroupOnly, store.LastCreatedCampaignTemplateRequest?.NotificationMode);
    }

    [Fact]
    public async Task CreateCampaignTemplateForGmAsync_AllowsNoSeatLimit()
    {
        var gmId = 1001L;
        var groupId = Guid.NewGuid();
        var store = new FakeSessionStore(
            groups:
            [
                new(groupId, 42, "Alpha", gmId)
            ]);
        var service = new AuthorizedSessionService(store);

        await service.CreateCampaignTemplateForGmAsync(
            groupId,
            gmId,
            new CreateCampaignTemplateRequest(
                "Open table",
                "West Marches",
                "https://example.test/west",
                8,
                7,
                null,
                SessionNotificationMode.GroupAndDirect));

        Assert.True(store.CreateCampaignTemplateCalled);
        Assert.Null(store.LastCreatedCampaignTemplateRequest?.MaxPlayers);
    }

    [Fact]
    public async Task CreateCampaignTemplateForGmAsync_Throws_WhenGroupBelongsToAnotherGm()
    {
        var groupId = Guid.NewGuid();
        var store = new FakeSessionStore(
            groups:
            [
                new(groupId, 42, "Alpha", 2002L)
            ]);
        var service = new AuthorizedSessionService(store);

        var action = () => service.CreateCampaignTemplateForGmAsync(
            groupId,
            1001L,
            new CreateCampaignTemplateRequest(
                "Weekly arc",
                "Kingmaker",
                "https://example.test/kingmaker",
                SessionCount: 6,
                IntervalDays: 7,
                MaxPlayers: 5,
                SessionNotificationMode.GroupOnly));

        await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
        Assert.False(store.CreateCampaignTemplateCalled);
    }

    [Fact]
    public async Task CreateBatchFromCampaignTemplateForGmAsync_CreatesBatch_WhenTemplateGroupBelongsToGm()
    {
        var gmId = 1001L;
        var groupId = Guid.NewGuid();
        var templateId = Guid.NewGuid();
        var firstScheduledAt = DateTime.UtcNow.AddDays(3);
        var store = new FakeSessionStore(
            groups:
            [
                new(groupId, 42, "Alpha", gmId)
            ],
            templates:
            [
                new(templateId, groupId, "Weekly arc", "Kingmaker", "https://example.test/kingmaker", 6, 7, 5, SessionNotificationModeExtensions.GroupOnlyValue, DateTime.UtcNow, DateTime.UtcNow)
            ]);
        var service = new AuthorizedSessionService(store);

        await service.CreateBatchFromCampaignTemplateForGmAsync(templateId, gmId, firstScheduledAt);

        Assert.True(store.CreateBatchFromTemplateCalled);
        Assert.Equal(templateId, store.LastCreatedBatchTemplateId);
        Assert.Equal(groupId, store.LastCreatedBatchTemplateGroupId);
        Assert.Equal(firstScheduledAt, store.LastCreatedBatchFirstScheduledAt);
    }

    [Fact]
    public async Task CreateBatchFromCampaignTemplateForGmAsync_Throws_WhenTemplateGroupBelongsToAnotherGm()
    {
        var groupId = Guid.NewGuid();
        var templateId = Guid.NewGuid();
        var store = new FakeSessionStore(
            groups:
            [
                new(groupId, 42, "Alpha", 2002L)
            ],
            templates:
            [
                new(templateId, groupId, "Weekly arc", "Kingmaker", "https://example.test/kingmaker", 6, 7, 5, SessionNotificationModeExtensions.GroupOnlyValue, DateTime.UtcNow, DateTime.UtcNow)
            ]);
        var service = new AuthorizedSessionService(store);

        var action = () => service.CreateBatchFromCampaignTemplateForGmAsync(templateId, 1001L, DateTime.UtcNow.AddDays(3));

        await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
        Assert.False(store.CreateBatchFromTemplateCalled);
    }

    private sealed class FakeSessionStore(
        IEnumerable<WebGameGroup>? groups = null,
        IEnumerable<WebSession>? sessions = null,
        IEnumerable<FakeGroupManager>? managers = null,
        IEnumerable<WebCampaignTemplate>? templates = null) : ISessionStore
    {
        private readonly Dictionary<Guid, WebGameGroup> groupsById = groups?.ToDictionary(group => group.Id) ?? [];
        private readonly Dictionary<Guid, WebSession> sessionsById = sessions?.ToDictionary(session => session.Id) ?? [];
        private readonly Dictionary<Guid, WebCampaignTemplate> templatesById = templates?.ToDictionary(template => template.Id) ?? [];
        private readonly List<FakeGroupManager> managers = managers?.ToList() ?? [];

        public bool UpdateCalled { get; private set; }
        public bool PromoteCalled { get; private set; }
        public bool UpdateBatchDetailsCalled { get; private set; }
        public bool UpdateBatchNotificationModeCalled { get; private set; }
        public bool RescheduleBatchCalled { get; private set; }
        public bool CloneBatchCalled { get; private set; }
        public bool CreateCampaignTemplateCalled { get; private set; }
        public bool DeleteCampaignTemplateCalled { get; private set; }
        public bool CreateBatchFromTemplateCalled { get; private set; }
        public bool AddCoGmCalled { get; private set; }
        public bool RemoveCoGmCalled { get; private set; }
        public Guid? LastUpdatedSessionId { get; private set; }
        public Guid? LastUpdatedGroupId { get; private set; }
        public string? LastUpdatedTitle { get; private set; }
        public DateTime? LastUpdatedScheduledAt { get; private set; }
        public string? LastUpdatedJoinLink { get; private set; }
        public int? LastUpdatedMaxPlayers { get; private set; }
        public Guid? LastPromotedSessionId { get; private set; }
        public Guid? LastPromotedGroupId { get; private set; }
        public Guid? LastUpdatedBatchId { get; private set; }
        public Guid? LastUpdatedBatchGroupId { get; private set; }
        public string? LastUpdatedBatchTitle { get; private set; }
        public string? LastUpdatedBatchJoinLink { get; private set; }
        public Guid? LastUpdatedNotificationBatchId { get; private set; }
        public Guid? LastUpdatedNotificationGroupId { get; private set; }
        public SessionNotificationMode? LastUpdatedNotificationMode { get; private set; }
        public Guid? LastRescheduledBatchId { get; private set; }
        public Guid? LastRescheduledBatchGroupId { get; private set; }
        public DateTime? LastRescheduledFirstScheduledAt { get; private set; }
        public int? LastRescheduledIntervalDays { get; private set; }
        public Guid? LastClonedBatchId { get; private set; }
        public Guid? LastClonedBatchGroupId { get; private set; }
        public BatchCloneInterval? LastCloneInterval { get; private set; }
        public Guid? LastCreatedCampaignTemplateGroupId { get; private set; }
        public CreateCampaignTemplateRequest? LastCreatedCampaignTemplateRequest { get; private set; }
        public Guid? LastDeletedCampaignTemplateId { get; private set; }
        public Guid? LastDeletedCampaignTemplateGroupId { get; private set; }
        public Guid? LastCreatedBatchTemplateId { get; private set; }
        public Guid? LastCreatedBatchTemplateGroupId { get; private set; }
        public DateTime? LastCreatedBatchFirstScheduledAt { get; private set; }
        public Guid? LastAddedCoGmGroupId { get; private set; }
        public long? LastAddedCoGmTelegramId { get; private set; }
        public string? LastAddedCoGmDisplayName { get; private set; }
        public string? LastAddedCoGmUsername { get; private set; }
        public Guid? LastRemovedCoGmGroupId { get; private set; }
        public long? LastRemovedCoGmTelegramId { get; private set; }
        public bool RemovePlayerCalled { get; private set; }
        public Guid? LastRemovedPlayerSessionId { get; private set; }
        public Guid? LastRemovedPlayerGroupId { get; private set; }
        public Guid? LastRemovedPlayerParticipantId { get; private set; }
        public List<SessionAuditLogEntry> LogEntries { get; private set; } = new();
        public Guid? LastLogSessionId { get; private set; }
        public long? LastLogActorTelegramId { get; private set; }
        public string? LastLogActorName { get; private set; }
        public string? LastLogChangeType { get; private set; }
        public string? LastLogOldValue { get; private set; }
        public string? LastLogNewValue { get; private set; }

        public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
            Task.FromResult(groupsById.Values.Where(group => IsManager(group.Id, gmId)).ToList());

        public Task<WebGameGroup?> GetGroupAsync(Guid groupId)
        {
            groupsById.TryGetValue(groupId, out var group);
            return Task.FromResult(group);
        }

        public Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId) =>
            Task.FromResult(IsManager(groupId, telegramId));

        public Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId) =>
            Task.FromResult(IsOwner(groupId, telegramId));

        public Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId)
        {
            if (!groupsById.TryGetValue(groupId, out var group))
            {
                return Task.FromResult(new List<WebGroupManager>());
            }

            var result = new List<WebGroupManager>
            {
                new(group.GmTelegramId, "Owner GM", null, GroupManagerRoleExtensions.OwnerValue, DateTime.UtcNow)
            };

            result.AddRange(managers
                .Where(manager => manager.GroupId == groupId)
                .Select(manager => new WebGroupManager(
                    manager.TelegramId,
                    $"Co-GM {manager.TelegramId}",
                    null,
                    manager.Role.ToDatabaseValue(),
                    DateTime.UtcNow)));

            return Task.FromResult(result);
        }

        public Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId) =>
            Task.FromResult(sessionsById.Values.Where(session => session.GroupId == groupId).ToList());

        public Task<WebSession?> GetSessionAsync(Guid sessionId)
        {
            sessionsById.TryGetValue(sessionId, out var session);
            return Task.FromResult(session);
        }

        public Task<WebSessionBatch?> GetBatchAsync(Guid batchId)
        {
            var batchSessions = sessionsById.Values
                .Where(session => session.BatchId == batchId)
                .OrderBy(session => session.ScheduledAt)
                .ToList();

            if (batchSessions.Count == 0)
            {
                return Task.FromResult<WebSessionBatch?>(null);
            }

            var firstSession = batchSessions[0];
            return Task.FromResult<WebSessionBatch?>(new(
                batchId,
                firstSession.GroupId,
                firstSession.Title,
                firstSession.JoinLink,
                firstSession.ScheduledAt,
                batchSessions[^1].ScheduledAt,
                batchSessions.Count));
        }

        public Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers)
        {
            UpdateCalled = true;
            LastUpdatedSessionId = sessionId;
            LastUpdatedGroupId = groupId;
            LastUpdatedTitle = title;
            LastUpdatedScheduledAt = scheduledAt;
            LastUpdatedJoinLink = joinLink;
            LastUpdatedMaxPlayers = maxPlayers;
            return Task.CompletedTask;
        }

        public Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId)
        {
            PromoteCalled = true;
            LastPromotedSessionId = sessionId;
            LastPromotedGroupId = groupId;
            return Task.CompletedTask;
        }

        public Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink)
        {
            UpdateBatchDetailsCalled = true;
            LastUpdatedBatchId = batchId;
            LastUpdatedBatchGroupId = groupId;
            LastUpdatedBatchTitle = title;
            LastUpdatedBatchJoinLink = joinLink;
            return Task.CompletedTask;
        }

        public Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode)
        {
            UpdateBatchNotificationModeCalled = true;
            LastUpdatedNotificationBatchId = batchId;
            LastUpdatedNotificationGroupId = groupId;
            LastUpdatedNotificationMode = notificationMode;
            return Task.CompletedTask;
        }

        public Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays)
        {
            RescheduleBatchCalled = true;
            LastRescheduledBatchId = batchId;
            LastRescheduledBatchGroupId = groupId;
            LastRescheduledFirstScheduledAt = firstScheduledAt;
            LastRescheduledIntervalDays = intervalDays;
            return Task.CompletedTask;
        }

        public Task<WebSessionBatch> CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval)
        {
            CloneBatchCalled = true;
            LastClonedBatchId = batchId;
            LastClonedBatchGroupId = groupId;
            LastCloneInterval = interval;
            return Task.FromResult(new WebSessionBatch(
                Guid.NewGuid(),
                groupId,
                "Session A",
                "https://example.test/a",
                DateTime.UtcNow.AddDays(7),
                DateTime.UtcNow.AddDays(7),
                1));
        }

        public Task<List<WebCampaignTemplate>> GetCampaignTemplatesAsync(Guid groupId) =>
            Task.FromResult(templatesById.Values.Where(template => template.GroupId == groupId).ToList());

        public Task<WebCampaignTemplate?> GetCampaignTemplateAsync(Guid templateId)
        {
            templatesById.TryGetValue(templateId, out var template);
            return Task.FromResult(template);
        }

        public Task<WebCampaignTemplate> CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request)
        {
            CreateCampaignTemplateCalled = true;
            LastCreatedCampaignTemplateGroupId = groupId;
            LastCreatedCampaignTemplateRequest = request;

            var template = new WebCampaignTemplate(
                Guid.NewGuid(),
                groupId,
                request.Name,
                request.Title,
                request.JoinLink,
                request.SessionCount,
                request.IntervalDays,
                request.MaxPlayers,
                request.NotificationMode.ToDatabaseValue(),
                DateTime.UtcNow,
                DateTime.UtcNow);

            templatesById[template.Id] = template;
            return Task.FromResult(template);
        }

        public Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId)
        {
            DeleteCampaignTemplateCalled = true;
            LastDeletedCampaignTemplateId = templateId;
            LastDeletedCampaignTemplateGroupId = groupId;
            templatesById.Remove(templateId);
            return Task.CompletedTask;
        }

        public Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt)
        {
            CreateBatchFromTemplateCalled = true;
            LastCreatedBatchTemplateId = templateId;
            LastCreatedBatchTemplateGroupId = groupId;
            LastCreatedBatchFirstScheduledAt = firstScheduledAt;

            var template = templatesById[templateId];
            return Task.FromResult(new WebSessionBatch(
                Guid.NewGuid(),
                groupId,
                template.Title,
                template.JoinLink,
                firstScheduledAt,
                firstScheduledAt.AddDays(template.IntervalDays * (template.SessionCount - 1)),
                template.SessionCount,
                template.NotificationMode));
        }

        public Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername)
        {
            AddCoGmCalled = true;
            LastAddedCoGmGroupId = groupId;
            LastAddedCoGmTelegramId = coGmTelegramId;
            LastAddedCoGmDisplayName = displayName;
            LastAddedCoGmUsername = telegramUsername;
            return Task.CompletedTask;
        }

        public Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId)
        {
            RemoveCoGmCalled = true;
            LastRemovedCoGmGroupId = groupId;
            LastRemovedCoGmTelegramId = coGmTelegramId;
            return Task.CompletedTask;
        }

        public Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId) =>
            Task.FromResult(new List<WebParticipant>());

        public Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId)
        {
            RemovePlayerCalled = true;
            LastRemovedPlayerSessionId = sessionId;
            LastRemovedPlayerGroupId = groupId;
            LastRemovedPlayerParticipantId = participantId;
            return Task.CompletedTask;
        }

        public Task<List<PlayerAttendanceStats>> GetGroupAttendanceStatsAsync(Guid groupId) =>
            Task.FromResult(new List<PlayerAttendanceStats>());

        public Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, string actorName, string changeType, string? oldValue, string? newValue)
        {
            var entry = new SessionAuditLogEntry(Guid.NewGuid(), sessionId, actorTelegramId, actorName, changeType, oldValue, newValue, DateTime.UtcNow);
            LogEntries.Add(entry);
            LastLogSessionId = sessionId;
            LastLogActorTelegramId = actorTelegramId;
            LastLogActorName = actorName;
            LastLogChangeType = changeType;
            LastLogOldValue = oldValue;
            LastLogNewValue = newValue;
            return Task.CompletedTask;
        }

        public Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId) =>
            Task.FromResult(LogEntries.Where(e => e.SessionId == sessionId).OrderByDescending(e => e.ChangedAt).ToList());

        private bool IsManager(Guid groupId, long telegramId) =>
            IsOwner(groupId, telegramId) ||
            managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == telegramId);

        private bool IsOwner(Guid groupId, long telegramId) =>
            groupsById.TryGetValue(groupId, out var group) && group.GmTelegramId == telegramId;
    }

    private sealed record FakeGroupManager(Guid GroupId, long TelegramId, GroupManagerRole Role);
}
