using GmRelay.Shared.Domain;
using GmRelay.Web.Services;
using GmRelay.Web.Services.Portfolio;
using GmRelay.Web.Services.Portfolio.Covers;
using Microsoft.AspNetCore.Http;
using System.Security.Claims;

namespace GmRelay.Bot.Tests.Web;

public sealed class AuthorizedPortfolioServiceTests
{
    private static IHttpContextAccessor CreateAccessor(string externalUserId, string? name = null)
    {
        var claims = new List<Claim>
        {
            new Claim(ClaimTypes.NameIdentifier, externalUserId),
            new Claim("TelegramId", externalUserId),
            new Claim("Platform", "Telegram")
        };
        if (name is not null)
            claims.Add(new Claim(ClaimTypes.Name, name));

        var identity = new ClaimsIdentity(claims, "Test");
        var principal = new ClaimsPrincipal(identity);
        var httpContext = new DefaultHttpContext { User = principal };
        return new HttpContextAccessor { HttpContext = httpContext };
    }

    private static IHttpContextAccessor CreateAnonymousAccessor()
    {
        var httpContext = new DefaultHttpContext();
        return new HttpContextAccessor { HttpContext = httpContext };
    }

    private static AuthorizedPortfolioService CreateService(
        FakePortfolioStore? portfolioStore = null,
        FakeSessionStore? sessionStore = null,
        FakePortfolioCoverStorage? coverStorage = null,
        IHttpContextAccessor? accessor = null,
        bool isManager = true,
        Guid? knownGroupId = null)
    {
        portfolioStore ??= new FakePortfolioStore();
        sessionStore ??= new FakeSessionStore();
        coverStorage ??= new FakePortfolioCoverStorage();
        accessor ??= CreateAccessor("1001");

        // Wire a known group + manager relationship for the test
        if (knownGroupId is not null)
        {
            portfolioStore.GroupIds[Guid.NewGuid()] = knownGroupId.Value; // placeholder
            sessionStore.ManagerFlags[(knownGroupId.Value, "Telegram", "1001")] = isManager;
        }

        return new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor);
    }

    [Fact]
    public async Task CreateDraftForCurrentUserAsync_ShouldAllowCoGm()
    {
        var groupId = Guid.NewGuid();
        var sessionId = Guid.NewGuid();
        var draftId = Guid.NewGuid();
        var portfolioStore = new FakePortfolioStore
        {
            CreateDraftResult = draftId,
            PortfolioGameGroupIds = new Dictionary<Guid, Guid?>
            {
                [draftId] = groupId
            }
        };
        var sessionStore = new FakeSessionStore
        {
            ManagerFlags = new Dictionary<(Guid, string, string), bool>
            {
                [(groupId, "Telegram", "1001")] = true
            }
        };
        var accessor = CreateAccessor("1001");
        var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);

        var created = await service.CreateDraftForCurrentUserAsync(groupId, sessionId);

        Assert.Equal(draftId, created);
        Assert.Equal(groupId, portfolioStore.LastCreateGroupId);
        Assert.Equal(sessionId, portfolioStore.LastCreatePreselectedSessionId);
    }

    [Fact]
    public async Task CreateDraftForCurrentUserAsync_ShouldRejectAnotherClubManager()
    {
        var groupId = Guid.NewGuid();
        var sessionStore = new FakeSessionStore
        {
            ManagerFlags = new Dictionary<(Guid, string, string), bool>
            {
                [(groupId, "Telegram", "1001")] = false
            }
        };
        var accessor = CreateAccessor("1001");
        var service = new AuthorizedPortfolioService(new FakePortfolioStore(), sessionStore, new FakePortfolioCoverStorage(), accessor);

        await Assert.ThrowsAsync<SessionAccessDeniedException>(
            () => service.CreateDraftForCurrentUserAsync(groupId, null));
    }

    [Fact]
    public async Task ReplaceCoverForCurrentUserAsync_ShouldDeleteOldCoverAfterSuccessfulSwap()
    {
        var groupId = Guid.NewGuid();
        var portfolioGameId = Guid.NewGuid();
        var portfolioStore = new FakePortfolioStore
        {
            PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId },
            CoverPriorKey = "old.png"
        };
        var coverStorage = new FakePortfolioCoverStorage
        {
            SaveKey = "new.png"
        };
        var sessionStore = new FakeSessionStore
        {
            ManagerFlags = new Dictionary<(Guid, string, string), bool>
            {
                [(groupId, "Telegram", "1001")] = true
            }
        };
        var accessor = CreateAccessor("1001");
        var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor);

        await service.ReplaceCoverForCurrentUserAsync(portfolioGameId, new MemoryStream([0x89, 0x50]), "image/png");

        Assert.Contains("old.png", coverStorage.DeletedKeys);
        Assert.Contains("new.png", coverStorage.SavedKeys);
        Assert.Equal(portfolioGameId, portfolioStore.LastSetCoverGameId);
        Assert.Equal("new.png", portfolioStore.LastSetCoverKey);
    }

    [Fact]
    public async Task ReplaceCoverForCurrentUserAsync_ShouldDeleteNewCoverWhenPersistenceFails()
    {
        var groupId = Guid.NewGuid();
        var portfolioGameId = Guid.NewGuid();
        var portfolioStore = new FakePortfolioStore
        {
            PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId },
            SetCoverThrows = new InvalidOperationException("boom")
        };
        var coverStorage = new FakePortfolioCoverStorage
        {
            SaveKey = "new.png"
        };
        var sessionStore = new FakeSessionStore
        {
            ManagerFlags = new Dictionary<(Guid, string, string), bool>
            {
                [(groupId, "Telegram", "1001")] = true
            }
        };
        var accessor = CreateAccessor("1001");
        var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor);

        await Assert.ThrowsAsync<InvalidOperationException>(
            () => service.ReplaceCoverForCurrentUserAsync(portfolioGameId, new MemoryStream([0x89, 0x50]), "image/png"));

        Assert.Contains("new.png", coverStorage.DeletedKeys);
        Assert.DoesNotContain("old.png", coverStorage.DeletedKeys);
    }

    [Fact]
    public async Task GetPortfolioGameForCurrentUserAsync_ShouldReturnNullForAnotherClubManager()
    {
        var groupId = Guid.NewGuid();
        var portfolioGameId = Guid.NewGuid();
        var portfolioStore = new FakePortfolioStore
        {
            PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId }
        };
        var sessionStore = new FakeSessionStore
        {
            ManagerFlags = new Dictionary<(Guid, string, string), bool>
            {
                [(groupId, "Telegram", "1001")] = false
            }
        };
        var accessor = CreateAccessor("1001");
        var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);

        var editor = await service.GetPortfolioGameForCurrentUserAsync(portfolioGameId);

        Assert.Null(editor);
    }

    [Fact]
    public async Task GetPortfolioGameForCurrentUserAsync_ShouldReturnEditorForCoGm()
    {
        var groupId = Guid.NewGuid();
        var portfolioGameId = Guid.NewGuid();
        var portfolioStore = new FakePortfolioStore
        {
            PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId },
            EditorResult = new PortfolioGameEditor(
                portfolioGameId,
                groupId,
                "Title",
                "slug",
                "Description",
                "/portfolio-covers/x.png",
                "D&D 5e",
                "Online",
                DateTime.UtcNow,
                false,
                [],
                [],
                [])
        };
        var sessionStore = new FakeSessionStore
        {
            ManagerFlags = new Dictionary<(Guid, string, string), bool>
            {
                [(groupId, "Telegram", "1001")] = true
            }
        };
        var accessor = CreateAccessor("1001");
        var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);

        var editor = await service.GetPortfolioGameForCurrentUserAsync(portfolioGameId);

        Assert.NotNull(editor);
        Assert.Equal("Title", editor!.Title);
    }

    [Fact]
    public async Task UpdateDraftForCurrentUserAsync_ShouldRejectAnotherClubManager()
    {
        var groupId = Guid.NewGuid();
        var portfolioGameId = Guid.NewGuid();
        var portfolioStore = new FakePortfolioStore
        {
            PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId }
        };
        var sessionStore = new FakeSessionStore
        {
            ManagerFlags = new Dictionary<(Guid, string, string), bool>
            {
                [(groupId, "Telegram", "1001")] = false
            }
        };
        var accessor = CreateAccessor("1001");
        var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
        var update = new PortfolioGameUpdate("Updated", "updated-slug", "desc", null, "Online", [], []);

        await Assert.ThrowsAsync<SessionAccessDeniedException>(
            () => service.UpdateDraftForCurrentUserAsync(portfolioGameId, update));
        Assert.False(portfolioStore.UpdateCalled);
    }

    [Fact]
    public async Task UpdateDraftForCurrentUserAsync_ShouldNormalizeFieldsBeforeStoring()
    {
        var groupId = Guid.NewGuid();
        var portfolioGameId = Guid.NewGuid();
        var portfolioStore = new FakePortfolioStore
        {
            PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId }
        };
        var sessionStore = new FakeSessionStore
        {
            ManagerFlags = new Dictionary<(Guid, string, string), bool>
            {
                [(groupId, "Telegram", "1001")] = true
            }
        };
        var accessor = CreateAccessor("1001");
        var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
        var update = new PortfolioGameUpdate("  Updated  ", "  my-slug  ", "  description  ", null, " Online ", [], []);

        await service.UpdateDraftForCurrentUserAsync(portfolioGameId, update);

        Assert.True(portfolioStore.UpdateCalled);
        Assert.Equal("Updated", portfolioStore.LastUpdateTitle);
        Assert.Equal("my-slug", portfolioStore.LastUpdateSlug);
        Assert.Equal("description", portfolioStore.LastUpdateDescription);
        Assert.Equal("Online", portfolioStore.LastUpdateFormat);
    }

    [Fact]
    public async Task ModerateReviewForCurrentUserAsync_ShouldResolveEffectivePlayerAndForwardIt()
    {
        var groupId = Guid.NewGuid();
        var portfolioGameId = Guid.NewGuid();
        var reviewId = Guid.NewGuid();
        var effectivePlayerId = Guid.NewGuid();
        var portfolioStore = new FakePortfolioStore
        {
            PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId }
        };
        var sessionStore = new FakeSessionStore
        {
            ManagerFlags = new Dictionary<(Guid, string, string), bool>
            {
                [(groupId, "Telegram", "1001")] = true
            },
            EffectivePlayerId = effectivePlayerId
        };
        var accessor = CreateAccessor("1001");
        var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);

        await service.ModerateReviewForCurrentUserAsync(portfolioGameId, reviewId, "Approved");

        Assert.True(portfolioStore.ModerateCalled);
        Assert.Equal(reviewId, portfolioStore.LastModerateReviewId);
        Assert.Equal(portfolioGameId, portfolioStore.LastModerateGameId);
        Assert.Equal(groupId, portfolioStore.LastModerateGroupId);
        Assert.Equal(effectivePlayerId, portfolioStore.LastModeratePlayerId);
        Assert.Equal("Approved", portfolioStore.LastModerateStatus);
    }

    [Fact]
    public async Task ModerateReviewForCurrentUserAsync_ShouldRejectAnotherClubManager()
    {
        var groupId = Guid.NewGuid();
        var portfolioGameId = Guid.NewGuid();
        var portfolioStore = new FakePortfolioStore
        {
            PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId }
        };
        var sessionStore = new FakeSessionStore
        {
            ManagerFlags = new Dictionary<(Guid, string, string), bool>
            {
                [(groupId, "Telegram", "1001")] = false
            }
        };
        var accessor = CreateAccessor("1001");
        var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);

        await Assert.ThrowsAsync<SessionAccessDeniedException>(
            () => service.ModerateReviewForCurrentUserAsync(portfolioGameId, Guid.NewGuid(), "Approved"));
        Assert.False(portfolioStore.ModerateCalled);
    }

    [Fact]
    public async Task DeleteForCurrentUserAsync_ShouldDeleteCoverAfterRowDeletion()
    {
        var groupId = Guid.NewGuid();
        var portfolioGameId = Guid.NewGuid();
        var portfolioStore = new FakePortfolioStore
        {
            PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId },
            DeleteCoverKey = "old.png"
        };
        var coverStorage = new FakePortfolioCoverStorage();
        var sessionStore = new FakeSessionStore
        {
            ManagerFlags = new Dictionary<(Guid, string, string), bool>
            {
                [(groupId, "Telegram", "1001")] = true
            }
        };
        var accessor = CreateAccessor("1001");
        var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor);

        await service.DeleteForCurrentUserAsync(portfolioGameId);

        Assert.True(portfolioStore.DeleteCalled);
        Assert.Equal(portfolioGameId, portfolioStore.LastDeleteGameId);
        Assert.Equal(groupId, portfolioStore.LastDeleteGroupId);
        Assert.Contains("old.png", coverStorage.DeletedKeys);
    }

    [Fact]
    public async Task DeleteForCurrentUserAsync_ShouldStillDeleteRowWhenNoCover()
    {
        var groupId = Guid.NewGuid();
        var portfolioGameId = Guid.NewGuid();
        var portfolioStore = new FakePortfolioStore
        {
            PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId },
            DeleteCoverKey = null
        };
        var coverStorage = new FakePortfolioCoverStorage();
        var sessionStore = new FakeSessionStore
        {
            ManagerFlags = new Dictionary<(Guid, string, string), bool>
            {
                [(groupId, "Telegram", "1001")] = true
            }
        };
        var accessor = CreateAccessor("1001");
        var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor);

        await service.DeleteForCurrentUserAsync(portfolioGameId);

        Assert.True(portfolioStore.DeleteCalled);
        Assert.Empty(coverStorage.DeletedKeys);
    }

    [Fact]
    public async Task SetPublicationForCurrentUserAsync_ShouldForwardIsPublicFlag()
    {
        var groupId = Guid.NewGuid();
        var portfolioGameId = Guid.NewGuid();
        var portfolioStore = new FakePortfolioStore
        {
            PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId }
        };
        var sessionStore = new FakeSessionStore
        {
            ManagerFlags = new Dictionary<(Guid, string, string), bool>
            {
                [(groupId, "Telegram", "1001")] = true
            }
        };
        var accessor = CreateAccessor("1001");
        var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);

        await service.SetPublicationForCurrentUserAsync(portfolioGameId, isPublic: true);

        Assert.True(portfolioStore.PublicationCalled);
        Assert.Equal(portfolioGameId, portfolioStore.LastPublicationGameId);
        Assert.Equal(groupId, portfolioStore.LastPublicationGroupId);
        Assert.True(portfolioStore.LastPublicationIsPublic);
    }

    [Fact]
    public async Task GetReviewSubmissionStateForCurrentUserAsync_ShouldReturnRequiresAuthForAnonymous()
    {
        var portfolioStore = new FakePortfolioStore();
        var sessionStore = new FakeSessionStore();
        var accessor = CreateAnonymousAccessor();
        var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);

        var state = await service.GetReviewSubmissionStateForCurrentUserAsync("some-slug");

        Assert.Equal(PortfolioReviewSubmissionState.RequiresAuthentication, state);
    }

    [Fact]
    public async Task GetReviewSubmissionStateForCurrentUserAsync_ShouldForwardPlatformAndUserId()
    {
        var portfolioStore = new FakePortfolioStore
        {
            ReviewStateResult = PortfolioReviewSubmissionState.Eligible
        };
        var sessionStore = new FakeSessionStore();
        var accessor = CreateAccessor("1001");
        var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);

        var state = await service.GetReviewSubmissionStateForCurrentUserAsync("some-slug");

        Assert.Equal(PortfolioReviewSubmissionState.Eligible, state);
        Assert.Equal("some-slug", portfolioStore.LastReviewStateSlug);
        Assert.Equal("Telegram", portfolioStore.LastReviewStatePlatform);
        Assert.Equal("1001", portfolioStore.LastReviewStateExternalUserId);
    }

    [Fact]
    public async Task SubmitReviewForCurrentUserAsync_ShouldRejectAnonymous()
    {
        var portfolioStore = new FakePortfolioStore();
        var sessionStore = new FakeSessionStore();
        var accessor = CreateAnonymousAccessor();
        var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);

        await Assert.ThrowsAsync<SessionAccessDeniedException>(
            () => service.SubmitReviewForCurrentUserAsync("slug", "great adventure, would play again", true));
        Assert.False(portfolioStore.SubmitReviewCalled);
    }

    [Fact]
    public async Task SubmitReviewForCurrentUserAsync_ShouldRejectMissingConsent()
    {
        var portfolioStore = new FakePortfolioStore();
        var sessionStore = new FakeSessionStore();
        var accessor = CreateAccessor("1001", "Alice");
        var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);

        await Assert.ThrowsAsync<InvalidOperationException>(
            () => service.SubmitReviewForCurrentUserAsync("slug", "great adventure, would play again", false));
        Assert.False(portfolioStore.SubmitReviewCalled);
    }

    [Fact]
    public async Task SubmitReviewForCurrentUserAsync_ShouldNormalizeBodyAndForwardIdentity()
    {
        var portfolioStore = new FakePortfolioStore();
        var sessionStore = new FakeSessionStore();
        var accessor = CreateAccessor("1001", "Alice");
        var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);

        await service.SubmitReviewForCurrentUserAsync(
            "  the-curse-of-strahd  ",
            "  great adventure, would play again  ",
            true);

        Assert.True(portfolioStore.SubmitReviewCalled);
        Assert.Equal("the-curse-of-strahd", portfolioStore.LastSubmitSlug);
        Assert.Equal("great adventure, would play again", portfolioStore.LastSubmitBody);
        Assert.Equal("Alice", portfolioStore.LastSubmitDisplayName);
        Assert.Equal("Telegram", portfolioStore.LastSubmitPlatform);
        Assert.Equal("1001", portfolioStore.LastSubmitExternalUserId);
    }

    [Fact]
    public async Task GetCompletedSessionsForCurrentUserAsync_ShouldReturnEmptyForNonManager()
    {
        var groupId = Guid.NewGuid();
        var portfolioStore = new FakePortfolioStore();
        var sessionStore = new FakeSessionStore
        {
            ManagerFlags = new Dictionary<(Guid, string, string), bool>
            {
                [(groupId, "Telegram", "1001")] = false
            }
        };
        var accessor = CreateAccessor("1001");
        var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);

        var sessions = await service.GetCompletedSessionsForCurrentUserAsync(groupId);

        Assert.Empty(sessions);
        Assert.Null(portfolioStore.LastEligibleGroupId);
    }

    [Fact]
    public async Task GetCompletedSessionsForCurrentUserAsync_ShouldReturnSessionsForManager()
    {
        var groupId = Guid.NewGuid();
        var sessionId = Guid.NewGuid();
        var portfolioStore = new FakePortfolioStore
        {
            EligibleSessions =
            [
                new PortfolioSessionOption(sessionId, "Old session", DateTime.UtcNow.AddDays(-7), false)
            ]
        };
        var sessionStore = new FakeSessionStore
        {
            ManagerFlags = new Dictionary<(Guid, string, string), bool>
            {
                [(groupId, "Telegram", "1001")] = true
            }
        };
        var accessor = CreateAccessor("1001");
        var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);

        var sessions = await service.GetCompletedSessionsForCurrentUserAsync(groupId);

        Assert.Single(sessions);
        Assert.Equal(sessionId, sessions[0].Id);
    }

    [Fact]
    public async Task GetPortfolioGamesForCurrentUserAsync_ShouldReturnEmptyForNonManager()
    {
        var groupId = Guid.NewGuid();
        var portfolioStore = new FakePortfolioStore();
        var sessionStore = new FakeSessionStore
        {
            ManagerFlags = new Dictionary<(Guid, string, string), bool>
            {
                [(groupId, "Telegram", "1001")] = false
            }
        };
        var accessor = CreateAccessor("1001");
        var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);

        var games = await service.GetPortfolioGamesForCurrentUserAsync(groupId);

        Assert.Empty(games);
    }

    [Fact]
    public async Task IDScopedMethod_ShouldThrowWhenPortfolioGameDoesNotExist()
    {
        var portfolioGameId = Guid.NewGuid();
        var portfolioStore = new FakePortfolioStore
        {
            PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = null }
        };
        var sessionStore = new FakeSessionStore();
        var accessor = CreateAccessor("1001");
        var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);

        await Assert.ThrowsAsync<InvalidOperationException>(
            () => service.DeleteForCurrentUserAsync(portfolioGameId));
    }

    // --- Fakes ---

    private sealed class FakePortfolioStore : IPortfolioStore
    {
        public Dictionary<Guid, Guid?> PortfolioGameGroupIds { get; set; } = new();

        public Dictionary<Guid, Guid> GroupIds { get; set; } = new();

        public Guid CreateDraftResult { get; set; } = Guid.NewGuid();

        public Guid? LastCreateGroupId { get; private set; }
        public Guid? LastCreatePreselectedSessionId { get; private set; }
        public bool CreateCalled { get; private set; }

        public PortfolioGameEditor? EditorResult { get; set; }

        public string? CoverPriorKey { get; set; }
        public Exception? SetCoverThrows { get; set; }
        public Guid? LastSetCoverGameId { get; private set; }
        public Guid? LastSetCoverGroupId { get; private set; }
        public string? LastSetCoverKey { get; private set; }

        public bool UpdateCalled { get; private set; }
        public Guid? LastUpdateGameId { get; private set; }
        public Guid? LastUpdateGroupId { get; private set; }
        public string? LastUpdateTitle { get; private set; }
        public string? LastUpdateSlug { get; private set; }
        public string? LastUpdateDescription { get; private set; }
        public string? LastUpdateFormat { get; private set; }

        public bool DeleteCalled { get; private set; }
        public Guid? LastDeleteGameId { get; private set; }
        public Guid? LastDeleteGroupId { get; private set; }
        public string? DeleteCoverKey { get; set; }

        public bool PublicationCalled { get; private set; }
        public Guid? LastPublicationGameId { get; private set; }
        public Guid? LastPublicationGroupId { get; private set; }
        public bool? LastPublicationIsPublic { get; private set; }

        public bool ModerateCalled { get; private set; }
        public Guid? LastModerateReviewId { get; private set; }
        public Guid? LastModerateGameId { get; private set; }
        public Guid? LastModerateGroupId { get; private set; }
        public Guid? LastModeratePlayerId { get; private set; }
        public string? LastModerateStatus { get; private set; }

        public IReadOnlyList<PortfolioSessionOption> EligibleSessions { get; set; } = [];
        public Guid? LastEligibleGroupId { get; private set; }

        public IReadOnlyList<PortfolioGameSummary> GamesForGroup { get; set; } = [];

        public PortfolioReviewSubmissionState ReviewStateResult { get; set; } = PortfolioReviewSubmissionState.Ineligible;
        public string? LastReviewStateSlug { get; private set; }
        public string? LastReviewStatePlatform { get; private set; }
        public string? LastReviewStateExternalUserId { get; private set; }

        public bool SubmitReviewCalled { get; private set; }
        public string? LastSubmitSlug { get; private set; }
        public string? LastSubmitBody { get; private set; }
        public string? LastSubmitDisplayName { get; private set; }
        public string? LastSubmitPlatform { get; private set; }
        public string? LastSubmitExternalUserId { get; private set; }

        public Task<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForMasterAsync(string masterSlug) =>
            Task.FromResult<IReadOnlyList<PublicPortfolioCard>>([]);

        public Task<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForClubAsync(string clubSlug) =>
            Task.FromResult<IReadOnlyList<PublicPortfolioCard>>([]);

        public Task<PublicPortfolioGame?> GetPublicPortfolioGameBySlugAsync(string slug) =>
            Task.FromResult<PublicPortfolioGame?>(null);

        public Task<IReadOnlyList<PortfolioGameSummary>> GetPortfolioGamesForGroupAsync(Guid groupId)
        {
            LastEligibleGroupId = groupId;
            return Task.FromResult(GamesForGroup);
        }

        public Task<Guid?> GetPortfolioGameGroupIdAsync(Guid portfolioGameId)
        {
            PortfolioGameGroupIds.TryGetValue(portfolioGameId, out var groupId);
            return Task.FromResult(groupId);
        }

        public Task<PortfolioGameEditor?> GetPortfolioGameForManagementAsync(Guid portfolioGameId) =>
            Task.FromResult(EditorResult);

        public Task<IReadOnlyList<PortfolioSessionOption>> GetEligibleCompletedSessionsAsync(Guid groupId, Guid? portfolioGameId)
        {
            LastEligibleGroupId = groupId;
            return Task.FromResult(EligibleSessions);
        }

        public Task<IReadOnlyList<PortfolioMasterOption>> GetPortfolioMasterOptionsAsync(Guid groupId, Guid? portfolioGameId) =>
            Task.FromResult<IReadOnlyList<PortfolioMasterOption>>([]);

        public Task<Guid> CreatePortfolioDraftAsync(Guid groupId, Guid? preselectedSessionId)
        {
            CreateCalled = true;
            LastCreateGroupId = groupId;
            LastCreatePreselectedSessionId = preselectedSessionId;
            return Task.FromResult(CreateDraftResult);
        }

        public Task UpdatePortfolioDraftAsync(Guid portfolioGameId, Guid groupId, PortfolioGameUpdate update)
        {
            UpdateCalled = true;
            LastUpdateGameId = portfolioGameId;
            LastUpdateGroupId = groupId;
            LastUpdateTitle = update.Title;
            LastUpdateSlug = update.PublicSlug;
            LastUpdateDescription = update.Description;
            LastUpdateFormat = update.Format;
            return Task.CompletedTask;
        }

        public Task<string?> SetPortfolioCoverAsync(Guid portfolioGameId, Guid groupId, string storageKey)
        {
            LastSetCoverGameId = portfolioGameId;
            LastSetCoverGroupId = groupId;
            LastSetCoverKey = storageKey;

            if (SetCoverThrows is not null)
            {
                throw SetCoverThrows;
            }

            return Task.FromResult(CoverPriorKey);
        }

        public Task<string?> DeletePortfolioGameAsync(Guid portfolioGameId, Guid groupId)
        {
            DeleteCalled = true;
            LastDeleteGameId = portfolioGameId;
            LastDeleteGroupId = groupId;
            return Task.FromResult(DeleteCoverKey);
        }

        public Task SetPortfolioPublicationAsync(Guid portfolioGameId, Guid groupId, bool isPublic)
        {
            PublicationCalled = true;
            LastPublicationGameId = portfolioGameId;
            LastPublicationGroupId = groupId;
            LastPublicationIsPublic = isPublic;
            return Task.CompletedTask;
        }

        public Task ModeratePortfolioReviewAsync(
            Guid reviewId,
            Guid portfolioGameId,
            Guid groupId,
            Guid moderatorPlayerId,
            string moderationStatus)
        {
            ModerateCalled = true;
            LastModerateReviewId = reviewId;
            LastModerateGameId = portfolioGameId;
            LastModerateGroupId = groupId;
            LastModeratePlayerId = moderatorPlayerId;
            LastModerateStatus = moderationStatus;
            return Task.CompletedTask;
        }

        public Task<PortfolioReviewSubmissionState> GetReviewSubmissionStateAsync(string slug, string platform, string externalUserId)
        {
            LastReviewStateSlug = slug;
            LastReviewStatePlatform = platform;
            LastReviewStateExternalUserId = externalUserId;
            return Task.FromResult(ReviewStateResult);
        }

        public Task SubmitPortfolioReviewAsync(string slug, string platform, string externalUserId, string displayName, string body)
        {
            SubmitReviewCalled = true;
            LastSubmitSlug = slug;
            LastSubmitPlatform = platform;
            LastSubmitExternalUserId = externalUserId;
            LastSubmitDisplayName = displayName;
            LastSubmitBody = body;
            return Task.CompletedTask;
        }
    }

    private sealed class FakeSessionStore : ISessionStore
    {
        public Dictionary<(Guid GroupId, string Platform, string ExternalUserId), bool> ManagerFlags { get; set; } = new();

        public Guid? EffectivePlayerId { get; set; } = Guid.NewGuid();

        public Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId)
        {
            if (ManagerFlags.TryGetValue((groupId, platform, externalUserId), out var flag))
            {
                return Task.FromResult(flag);
            }
            return Task.FromResult(false);
        }

        public Task<Guid?> ResolveEffectivePlayerIdAsync(string platform, string externalUserId) =>
            Task.FromResult(EffectivePlayerId);

        // Unused interface members — throw so accidental use surfaces in test output
        public Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId) => throw new NotImplementedException();
        public Task<WebGameGroup?> GetGroupAsync(Guid groupId) => throw new NotImplementedException();
        public Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId) => throw new NotImplementedException();
        public Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled) => throw new NotImplementedException();
        public Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode) => throw new NotImplementedException();
        public Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode) => throw new NotImplementedException();
        public Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId = null) => throw new NotImplementedException();
        public Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId = null) => throw new NotImplementedException();
        public Task<bool> IsActiveClubMemberAsync(Guid groupId, Guid playerId) => throw new NotImplementedException();
        public Task<Guid?> GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId) => throw new NotImplementedException();
        public Task<IReadOnlyList<WebClubShowcaseSession>> GetClubShowcaseSessionsAsync(Guid groupId, Guid? viewerPlayerId, int page, int pageSize) => throw new NotImplementedException();
        public Task<int> GetPendingApplicationsCountAsync(Guid groupId) => throw new NotImplementedException();
        public Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId) => throw new NotImplementedException();
        public Task<List<WebMembership>> GetMembershipsForPlayerAsync(Guid playerId) => throw new NotImplementedException();
        public Task<Guid> ApplyForMembershipAsync(Guid groupId, Guid playerId, string? message) => throw new NotImplementedException();
        public Task ApproveMembershipAsync(Guid membershipId, Guid approverPlayerId) => throw new NotImplementedException();
        public Task RejectMembershipAsync(Guid membershipId, Guid approverPlayerId) => throw new NotImplementedException();
        public Task LeaveClubMembershipAsync(Guid membershipId, Guid playerId) => throw new NotImplementedException();
        public Task<Guid?> GetGroupIdForMembershipAsync(Guid membershipId) => throw new NotImplementedException();
        public Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId) => throw new NotImplementedException();
        public Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId) => throw new NotImplementedException();
        public Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId) => throw new NotImplementedException();
        public Task<WebSession?> GetSessionAsync(Guid sessionId) => throw new NotImplementedException();
        public Task<WebSessionBatch?> GetBatchAsync(Guid batchId) => throw new NotImplementedException();
        public Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers) => throw new NotImplementedException();
        public Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId) => throw new NotImplementedException();
        public Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink) => throw new NotImplementedException();
        public Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode) => throw new NotImplementedException();
        public Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays) => throw new NotImplementedException();
        public Task<WebSessionBatch> CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval) => throw new NotImplementedException();
        public Task<List<WebCampaignTemplate>> GetCampaignTemplatesAsync(Guid groupId) => throw new NotImplementedException();
        public Task<WebCampaignTemplate?> GetCampaignTemplateAsync(Guid templateId) => throw new NotImplementedException();
        public Task<WebCampaignTemplate> CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request) => throw new NotImplementedException();
        public Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId) => throw new NotImplementedException();
        public Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt) => throw new NotImplementedException();
        public Task AddGroupCoGmAsync(Guid groupId, string ownerPlatform, string ownerExternalUserId, string coGmPlatform, string coGmExternalUserId, string displayName, string? externalUsername) => throw new NotImplementedException();
        public Task RemoveGroupCoGmAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId) => throw new NotImplementedException();
        public Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId) => throw new NotImplementedException();
        public Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId) => throw new NotImplementedException();
        public Task<List<PlayerAttendanceStats>> GetGroupAttendanceStatsAsync(Guid groupId) => throw new NotImplementedException();
        public Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue) => throw new NotImplementedException();
        public Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId) => throw new NotImplementedException();
        public Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl) => throw new NotImplementedException();
        public Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId) => throw new NotImplementedException();
        public Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio) => throw new NotImplementedException();
        public Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId = null) => throw new NotImplementedException();
        public Task<List<LinkedIdentity>> GetLinkedIdentitiesAsync(string platform, string externalUserId) => throw new NotImplementedException();
        public Task LinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId, string? currentName) => throw new NotImplementedException();
        public Task UnlinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId) => throw new NotImplementedException();
        public Task UpsertPlayerAsync(string platform, string externalUserId, string displayName, string? avatarUrl) => throw new NotImplementedException();
        public Task<IReadOnlyList<GmRelay.Shared.Features.Showcase.ShowcaseSessionDto>> GetShowcaseSessionsAsync(GmRelay.Shared.Features.Showcase.ShowcaseFilter filter, int page, int pageSize) => throw new NotImplementedException();
        public Task<GmRelay.Shared.Features.Showcase.ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId) => throw new NotImplementedException();
        public Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName) => throw new NotImplementedException();
    }

    private sealed class FakePortfolioCoverStorage : IPortfolioCoverStorage
    {
        public string SaveKey { get; set; } = Guid.NewGuid().ToString("N") + ".png";
        public List<string> SavedKeys { get; } = new();
        public List<string> DeletedKeys { get; } = new();

        public Task<PortfolioCoverUploadResult> SaveAsync(Stream content, string contentType, CancellationToken cancellationToken = default)
        {
            SavedKeys.Add(SaveKey);
            return Task.FromResult(new PortfolioCoverUploadResult(SaveKey, contentType));
        }

        public Task DeleteIfExistsAsync(string storageKey, CancellationToken cancellationToken = default)
        {
            DeletedKeys.Add(storageKey);
            return Task.CompletedTask;
        }

        public string GetPublicPath(string storageKey) => "/portfolio-covers/" + storageKey;
    }
}
