using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Web.Services;
using GmRelay.Web.Services.Portfolio.Covers;
using Npgsql;
using System.Data;

namespace GmRelay.Web.Services.Portfolio;

public sealed class PortfolioService(
    NpgsqlDataSource dataSource,
    IPortfolioCoverStorage coverStorage) : IPortfolioStore
{
    // --- Public reads ---

    public async Task<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForMasterAsync(string masterSlug)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        var rows = await conn.QueryAsync<PublicCardRow>(
            """
            SELECT pg.id AS Id,
                   pg.public_slug AS Slug,
                   pg.title AS Title,
                   pg.cover_storage_key AS CoverStorageKey,
                   pg.system AS System,
                   pg.format AS Format,
                   pg.completed_at AS CompletedAt
            FROM portfolio_games pg
            JOIN portfolio_game_masters pgm ON pgm.portfolio_game_id = pg.id
            JOIN players p ON p.id = pgm.player_id
            JOIN master_profiles mp ON mp.player_id = p.id
            WHERE pg.is_public = true
              AND mp.is_public = true
              AND mp.public_slug IS NOT NULL
              AND lower(mp.public_slug) = lower(@MasterSlug)
            ORDER BY pg.completed_at DESC
            """,
            new { MasterSlug = masterSlug });

        return rows.Select(MapToPublicCard).ToList();
    }

    public async Task<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForClubAsync(string clubSlug)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        var rows = await conn.QueryAsync<PublicCardRow>(
            """
            SELECT pg.id AS Id,
                   pg.public_slug AS Slug,
                   pg.title AS Title,
                   pg.cover_storage_key AS CoverStorageKey,
                   pg.system AS System,
                   pg.format AS Format,
                   pg.completed_at AS CompletedAt
            FROM portfolio_games pg
            JOIN game_groups g ON g.id = pg.group_id
            WHERE pg.is_public = true
              AND g.public_schedule_enabled = true
              AND g.public_slug IS NOT NULL
              AND lower(g.public_slug) = lower(@ClubSlug)
            ORDER BY pg.completed_at DESC
            """,
            new { ClubSlug = clubSlug });

        return rows.Select(MapToPublicCard).ToList();
    }

    public async Task<PublicPortfolioGame?> GetPublicPortfolioGameBySlugAsync(string slug)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        var detail = await conn.QuerySingleOrDefaultAsync<PublicDetailRow>(
            """
            SELECT pg.id AS Id,
                   pg.public_slug AS Slug,
                   pg.title AS Title,
                   pg.description AS Description,
                   pg.cover_storage_key AS CoverStorageKey,
                   pg.system AS System,
                   pg.format AS Format,
                   pg.completed_at AS CompletedAt,
                   COALESCE(NULLIF(g.name, g.external_group_id), NULL) AS ClubName,
                   CASE
                       WHEN g.public_schedule_enabled = true AND g.public_slug IS NOT NULL
                           THEN g.public_slug
                       ELSE NULL
                   END AS ClubSlug
            FROM portfolio_games pg
            JOIN game_groups g ON g.id = pg.group_id
            WHERE pg.is_public = true
              AND lower(pg.public_slug) = lower(@Slug)
            """,
            new { Slug = slug });

        if (detail is null)
        {
            return null;
        }

        var masters = (await conn.QueryAsync<PublicMasterRow>(
            """
            SELECT mp.public_slug AS Slug,
                   mp.display_name AS DisplayName
            FROM portfolio_game_masters pgm
            JOIN master_profiles mp ON mp.player_id = pgm.player_id
            WHERE pgm.portfolio_game_id = @PortfolioGameId
              AND mp.is_public = true
              AND mp.public_slug IS NOT NULL
            ORDER BY mp.display_name
            """,
            new { PortfolioGameId = detail.Id })).ToList();

        var reviews = (await conn.QueryAsync<PublicReviewRow>(
            """
            SELECT r.author_display_name AS AuthorDisplayName,
                   r.body AS Body,
                   r.created_at AS CreatedAt
            FROM portfolio_game_reviews r
            WHERE r.portfolio_game_id = @PortfolioGameId
              AND r.moderation_status = 'Approved'
              AND r.publication_consent_at IS NOT NULL
            ORDER BY r.created_at DESC
            """,
            new { PortfolioGameId = detail.Id })).ToList();

        return new PublicPortfolioGame(
            detail.Slug!,
            detail.Title,
            detail.Description ?? string.Empty,
            string.IsNullOrEmpty(detail.CoverStorageKey) ? string.Empty : coverStorage.GetPublicPath(detail.CoverStorageKey),
            detail.System,
            detail.Format,
            detail.CompletedAt,
            detail.ClubSlug is null ? null : detail.ClubName,
            detail.ClubSlug,
            masters.Select(m => new PublicPortfolioMaster(m.Slug!, m.DisplayName)).ToList(),
            reviews.Select(r => new PublicPortfolioReview(r.AuthorDisplayName, r.Body, r.CreatedAt)).ToList());
    }

    // --- Protected reads ---

    public async Task<IReadOnlyList<PortfolioGameSummary>> GetPortfolioGamesForGroupAsync(Guid groupId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        var rows = await conn.QueryAsync<PortfolioGameSummaryRow>(
            """
            SELECT pg.id AS Id,
                   pg.group_id AS GroupId,
                   pg.title AS Title,
                   pg.public_slug AS PublicSlug,
                   pg.is_public AS IsPublic,
                   pg.completed_at AS CompletedAt,
                   COALESCE(session_counts.count, 0)::int AS SessionCount,
                   COALESCE(master_counts.count, 0)::int AS MasterCount,
                   COALESCE(pending_counts.count, 0)::int AS PendingReviewCount
            FROM portfolio_games pg
            LEFT JOIN LATERAL (
                SELECT COUNT(*) AS count
                FROM portfolio_game_sessions pgs
                WHERE pgs.portfolio_game_id = pg.id
            ) session_counts ON true
            LEFT JOIN LATERAL (
                SELECT COUNT(*) AS count
                FROM portfolio_game_masters pgm
                WHERE pgm.portfolio_game_id = pg.id
            ) master_counts ON true
            LEFT JOIN LATERAL (
                SELECT COUNT(*) AS count
                FROM portfolio_game_reviews r
                WHERE r.portfolio_game_id = pg.id
                  AND r.moderation_status = 'Pending'
            ) pending_counts ON true
            WHERE pg.group_id = @GroupId
            ORDER BY pg.completed_at DESC, pg.created_at DESC
            """,
            new { GroupId = groupId });

        return rows.Select(r => new PortfolioGameSummary(
            r.Id,
            r.GroupId,
            r.Title,
            r.PublicSlug,
            r.IsPublic,
            r.CompletedAt,
            r.SessionCount,
            r.MasterCount,
            r.PendingReviewCount)).ToList();
    }

    public async Task<Guid?> GetPortfolioGameGroupIdAsync(Guid portfolioGameId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        return await conn.QuerySingleOrDefaultAsync<Guid?>(
            "SELECT group_id FROM portfolio_games WHERE id = @PortfolioGameId",
            new { PortfolioGameId = portfolioGameId });
    }

    public async Task<PortfolioGameEditor?> GetPortfolioGameForManagementAsync(Guid portfolioGameId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        var header = await conn.QuerySingleOrDefaultAsync<EditorHeaderRow>(
            """
            SELECT pg.id AS Id,
                   pg.group_id AS GroupId,
                   pg.title AS Title,
                   pg.public_slug AS PublicSlug,
                   pg.description AS Description,
                   pg.cover_storage_key AS CoverStorageKey,
                   pg.system AS System,
                   pg.format AS Format,
                   pg.completed_at AS CompletedAt,
                   pg.is_public AS IsPublic
            FROM portfolio_games pg
            WHERE pg.id = @PortfolioGameId
            """,
            new { PortfolioGameId = portfolioGameId });

        if (header is null)
        {
            return null;
        }

        var sessions = (await conn.QueryAsync<SessionOptionRow>(
            """
            SELECT s.id AS Id,
                   s.title AS Title,
                   s.scheduled_at AS ScheduledAt,
                   EXISTS (
                       SELECT 1
                       FROM portfolio_game_sessions pgs
                       WHERE pgs.portfolio_game_id = @PortfolioGameId
                         AND pgs.session_id = s.id
                   ) AS Selected
            FROM sessions s
            WHERE s.group_id = @GroupId
              AND s.scheduled_at < now()
            ORDER BY s.scheduled_at DESC
            """,
            new { PortfolioGameId = header.Id, GroupId = header.GroupId })).ToList();

        var masters = (await conn.QueryAsync<MasterOptionRow>(
            """
            SELECT p.id AS PlayerId,
                   p.display_name AS DisplayName,
                   EXISTS (
                       SELECT 1
                       FROM portfolio_game_masters pgm
                       WHERE pgm.portfolio_game_id = @PortfolioGameId
                         AND pgm.player_id = p.id
                   ) AS Selected
            FROM group_managers gm
            JOIN players p ON p.id = gm.player_id
            WHERE gm.group_id = @GroupId
            ORDER BY CASE gm.role WHEN @OwnerRole THEN 0 ELSE 1 END,
                     p.display_name
            """,
            new
            {
                PortfolioGameId = header.Id,
                GroupId = header.GroupId,
                OwnerRole = GroupManagerRoleExtensions.OwnerValue
            })).ToList();

        var reviews = (await conn.QueryAsync<ModerationReviewRow>(
            """
            SELECT r.id AS Id,
                   r.author_display_name AS AuthorDisplayName,
                   r.body AS Body,
                   r.moderation_status AS ModerationStatus,
                   r.created_at AS CreatedAt
            FROM portfolio_game_reviews r
            WHERE r.portfolio_game_id = @PortfolioGameId
            ORDER BY r.created_at DESC
            """,
            new { PortfolioGameId = header.Id })).ToList();

        return new PortfolioGameEditor(
            header.Id,
            header.GroupId,
            header.Title,
            header.PublicSlug,
            header.Description,
            header.CoverStorageKey is null ? null : coverStorage.GetPublicPath(header.CoverStorageKey),
            header.System,
            header.Format,
            header.CompletedAt,
            header.IsPublic,
            sessions.Select(s => new PortfolioSessionOption(s.Id, s.Title, s.ScheduledAt, s.Selected)).ToList(),
            masters.Select(m => new PortfolioMasterOption(m.PlayerId, m.DisplayName, m.Selected)).ToList(),
            reviews.Select(r => new PortfolioReviewForModeration(r.Id, r.AuthorDisplayName, r.Body, r.ModerationStatus, r.CreatedAt)).ToList());
    }

    public async Task<IReadOnlyList<PortfolioSessionOption>> GetEligibleCompletedSessionsAsync(Guid groupId, Guid? portfolioGameId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        var rows = await conn.QueryAsync<SessionOptionRow>(
            """
            SELECT s.id AS Id,
                   s.title AS Title,
                   s.scheduled_at AS ScheduledAt,
                   COALESCE(linked.Selected, false) AS Selected
            FROM sessions s
            LEFT JOIN LATERAL (
                SELECT true AS Selected
                FROM portfolio_game_sessions pgs
                WHERE pgs.session_id = s.id
                  AND (@PortfolioGameId IS NULL OR pgs.portfolio_game_id = @PortfolioGameId)
            ) linked ON true
            WHERE s.group_id = @GroupId
              AND s.scheduled_at < now()
            ORDER BY s.scheduled_at DESC
            """,
            new { GroupId = groupId, PortfolioGameId = portfolioGameId });

        return rows.Select(s => new PortfolioSessionOption(s.Id, s.Title, s.ScheduledAt, s.Selected)).ToList();
    }

    public async Task<IReadOnlyList<PortfolioMasterOption>> GetPortfolioMasterOptionsAsync(Guid groupId, Guid? portfolioGameId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        var rows = await conn.QueryAsync<MasterOptionRow>(
            """
            SELECT p.id AS PlayerId,
                   p.display_name AS DisplayName,
                   COALESCE(linked.Selected, false) AS Selected
            FROM group_managers gm
            JOIN players p ON p.id = gm.player_id
            LEFT JOIN LATERAL (
                SELECT true AS Selected
                FROM portfolio_game_masters pgm
                WHERE pgm.player_id = p.id
                  AND (@PortfolioGameId IS NULL OR pgm.portfolio_game_id = @PortfolioGameId)
            ) linked ON true
            WHERE gm.group_id = @GroupId
            ORDER BY CASE gm.role WHEN @OwnerRole THEN 0 ELSE 1 END,
                     p.display_name
            """,
            new
            {
                GroupId = groupId,
                PortfolioGameId = portfolioGameId,
                OwnerRole = GroupManagerRoleExtensions.OwnerValue
            });

        return rows.Select(m => new PortfolioMasterOption(m.PlayerId, m.DisplayName, m.Selected)).ToList();
    }

    // --- Protected writes ---

    public async Task<Guid> CreatePortfolioDraftAsync(Guid groupId, Guid? preselectedSessionId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        await using var transaction = await conn.BeginTransactionAsync();
        await conn.ExecuteAsync("SELECT pg_advisory_xact_lock(20260530, 108)", transaction: transaction);

        var newId = await conn.ExecuteScalarAsync<Guid>(
            """
            INSERT INTO portfolio_games (group_id, title)
            VALUES (@GroupId, 'New adventure')
            RETURNING id
            """,
            new { GroupId = groupId },
            transaction);

        if (preselectedSessionId is not null)
        {
            await conn.ExecuteAsync(
                """
                INSERT INTO portfolio_game_sessions (portfolio_game_id, session_id)
                SELECT @PortfolioGameId, s.id
                FROM sessions s
                WHERE s.id = @SessionId
                  AND s.group_id = @GroupId
                  AND s.scheduled_at < now()
                  AND NOT EXISTS (
                      SELECT 1 FROM portfolio_game_sessions pgs WHERE pgs.session_id = s.id
                  )
                """,
                new
                {
                    PortfolioGameId = newId,
                    SessionId = preselectedSessionId.Value,
                    GroupId = groupId
                },
                transaction);
        }

        await transaction.CommitAsync();
        return newId;
    }

    public async Task UpdatePortfolioDraftAsync(Guid portfolioGameId, Guid groupId, PortfolioGameUpdate update)
    {
        var title = PortfolioValidation.NormalizeTitle(update.Title);
        var slug = string.IsNullOrWhiteSpace(update.PublicSlug) ? null : PortfolioValidation.NormalizeSlug(update.PublicSlug);
        var description = PortfolioValidation.NormalizeDescription(update.Description);
        var system = string.IsNullOrWhiteSpace(update.System) ? null : update.System.Trim();
        var format = PortfolioValidation.NormalizeFormat(update.Format);

        var sessionIds = update.SessionIds?.Distinct().ToArray() ?? Array.Empty<Guid>();
        var masterPlayerIds = update.MasterPlayerIds?.Distinct().ToArray() ?? Array.Empty<Guid>();

        await using var conn = await dataSource.OpenConnectionAsync();
        await using var transaction = await conn.BeginTransactionAsync();

        await conn.ExecuteAsync("SELECT pg_advisory_xact_lock(20260530, 108)", transaction: transaction);

        var existing = await conn.QuerySingleOrDefaultAsync<EditorHeaderRow>(
            """
            SELECT pg.id AS Id,
                   pg.group_id AS GroupId,
                   pg.title AS Title,
                   pg.public_slug AS PublicSlug,
                   pg.description AS Description,
                   pg.cover_storage_key AS CoverStorageKey,
                   pg.system AS System,
                   pg.format AS Format,
                   pg.completed_at AS CompletedAt,
                   pg.is_public AS IsPublic
            FROM portfolio_games pg
            WHERE pg.id = @PortfolioGameId
              AND pg.group_id = @GroupId
            FOR UPDATE OF pg
            """,
            new { PortfolioGameId = portfolioGameId, GroupId = groupId },
            transaction);

        if (existing is null)
        {
            throw new InvalidOperationException("Portfolio game not found in this group.");
        }

        try
        {
            await conn.ExecuteAsync(
                """
                UPDATE portfolio_games pg
                SET title = @Title,
                    public_slug = @PublicSlug,
                    description = @Description,
                    system = @System,
                    format = @Format,
                    updated_at = now()
                WHERE pg.id = @PortfolioGameId
                """,
                new
                {
                    PortfolioGameId = portfolioGameId,
                    Title = title,
                    PublicSlug = slug,
                    Description = description,
                    System = system,
                    Format = format
                },
                transaction);
        }
        catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UniqueViolation)
        {
            throw new InvalidOperationException("Public slug is already in use.", ex);
        }

        // Unpublish before replacing required child links so the deferred validator never sees a
        // public card without a session/master. The trigger acquires the same advisory lock.
        await conn.ExecuteAsync(
            """
            UPDATE portfolio_games pg
            SET is_public = false,
                updated_at = now()
            WHERE pg.id = @PortfolioGameId
              AND pg.is_public = true
            """,
            new { PortfolioGameId = portfolioGameId },
            transaction);

        if (sessionIds.Length > 0)
        {
            var validatedSessions = (await conn.QueryAsync<Guid>(
                """
                SELECT s.id
                FROM sessions s
                WHERE s.id = ANY(@SessionIds)
                  AND s.group_id = @GroupId
                  AND s.scheduled_at < now()
                """,
                new { SessionIds = sessionIds, GroupId = groupId },
                transaction)).ToHashSet();

            if (validatedSessions.Count != sessionIds.Length)
            {
                throw new InvalidOperationException("All linked sessions must belong to the same group and be in the past.");
            }

            await conn.ExecuteAsync(
                "DELETE FROM portfolio_game_sessions WHERE portfolio_game_id = @PortfolioGameId",
                new { PortfolioGameId = portfolioGameId },
                transaction);

            await conn.ExecuteAsync(
                """
                INSERT INTO portfolio_game_sessions (portfolio_game_id, session_id)
                SELECT @PortfolioGameId, UNNEST(@SessionIds::uuid[])
                """,
                new { PortfolioGameId = portfolioGameId, SessionIds = sessionIds },
                transaction);
        }
        else
        {
            await conn.ExecuteAsync(
                "DELETE FROM portfolio_game_sessions WHERE portfolio_game_id = @PortfolioGameId",
                new { PortfolioGameId = portfolioGameId },
                transaction);
        }

        if (masterPlayerIds.Length > 0)
        {
            var validatedMasters = (await conn.QueryAsync<Guid>(
                """
                SELECT p.id
                FROM players p
                JOIN group_managers gm ON gm.player_id = p.id
                WHERE p.id = ANY(@PlayerIds)
                  AND gm.group_id = @GroupId
                """,
                new { PlayerIds = masterPlayerIds, GroupId = groupId },
                transaction)).ToHashSet();

            if (validatedMasters.Count != masterPlayerIds.Length)
            {
                throw new InvalidOperationException("All masters must be managers of the same group.");
            }

            await conn.ExecuteAsync(
                "DELETE FROM portfolio_game_masters WHERE portfolio_game_id = @PortfolioGameId",
                new { PortfolioGameId = portfolioGameId },
                transaction);

            await conn.ExecuteAsync(
                """
                INSERT INTO portfolio_game_masters (portfolio_game_id, player_id)
                SELECT @PortfolioGameId, UNNEST(@PlayerIds::uuid[])
                """,
                new { PortfolioGameId = portfolioGameId, PlayerIds = masterPlayerIds },
                transaction);
        }
        else
        {
            await conn.ExecuteAsync(
                "DELETE FROM portfolio_game_masters WHERE portfolio_game_id = @PortfolioGameId",
                new { PortfolioGameId = portfolioGameId },
                transaction);
        }

        await transaction.CommitAsync();
    }

    public async Task<string?> SetPortfolioCoverAsync(Guid portfolioGameId, Guid groupId, string storageKey)
    {
        if (string.IsNullOrWhiteSpace(storageKey))
        {
            throw new InvalidOperationException("Cover storage key must not be empty.");
        }

        await using var conn = await dataSource.OpenConnectionAsync();
        await using var transaction = await conn.BeginTransactionAsync();

        await conn.ExecuteAsync("SELECT pg_advisory_xact_lock(20260530, 108)", transaction: transaction);

        var priorKey = await conn.QuerySingleOrDefaultAsync<string?>(
            """
            SELECT cover_storage_key
            FROM portfolio_games pg
            WHERE pg.id = @PortfolioGameId
              AND pg.group_id = @GroupId
            FOR UPDATE OF pg
            """,
            new { PortfolioGameId = portfolioGameId, GroupId = groupId },
            transaction);

        if (priorKey is null)
        {
            // Could be NULL cover key on the row. Distinguish "missing" from "null cover".
            var rowExists = await conn.ExecuteScalarAsync<bool>(
                """
                SELECT EXISTS (
                    SELECT 1 FROM portfolio_games WHERE id = @PortfolioGameId AND group_id = @GroupId
                )
                """,
                new { PortfolioGameId = portfolioGameId, GroupId = groupId },
                transaction);
            if (!rowExists)
            {
                throw new InvalidOperationException("Portfolio game not found in this group.");
            }
        }

        await conn.ExecuteAsync(
            """
            UPDATE portfolio_games pg
            SET cover_storage_key = @StorageKey,
                updated_at = now()
            WHERE pg.id = @PortfolioGameId
              AND pg.group_id = @GroupId
            """,
            new { PortfolioGameId = portfolioGameId, GroupId = groupId, StorageKey = storageKey },
            transaction);

        await transaction.CommitAsync();
        return priorKey;
    }

    public async Task<string?> DeletePortfolioGameAsync(Guid portfolioGameId, Guid groupId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        await using var transaction = await conn.BeginTransactionAsync();

        await conn.ExecuteAsync("SELECT pg_advisory_xact_lock(20260530, 108)", transaction: transaction);

        var coverKey = await conn.QuerySingleOrDefaultAsync<string?>(
            """
            SELECT cover_storage_key
            FROM portfolio_games
            WHERE id = @PortfolioGameId
              AND group_id = @GroupId
            FOR UPDATE
            """,
            new { PortfolioGameId = portfolioGameId, GroupId = groupId },
            transaction);

        if (coverKey is null)
        {
            // Could be NULL cover key on the row. Distinguish "missing" from "null cover".
            var rowExists = await conn.ExecuteScalarAsync<bool>(
                """
                SELECT EXISTS (
                    SELECT 1 FROM portfolio_games WHERE id = @PortfolioGameId AND group_id = @GroupId
                )
                """,
                new { PortfolioGameId = portfolioGameId, GroupId = groupId },
                transaction);
            if (!rowExists)
            {
                throw new InvalidOperationException("Portfolio game not found in this group.");
            }
        }

        await conn.ExecuteAsync(
            "DELETE FROM portfolio_games WHERE id = @PortfolioGameId AND group_id = @GroupId",
            new { PortfolioGameId = portfolioGameId, GroupId = groupId },
            transaction);

        await transaction.CommitAsync();
        return coverKey;
    }

    public async Task SetPortfolioPublicationAsync(Guid portfolioGameId, Guid groupId, bool isPublic)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        await using var transaction = await conn.BeginTransactionAsync();

        await conn.ExecuteAsync("SELECT pg_advisory_xact_lock(20260530, 108)", transaction: transaction);

        var row = await conn.QuerySingleOrDefaultAsync<EditorHeaderRow>(
            """
            SELECT pg.id AS Id,
                   pg.group_id AS GroupId,
                   pg.title AS Title,
                   pg.public_slug AS PublicSlug,
                   pg.description AS Description,
                   pg.cover_storage_key AS CoverStorageKey,
                   pg.system AS System,
                   pg.format AS Format,
                   pg.completed_at AS CompletedAt,
                   pg.is_public AS IsPublic
            FROM portfolio_games pg
            WHERE pg.id = @PortfolioGameId
              AND pg.group_id = @GroupId
            FOR UPDATE OF pg
            """,
            new { PortfolioGameId = portfolioGameId, GroupId = groupId },
            transaction);

        if (row is null)
        {
            throw new InvalidOperationException("Portfolio game not found in this group.");
        }

        if (!isPublic)
        {
            await conn.ExecuteAsync(
                """
                UPDATE portfolio_games pg
                SET is_public = false,
                    updated_at = now()
                WHERE pg.id = @PortfolioGameId
                """,
                new { PortfolioGameId = portfolioGameId },
                transaction);

            await transaction.CommitAsync();
            return;
        }

        if (string.IsNullOrWhiteSpace(row.PublicSlug))
        {
            throw new InvalidOperationException("Public slug is required before publishing.");
        }
        if (string.IsNullOrWhiteSpace(row.Description))
        {
            throw new InvalidOperationException("Description is required before publishing.");
        }
        if (string.IsNullOrWhiteSpace(row.CoverStorageKey))
        {
            throw new InvalidOperationException("Cover image is required before publishing.");
        }

        var sessionCount = await conn.ExecuteScalarAsync<int>(
            "SELECT COUNT(*) FROM portfolio_game_sessions WHERE portfolio_game_id = @PortfolioGameId",
            new { PortfolioGameId = portfolioGameId },
            transaction);
        if (sessionCount == 0)
        {
            throw new InvalidOperationException("At least one linked session is required before publishing.");
        }

        var futureSessionCount = await conn.ExecuteScalarAsync<int>(
            """
            SELECT COUNT(*)
            FROM portfolio_game_sessions pgs
            JOIN sessions s ON s.id = pgs.session_id
            WHERE pgs.portfolio_game_id = @PortfolioGameId
              AND s.scheduled_at < now()
            """,
            new { PortfolioGameId = portfolioGameId },
            transaction);
        if (futureSessionCount != sessionCount)
        {
            throw new InvalidOperationException("Every linked session must already be in the past before publishing.");
        }

        var masterCount = await conn.ExecuteScalarAsync<int>(
            "SELECT COUNT(*) FROM portfolio_game_masters WHERE portfolio_game_id = @PortfolioGameId",
            new { PortfolioGameId = portfolioGameId },
            transaction);
        if (masterCount == 0)
        {
            throw new InvalidOperationException("At least one master is required before publishing.");
        }

        await conn.ExecuteAsync(
            """
            UPDATE portfolio_games pg
            SET is_public = true,
                published_at = COALESCE(pg.published_at, now()),
                updated_at = now()
            WHERE pg.id = @PortfolioGameId
            """,
            new { PortfolioGameId = portfolioGameId },
            transaction);

        await transaction.CommitAsync();
    }

    public async Task ModeratePortfolioReviewAsync(
        Guid reviewId,
        Guid portfolioGameId,
        Guid groupId,
        Guid moderatorPlayerId,
        string moderationStatus)
    {
        if (moderationStatus is not "Approved" and not "Rejected" and not "Hidden")
        {
            throw new InvalidOperationException("Moderation status must be Approved, Rejected, or Hidden.");
        }

        await using var conn = await dataSource.OpenConnectionAsync();
        await using var transaction = await conn.BeginTransactionAsync();

        await conn.ExecuteAsync("SELECT pg_advisory_xact_lock(20260530, 108)", transaction: transaction);

        var updated = await conn.ExecuteAsync(
            """
            UPDATE portfolio_game_reviews r
            SET moderation_status = @ModerationStatus,
                moderated_by_player_id = @ModeratorPlayerId,
                moderated_at = now(),
                updated_at = now()
            FROM portfolio_games pg
            WHERE r.id = @ReviewId
              AND r.portfolio_game_id = pg.id
              AND pg.id = @PortfolioGameId
              AND pg.group_id = @GroupId
            """,
            new
            {
                ReviewId = reviewId,
                PortfolioGameId = portfolioGameId,
                GroupId = groupId,
                ModeratorPlayerId = moderatorPlayerId,
                ModerationStatus = moderationStatus
            },
            transaction);

        if (updated == 0)
        {
            throw new InvalidOperationException("Review not found in the specified portfolio game.");
        }

        await transaction.CommitAsync();
    }

    // --- Review submission ---

    public async Task<PortfolioReviewSubmissionState> GetReviewSubmissionStateAsync(string slug, string platform, string externalUserId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        var playerIds = await ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId);
        if (playerIds.Length == 0)
        {
            return PortfolioReviewSubmissionState.RequiresAuthentication;
        }

        var portfolioGameId = await conn.QuerySingleOrDefaultAsync<Guid?>(
            """
            SELECT pg.id
            FROM portfolio_games pg
            WHERE pg.is_public = true
              AND lower(pg.public_slug) = lower(@Slug)
            """,
            new { Slug = slug });

        if (portfolioGameId is null)
        {
            return PortfolioReviewSubmissionState.Ineligible;
        }

        var existing = await conn.ExecuteScalarAsync<bool>(
            """
            SELECT EXISTS (
                SELECT 1
                FROM portfolio_game_reviews r
                WHERE r.portfolio_game_id = @PortfolioGameId
                  AND r.author_player_id = ANY(@PlayerIds)
            )
            """,
            new { PortfolioGameId = portfolioGameId.Value, PlayerIds = playerIds });
        if (existing)
        {
            return PortfolioReviewSubmissionState.AlreadySubmitted;
        }

        var eligible = await conn.ExecuteScalarAsync<bool>(
            """
            SELECT EXISTS (
                SELECT 1
                FROM portfolio_game_sessions pgs
                JOIN session_participants sp ON sp.session_id = pgs.session_id
                WHERE pgs.portfolio_game_id = @PortfolioGameId
                  AND sp.player_id = ANY(@PlayerIds)
                  AND sp.is_gm = false
                  AND sp.registration_status = @Active
                  AND EXISTS (
                      SELECT 1 FROM sessions s
                      WHERE s.id = pgs.session_id
                        AND s.scheduled_at < now()
                  )
            )
            """,
            new
            {
                PortfolioGameId = portfolioGameId.Value,
                PlayerIds = playerIds,
                Active = ParticipantRegistrationStatus.Active
            });

        return eligible
            ? PortfolioReviewSubmissionState.Eligible
            : PortfolioReviewSubmissionState.Ineligible;
    }

    public async Task SubmitPortfolioReviewAsync(
        string slug,
        string platform,
        string externalUserId,
        string displayName,
        string body)
    {
        var normalizedBody = PortfolioValidation.NormalizeReviewBody(body);
        var normalizedName = (displayName ?? string.Empty).Trim();
        if (normalizedName.Length == 0)
        {
            throw new InvalidOperationException("Display name is required.");
        }
        if (normalizedName.Length > 255)
        {
            throw new InvalidOperationException("Display name is too long.");
        }

        await using var conn = await dataSource.OpenConnectionAsync();
        var playerIds = await ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId);
        if (playerIds.Length == 0)
        {
            throw new InvalidOperationException("Authenticated player not found.");
        }

        var portfolioGameId = await conn.QuerySingleOrDefaultAsync<Guid?>(
            """
            SELECT pg.id
            FROM portfolio_games pg
            WHERE pg.is_public = true
              AND lower(pg.public_slug) = lower(@Slug)
            """,
            new { Slug = slug });

        if (portfolioGameId is null)
        {
            throw new InvalidOperationException("Public portfolio game not found.");
        }

        var eligible = await conn.ExecuteScalarAsync<bool>(
            """
            SELECT EXISTS (
                SELECT 1
                FROM portfolio_game_sessions pgs
                JOIN session_participants sp ON sp.session_id = pgs.session_id
                WHERE pgs.portfolio_game_id = @PortfolioGameId
                  AND sp.player_id = ANY(@PlayerIds)
                  AND sp.is_gm = false
                  AND sp.registration_status = @Active
                  AND EXISTS (
                      SELECT 1 FROM sessions s
                      WHERE s.id = pgs.session_id
                        AND s.scheduled_at < now()
                  )
            )
            """,
            new
            {
                PortfolioGameId = portfolioGameId.Value,
                PlayerIds = playerIds,
                Active = ParticipantRegistrationStatus.Active
            });

        if (!eligible)
        {
            throw new InvalidOperationException("Only past participants of a linked session can submit a review.");
        }

        var existing = await conn.ExecuteScalarAsync<bool>(
            """
            SELECT EXISTS (
                SELECT 1
                FROM portfolio_game_reviews r
                WHERE r.portfolio_game_id = @PortfolioGameId
                  AND r.author_player_id = ANY(@PlayerIds)
            )
            """,
            new { PortfolioGameId = portfolioGameId.Value, PlayerIds = playerIds });
        if (existing)
        {
            throw new InvalidOperationException("You have already submitted a review for this adventure.");
        }

        var effectiveId = await ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
        if (effectiveId is null)
        {
            throw new InvalidOperationException("Authenticated player not found.");
        }

        await using var transaction = await conn.BeginTransactionAsync();
        await conn.ExecuteAsync("SELECT pg_advisory_xact_lock(20260530, 108)", transaction: transaction);

        await conn.ExecuteAsync(
            """
            INSERT INTO portfolio_game_reviews
                (portfolio_game_id, author_player_id, author_display_name, body, publication_consent_at, moderation_status)
            VALUES
                (@PortfolioGameId, @AuthorPlayerId, @AuthorDisplayName, @Body, now(), 'Pending')
            ON CONFLICT (portfolio_game_id, author_player_id) DO NOTHING
            """,
            new
            {
                PortfolioGameId = portfolioGameId.Value,
                AuthorPlayerId = effectiveId.Value,
                AuthorDisplayName = normalizedName,
                Body = normalizedBody
            },
            transaction);

        await transaction.CommitAsync();
    }

    // --- Internal helpers ---

    private static async Task<Guid?> ResolvePlayerIdAsync(NpgsqlConnection conn, string platform, string externalUserId)
    {
        return await conn.QuerySingleOrDefaultAsync<Guid?>(
            "SELECT id FROM players WHERE platform = @Platform AND external_user_id = @ExternalUserId",
            new { Platform = platform, ExternalUserId = externalUserId });
    }

    private static async Task<Guid?> ResolveEffectivePlayerIdAsync(NpgsqlConnection conn, string platform, string externalUserId)
    {
        var playerId = await ResolvePlayerIdAsync(conn, platform, externalUserId);
        if (playerId is null)
        {
            return null;
        }

        var primaryId = await conn.QuerySingleOrDefaultAsync<Guid?>(
            """
            SELECT primary_player_id
            FROM player_links
            WHERE secondary_player_id = @PlayerId
            """,
            new { PlayerId = playerId.Value });

        return primaryId ?? playerId;
    }

    private static async Task<Guid[]> ResolveLinkedPlayerIdsAsync(NpgsqlConnection conn, string platform, string externalUserId)
    {
        var effectiveId = await ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
        if (effectiveId is null)
        {
            return [];
        }

        return (await conn.QueryAsync<Guid>(
            """
            SELECT @EffectiveId
            UNION
            SELECT secondary_player_id
            FROM player_links
            WHERE primary_player_id = @EffectiveId
            """,
            new { EffectiveId = effectiveId.Value })).ToArray();
    }

    private PublicPortfolioCard MapToPublicCard(PublicCardRow row)
    {
        return new PublicPortfolioCard(
            row.Slug ?? string.Empty,
            row.Title,
            string.IsNullOrEmpty(row.CoverStorageKey) ? string.Empty : coverStorage.GetPublicPath(row.CoverStorageKey),
            row.System,
            row.Format,
            row.CompletedAt);
    }

    // --- Internal DTOs (Dapper row shapes) ---

    private sealed record PublicCardRow(
        Guid Id,
        string? Slug,
        string Title,
        string? CoverStorageKey,
        string? System,
        string? Format,
        DateTime CompletedAt);

    private sealed record PublicDetailRow(
        Guid Id,
        string? Slug,
        string Title,
        string? Description,
        string? CoverStorageKey,
        string? System,
        string? Format,
        DateTime CompletedAt,
        string? ClubName,
        string? ClubSlug);

    private sealed record PublicMasterRow(string? Slug, string DisplayName);

    private sealed record PublicReviewRow(string AuthorDisplayName, string Body, DateTime CreatedAt);

    private sealed record PortfolioGameSummaryRow(
        Guid Id,
        Guid GroupId,
        string Title,
        string? PublicSlug,
        bool IsPublic,
        DateTime CompletedAt,
        int SessionCount,
        int MasterCount,
        int PendingReviewCount);

    private sealed record EditorHeaderRow(
        Guid Id,
        Guid GroupId,
        string Title,
        string? PublicSlug,
        string? Description,
        string? CoverStorageKey,
        string? System,
        string? Format,
        DateTime CompletedAt,
        bool IsPublic);

    private sealed record SessionOptionRow(Guid Id, string Title, DateTime ScheduledAt, bool Selected);

    private sealed record MasterOptionRow(Guid PlayerId, string DisplayName, bool Selected);

    private sealed record ModerationReviewRow(
        Guid Id,
        string AuthorDisplayName,
        string Body,
        string ModerationStatus,
        DateTime CreatedAt);
}
