using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Showcase;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Exceptions;
using GmRelay.Web.Services;

namespace GmRelay.Web.Services;

public sealed record WebGameGroup(
    Guid Id,
    long TelegramChatId,
    string? ExternalGroupId,
    string Name,
    string? Platform,
    string ManagerRole = GroupManagerRoleExtensions.OwnerValue)
{
    public long GmTelegramId { get; init; }

    public WebGameGroup(Guid id, long telegramChatId, string name, long gmTelegramId)
        : this(id, telegramChatId, null, name, null)
    {
        GmTelegramId = gmTelegramId;
    }
}

public sealed record WebGroupManager(
    long TelegramId,
    string? ExternalUserId,
    string? Platform,
    string DisplayName,
    string? TelegramUsername,
    string? ExternalUsername,
    string Role,
    DateTime AddedAt)
{
    public WebGroupManager(long telegramId, string displayName, string? telegramUsername, string role, DateTime addedAt)
        : this(telegramId, null, null, displayName, telegramUsername, null, role, addedAt) { }

    public WebGroupManager(
        long telegramId,
        string? externalUserId,
        string displayName,
        string? telegramUsername,
        string? externalUsername,
        string role,
        DateTime addedAt)
        : this(telegramId, externalUserId, null, displayName, telegramUsername, externalUsername, role, addedAt) { }
}

public sealed record WebGroupManagement(
    WebGameGroup Group,
    IReadOnlyList<WebGroupManager> Managers,
    bool CurrentUserIsOwner);
public sealed record WebSession(
    Guid Id,
    Guid GroupId,
    string Title,
    DateTime ScheduledAt,
    string Status,
    string JoinLink,
    Guid BatchId,
    int? BatchMessageId,
    long TelegramChatId,
    int? MaxPlayers,
    int ActivePlayerCount,
    int WaitlistedPlayerCount,
    string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue,
    int? ThreadId = null,
    string PublicationMode = PublicationModeExtensions.NoneValue)
{
    public bool IsPublic
    {
        get
        {
            var mode = PublicationModeExtensions.FromDatabaseValue(PublicationMode);
            return mode == GmRelay.Shared.Domain.PublicationMode.Catalog || mode == GmRelay.Shared.Domain.PublicationMode.Both;
        }
    }

    public bool IsMembersOnly => PublicationModeExtensions.FromDatabaseValue(PublicationMode) == GmRelay.Shared.Domain.PublicationMode.ClubOnly;
}

public sealed record WebParticipant(
    Guid Id,
    long TelegramId,
    string? ExternalUserId,
    string DisplayName,
    string? TelegramUsername,
    string? ExternalUsername,
    string RsvpStatus,
    string RegistrationStatus,
    bool IsGm,
    DateTime? RespondedAt);

internal sealed record WebPromotedParticipantDto(Guid ParticipantRowId, string DisplayName);
internal sealed record WebDirectNotificationRecipient(long TelegramId, string DisplayName);
internal sealed record WebBatchInfo(
    Guid BatchId,
    Guid GroupId,
    string Title,
    string JoinLink,
    long TelegramChatId,
    int? BatchMessageId,
    int? ThreadId,
    string NotificationMode);

internal sealed record WebBatchSessionRow(
    Guid Id,
    Guid GroupId,
    string Title,
    string JoinLink,
    DateTime ScheduledAt,
    string Status,
    int? MaxPlayers,
    int? BatchMessageId,
    long TelegramChatId,
    int? ThreadId,
    string NotificationMode,
    bool TopicCreatedByBot = false);
internal sealed record WebTemplateGroupDto(long TelegramChatId);
internal sealed record WebTemplateTopicDestination(int? MessageThreadId, bool TopicCreatedByBot);
internal sealed record WebPublicGroupRow(
    Guid GroupId,
    string Name,
    string Slug,
    string? MasterProfileSlug,
    string? MasterDisplayName);
internal sealed record ShowcaseSessionRow(
    Guid Id,
    Guid GroupId,
    string GroupName,
    string? GroupSlug,
    string Title,
    DateTime ScheduledAt,
    string Status,
    string? System,
    bool IsOneShot,
    string? Format,
    int? DurationMinutes,
    string? CoverImageUrl,
    int? MaxPlayers,
    int ActivePlayerCount,
    int WaitlistedPlayerCount,
    bool AllowDirectRegistration,
    string? Description,
    string? MasterProfileSlug,
    string? MasterDisplayName,
    string PublicationMode = "None",
    bool IsMembersOnly = false);
internal sealed record PublicMasterProfileRow(Guid PlayerId, string Slug, string DisplayName, string? Bio);

public sealed class SessionService(
    NpgsqlDataSource dataSource,
    ITelegramBotClient bot,
    ILogger<SessionService> logger) : ISessionStore
{
    public async Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        var playerIds = await _ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId);
        if (playerIds.Length == 0)
            return [];

        return (await conn.QueryAsync<WebGameGroup>(
            """
            WITH visible_groups AS (
                SELECT gm.group_id,
                       CASE
                           WHEN bool_or(gm.role = @OwnerRole) THEN @OwnerRole
                           ELSE @CoGmRole
                       END AS ManagerRole
                FROM group_managers gm
                WHERE gm.player_id = ANY(@PlayerIds)
                GROUP BY gm.group_id
            )
            SELECT g.id,
                   g.external_group_id::BIGINT AS TelegramChatId,
                   g.external_group_id AS ExternalGroupId,
                   COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS Name,
                   g.platform AS Platform,
                   vg.ManagerRole
            FROM visible_groups vg
            JOIN game_groups g ON g.id = vg.group_id
            LEFT JOIN LATERAL (
                SELECT s.title
                FROM sessions s
                WHERE s.group_id = g.id
                ORDER BY s.scheduled_at DESC
                LIMIT 1
            ) latest_session ON true
            ORDER BY g.name
            """,
            new
            {
                PlayerIds = playerIds,
                OwnerRole = GroupManagerRoleExtensions.OwnerValue,
                CoGmRole = GroupManagerRoleExtensions.CoGmValue
            })).ToList();
    }

    public async Task<WebGameGroup?> GetGroupAsync(Guid groupId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        return await conn.QuerySingleOrDefaultAsync<WebGameGroup>(
            """
            SELECT g.id,
                   g.external_group_id::BIGINT AS TelegramChatId,
                   g.external_group_id AS ExternalGroupId,
                   COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS Name,
                   g.platform AS Platform,
                   @OwnerRole AS ManagerRole
            FROM game_groups g
            LEFT JOIN LATERAL (
                SELECT s.title
                FROM sessions s
                WHERE s.group_id = g.id
                ORDER BY s.scheduled_at DESC
                LIMIT 1
            ) latest_session ON true
            WHERE g.id = @GroupId
            """,
            new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
    }

    public async Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        return await conn.QuerySingleOrDefaultAsync<WebPublicGroupSettings>(
            """
            SELECT g.id AS GroupId,
                   COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS GroupName,
                   g.public_slug AS PublicSlug,
                   g.public_schedule_enabled AS PublicScheduleEnabled,
                   COALESCE(public_counts.count, 0)::int AS PublicSessionCount
            FROM game_groups g
            LEFT JOIN LATERAL (
                SELECT s.title
                FROM sessions s
                WHERE s.group_id = g.id
                ORDER BY s.scheduled_at DESC
                LIMIT 1
            ) latest_session ON true
            LEFT JOIN LATERAL (
                SELECT COUNT(*) AS count
                FROM sessions s
                WHERE s.group_id = g.id
                  AND s.publication_mode IN ('Catalog', 'Both')
            ) public_counts ON true
            WHERE g.id = @GroupId
            """,
            new { GroupId = groupId });
    }

    public async Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        try
        {
            await conn.ExecuteAsync(
                """
                UPDATE game_groups
                SET public_slug = @PublicSlug,
                    public_schedule_enabled = @PublicScheduleEnabled,
                    public_schedule_updated_at = now()
                WHERE id = @GroupId
                """,
                new
                {
                    GroupId = groupId,
                    PublicSlug = string.IsNullOrWhiteSpace(publicSlug) ? null : publicSlug,
                    PublicScheduleEnabled = publicScheduleEnabled
                });
        }
        catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UniqueViolation)
        {
            throw new InvalidOperationException("Public slug is already in use.", ex);
        }
    }

    public async Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        var updatedRows = await conn.ExecuteAsync(
            """
            UPDATE sessions
            SET publication_mode = @Mode,
                updated_at = now()
            WHERE id = @SessionId
              AND group_id = @GroupId
            """,
            new { SessionId = sessionId, GroupId = groupId, Mode = mode.ToDatabaseValue() });

        if (updatedRows == 0)
        {
            throw new SessionAccessDeniedException(sessionId, "0");
        }
    }

    public async Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        var updatedRows = await conn.ExecuteAsync(
            """
            UPDATE sessions
            SET publication_mode = @Mode,
                updated_at = now()
            WHERE batch_id = @BatchId
              AND group_id = @GroupId
            """,
            new { BatchId = batchId, GroupId = groupId, Mode = mode.ToDatabaseValue() });

        if (updatedRows == 0)
        {
            throw new SessionAccessDeniedException(batchId, "0");
        }
    }

    public async Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        var group = await conn.QuerySingleOrDefaultAsync<WebPublicGroupRow>(
            """
            SELECT g.id AS GroupId,
                   COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS Name,
                   g.public_slug AS Slug,
                   mp.public_slug AS MasterProfileSlug,
                   mp.display_name AS MasterDisplayName
            FROM game_groups g
            LEFT JOIN LATERAL (
                SELECT s.title
                FROM sessions s
                WHERE s.group_id = g.id
                ORDER BY s.scheduled_at DESC
                LIMIT 1
            ) latest_session ON true
            LEFT JOIN LATERAL (
                SELECT gm.player_id
                FROM group_managers gm
                WHERE gm.group_id = g.id
                  AND gm.role = @OwnerRole
                ORDER BY gm.created_at
                LIMIT 1
            ) owner_manager ON true
            LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
            LEFT JOIN master_profiles mp ON mp.player_id = COALESCE(owner_link.primary_player_id, owner_manager.player_id)
                AND mp.is_public = true
                AND mp.public_slug IS NOT NULL
            WHERE g.public_schedule_enabled = true
              AND g.public_slug IS NOT NULL
              AND lower(g.public_slug) = lower(@Slug)
            """,
            new { Slug = slug, OwnerRole = GroupManagerRoleExtensions.OwnerValue });

        if (group is null)
        {
            return null;
        }

        var sessions = await GetPublicSessionsForGroupAsync(conn, group.GroupId, viewerPlayerId);
        return new WebPublicClub(group.GroupId, group.Name, group.Slug, sessions, group.MasterProfileSlug, group.MasterDisplayName);
    }

    public async Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        return await conn.QuerySingleOrDefaultAsync<WebPublicSession>(
            """
            SELECT s.id AS Id,
                   s.group_id AS GroupId,
                   COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS GroupName,
                   g.public_slug AS GroupSlug,
                   s.title AS Title,
                   s.scheduled_at AS ScheduledAt,
                   s.status AS Status,
                   s.max_players AS MaxPlayers,
                   COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
                   COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
                   s.publication_mode AS PublicationMode,
                   (s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
                   mp.public_slug AS MasterProfileSlug,
                   mp.display_name AS MasterDisplayName
            FROM sessions s
            JOIN game_groups g ON g.id = s.group_id
            LEFT JOIN LATERAL (
                SELECT recent.title
                FROM sessions recent
                WHERE recent.group_id = g.id
                ORDER BY recent.scheduled_at DESC
                LIMIT 1
            ) latest_session ON true
            LEFT JOIN LATERAL (
                SELECT COUNT(*) AS count
                FROM session_participants sp
                WHERE sp.session_id = s.id
                  AND sp.is_gm = false
                  AND sp.registration_status = @Active
            ) active_counts ON true
            LEFT JOIN LATERAL (
                SELECT COUNT(*) AS count
                FROM session_participants sp
                WHERE sp.session_id = s.id
                  AND sp.is_gm = false
                  AND sp.registration_status = @Waitlisted
            ) waitlist_counts ON true
            LEFT JOIN LATERAL (
                SELECT gm.player_id
                FROM group_managers gm
                WHERE gm.group_id = g.id
                  AND gm.role = @OwnerRole
                ORDER BY gm.created_at
                LIMIT 1
            ) owner_manager ON true
            LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
            LEFT JOIN master_profiles mp ON mp.player_id = COALESCE(owner_link.primary_player_id, owner_manager.player_id)
                AND mp.is_public = true
                AND mp.public_slug IS NOT NULL
            WHERE s.id = @SessionId
              AND g.public_schedule_enabled = true
              AND g.public_slug IS NOT NULL
              AND s.scheduled_at > now() - interval '4 hours'
              AND s.status <> @Cancelled
              AND (
                    s.publication_mode IN ('Catalog', 'Both')
                    OR (
                        s.publication_mode = 'ClubOnly'
                        AND @ViewerPlayerId IS NOT NULL
                        AND EXISTS (
                            SELECT 1 FROM club_memberships cm
                            WHERE cm.group_id = s.group_id
                              AND cm.player_id = @ViewerPlayerId
                              AND cm.status = 'Active'
                        )
                    )
                  )
            """,
            new
            {
                SessionId = sessionId,
                Active = ParticipantRegistrationStatus.Active,
                Waitlisted = ParticipantRegistrationStatus.Waitlisted,
                Cancelled = SessionStatus.Cancelled,
                OwnerRole = GroupManagerRoleExtensions.OwnerValue,
                ViewerPlayerId = viewerPlayerId
            });
    }

    public async Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        var rows = await conn.QueryAsync<ShowcaseSessionRow>(
            """
            SELECT s.id AS Id,
                   s.group_id AS GroupId,
                   COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS GroupName,
                   g.public_slug AS GroupSlug,
                   s.title AS Title,
                   s.scheduled_at AS ScheduledAt,
                   s.status AS Status,
                   s.system AS System,
                   s.is_one_shot AS IsOneShot,
                   s.format AS Format,
                   s.duration_minutes AS DurationMinutes,
                   s.cover_image_url AS CoverImageUrl,
                   s.max_players AS MaxPlayers,
                   COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
                   COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
                   s.allow_direct_registration AS AllowDirectRegistration,
                   s.description AS Description,
                   mp.public_slug AS MasterProfileSlug,
                   mp.display_name AS MasterDisplayName,
                   s.publication_mode AS PublicationMode,
                   (s.publication_mode = 'ClubOnly') AS IsMembersOnly
            FROM sessions s
            JOIN game_groups g ON g.id = s.group_id
            LEFT JOIN LATERAL (
                SELECT recent.title
                FROM sessions recent
                WHERE recent.group_id = g.id
                ORDER BY recent.scheduled_at DESC
                LIMIT 1
            ) latest_session ON true
            LEFT JOIN LATERAL (
                SELECT COUNT(*) AS count
                FROM session_participants sp
                WHERE sp.session_id = s.id
                  AND sp.is_gm = false
                  AND sp.registration_status = @Active
            ) active_counts ON true
            LEFT JOIN LATERAL (
                SELECT COUNT(*) AS count
                FROM session_participants sp
                WHERE sp.session_id = s.id
                  AND sp.is_gm = false
                  AND sp.registration_status = @Waitlisted
            ) waitlist_counts ON true
            LEFT JOIN LATERAL (
                SELECT gm.player_id
                FROM group_managers gm
                WHERE gm.group_id = g.id
                  AND gm.role = @OwnerRole
                ORDER BY gm.created_at
                LIMIT 1
            ) owner_manager ON true
            LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
            LEFT JOIN master_profiles mp ON mp.player_id = COALESCE(owner_link.primary_player_id, owner_manager.player_id)
                AND mp.is_public = true
                AND mp.public_slug IS NOT NULL
            WHERE g.public_schedule_enabled = true
              AND g.public_slug IS NOT NULL
              AND s.publication_mode IN ('Catalog', 'Both')
              AND s.scheduled_at > now() - interval '4 hours'
              AND s.status <> @Cancelled
              AND (
                  @DateFilter = 'All'
                  OR (@DateFilter = 'Today' AND s.scheduled_at >= CURRENT_DATE AND s.scheduled_at < CURRENT_DATE + interval '1 day')
                  OR (@DateFilter = 'Tomorrow' AND s.scheduled_at >= CURRENT_DATE + interval '1 day' AND s.scheduled_at < CURRENT_DATE + interval '2 days')
                  OR (@DateFilter = 'ThisWeek' AND s.scheduled_at >= CURRENT_DATE AND s.scheduled_at < CURRENT_DATE + interval '7 days')
              )
              AND (
                  @SeatFilter = 'Any'
                  OR (@SeatFilter = 'Available' AND (s.max_players IS NULL OR active_counts.count < s.max_players))
                  OR (@SeatFilter = 'Waitlist' AND (s.max_players IS NOT NULL AND active_counts.count >= s.max_players))
              )
              AND (@System IS NULL OR s.system = @System)
              AND (@IsOneShot IS NULL OR s.is_one_shot = @IsOneShot)
              AND (@Format IS NULL OR s.format = @Format)
            ORDER BY s.scheduled_at ASC
            LIMIT @PageSize OFFSET @Offset
            """,
            new
            {
                Active = ParticipantRegistrationStatus.Active,
                Waitlisted = ParticipantRegistrationStatus.Waitlisted,
                Cancelled = SessionStatus.Cancelled,
                DateFilter = filter.Date.ToString(),
                SeatFilter = filter.Seats.ToString(),
                filter.System,
                filter.IsOneShot,
                filter.Format,
                PageSize = pageSize,
                Offset = (page - 1) * pageSize,
                OwnerRole = GroupManagerRoleExtensions.OwnerValue
            });

        return rows.Select(r => new ShowcaseSessionDto(
            r.Id, r.GroupId, r.GroupName, r.GroupSlug, r.Title, r.ScheduledAt, r.Status,
            r.System, r.IsOneShot, r.Format, r.DurationMinutes, r.CoverImageUrl,
            r.MaxPlayers, r.ActivePlayerCount, r.WaitlistedPlayerCount, r.AllowDirectRegistration,
            r.Description,
            PublicationMode: r.PublicationMode,
            IsMembersOnly: r.IsMembersOnly,
            r.MasterProfileSlug, r.MasterDisplayName)).ToList();
    }

    public async Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        var row = await conn.QuerySingleOrDefaultAsync<ShowcaseSessionRow>(
            """
            SELECT s.id AS Id,
                   s.group_id AS GroupId,
                   COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS GroupName,
                   g.public_slug AS GroupSlug,
                   s.title AS Title,
                   s.scheduled_at AS ScheduledAt,
                   s.status AS Status,
                   s.system AS System,
                   s.is_one_shot AS IsOneShot,
                   s.format AS Format,
                   s.duration_minutes AS DurationMinutes,
                   s.cover_image_url AS CoverImageUrl,
                   s.max_players AS MaxPlayers,
                   COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
                   COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
                   s.allow_direct_registration AS AllowDirectRegistration,
                   s.description AS Description,
                   mp.public_slug AS MasterProfileSlug,
                   mp.display_name AS MasterDisplayName,
                   s.publication_mode AS PublicationMode,
                   (s.publication_mode = 'ClubOnly') AS IsMembersOnly
            FROM sessions s
            JOIN game_groups g ON g.id = s.group_id
            LEFT JOIN LATERAL (
                SELECT recent.title
                FROM sessions recent
                WHERE recent.group_id = g.id
                ORDER BY recent.scheduled_at DESC
                LIMIT 1
            ) latest_session ON true
            LEFT JOIN LATERAL (
                SELECT COUNT(*) AS count
                FROM session_participants sp
                WHERE sp.session_id = s.id
                  AND sp.is_gm = false
                  AND sp.registration_status = @Active
            ) active_counts ON true
            LEFT JOIN LATERAL (
                SELECT COUNT(*) AS count
                FROM session_participants sp
                WHERE sp.session_id = s.id
                  AND sp.is_gm = false
                  AND sp.registration_status = @Waitlisted
            ) waitlist_counts ON true
            LEFT JOIN LATERAL (
                SELECT gm.player_id
                FROM group_managers gm
                WHERE gm.group_id = g.id
                  AND gm.role = @OwnerRole
                ORDER BY gm.created_at
                LIMIT 1
            ) owner_manager ON true
            LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
            LEFT JOIN master_profiles mp ON mp.player_id = COALESCE(owner_link.primary_player_id, owner_manager.player_id)
                AND mp.is_public = true
                AND mp.public_slug IS NOT NULL
            WHERE s.id = @SessionId
              AND g.public_schedule_enabled = true
              AND g.public_slug IS NOT NULL
              AND s.publication_mode IN ('Catalog', 'Both')
              AND s.scheduled_at > now() - interval '4 hours'
              AND s.status <> @Cancelled
            """,
            new
            {
                SessionId = sessionId,
                Active = ParticipantRegistrationStatus.Active,
                Waitlisted = ParticipantRegistrationStatus.Waitlisted,
                Cancelled = SessionStatus.Cancelled,
                OwnerRole = GroupManagerRoleExtensions.OwnerValue
            });

        if (row is null)
            return null;

        return new ShowcaseSessionDto(
            row.Id, row.GroupId, row.GroupName, row.GroupSlug, row.Title, row.ScheduledAt, row.Status,
            row.System, row.IsOneShot, row.Format, row.DurationMinutes, row.CoverImageUrl,
            row.MaxPlayers, row.ActivePlayerCount, row.WaitlistedPlayerCount, row.AllowDirectRegistration,
            row.Description,
            PublicationMode: row.PublicationMode,
            IsMembersOnly: row.IsMembersOnly,
            row.MasterProfileSlug, row.MasterDisplayName);
    }

    public async Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        await using var transaction = await conn.BeginTransactionAsync();

        var session = await conn.QuerySingleOrDefaultAsync<dynamic>(
            """
            SELECT s.id, s.max_players AS MaxPlayers, s.allow_direct_registration AS AllowDirectRegistration
            FROM sessions s
            JOIN game_groups g ON g.id = s.group_id
            WHERE s.id = @SessionId
              AND s.publication_mode IN ('Catalog', 'Both')
              AND g.public_schedule_enabled = true
              AND g.public_slug IS NOT NULL
              AND s.scheduled_at > now() - interval '4 hours'
              AND s.status <> @Cancelled
            FOR UPDATE OF s
            """,
            new { SessionId = sessionId, Cancelled = SessionStatus.Cancelled },
            transaction);

        if (session is null || !(bool)session.allowdirectregistration)
        {
            await transaction.RollbackAsync();
            return false;
        }

        var playerId = await _UpsertPlayerAndGetIdAsync(conn, platform, externalUserId, displayName, null, transaction);

        var registrationStatus = SessionCapacityRules.DecideJoinStatus(
            (int?)session.maxplayers,
            await conn.ExecuteScalarAsync<int>(
                """
                SELECT COUNT(*) FROM session_participants
                WHERE session_id = @SessionId AND is_gm = false AND registration_status = @Active
                """,
                new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active },
                transaction));

        var inserted = await conn.ExecuteAsync(
            """
            INSERT INTO session_participants (session_id, player_id, is_gm, rsvp_status, registration_status)
            VALUES (@SessionId, @PlayerId, false, @Pending, @RegistrationStatus)
            ON CONFLICT (session_id, player_id) DO NOTHING
            """,
            new
            {
                SessionId = sessionId,
                PlayerId = playerId,
                Pending = RsvpStatus.Pending,
                RegistrationStatus = registrationStatus
            },
            transaction);

        if (inserted == 0)
        {
            await transaction.RollbackAsync();
            return false;
        }

        await transaction.CommitAsync();
        return true;
    }

    public async Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        var playerIds = await _ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId);
        if (playerIds.Length == 0)
            return false;

        return await conn.ExecuteScalarAsync<bool>(
            """
            SELECT EXISTS (
                SELECT 1
                FROM group_managers
                WHERE group_id = @GroupId
                  AND player_id = ANY(@PlayerIds)
            )
            """,
            new { GroupId = groupId, PlayerIds = playerIds });
    }

    public async Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        var playerIds = await _ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId);
        if (playerIds.Length == 0)
            return false;

        return await conn.ExecuteScalarAsync<bool>(
            """
            SELECT EXISTS (
                SELECT 1
                FROM group_managers
                WHERE group_id = @GroupId
                  AND player_id = ANY(@PlayerIds)
                  AND role = @OwnerRole
            )
            """,
            new { GroupId = groupId, PlayerIds = playerIds, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
    }

    public async Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        return (await conn.QueryAsync<WebGroupManager>(
            """
            SELECT CASE
                       WHEN p.platform = 'Telegram' AND p.external_user_id ~ '^[0-9]+$'
                           THEN p.external_user_id::BIGINT
                       ELSE 0
                   END AS TelegramId,
                   p.external_user_id AS ExternalUserId,
                   p.platform AS Platform,
                   p.display_name AS DisplayName,
                   p.external_username AS TelegramUsername,
                   p.external_username AS ExternalUsername,
                   gm.role AS Role,
                   gm.created_at AS AddedAt
            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,
                     gm.created_at,
                     p.display_name
            """,
            new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue })).ToList();
    }

    public async Task<List<PlayerAttendanceStats>> GetGroupAttendanceStatsAsync(Guid groupId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        return (await conn.QueryAsync<PlayerAttendanceStats>(
            """
            SELECT
                p.id AS PlayerId,
                p.display_name AS DisplayName,
                p.external_username AS ExternalUsername,
                COUNT(DISTINCT s.id) AS TotalSessions,
                COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Confirmed' THEN s.id END) AS ConfirmedCount,
                COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Declined' THEN s.id END) AS DeclinedCount,
                COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Pending' THEN s.id END) AS NoResponseCount,
                COUNT(DISTINCT CASE WHEN sp.registration_status = 'Waitlisted' THEN s.id END) AS WaitlistedCount,
                COUNT(DISTINCT CASE WHEN s.status = 'Cancelled' AND sp.rsvp_status IN ('Confirmed','Declined') THEN s.id END) AS CancellationAffectedCount,
                CASE WHEN COUNT(DISTINCT s.id) > 0
                    THEN ROUND(
                        COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Confirmed' THEN s.id END)
                        * 100.0 / COUNT(DISTINCT s.id), 2)
                    ELSE 0
                END AS AttendanceRate
            FROM players p
            JOIN session_participants sp ON sp.player_id = p.id
            JOIN sessions s ON s.id = sp.session_id
            WHERE s.group_id = @GroupId
              AND s.scheduled_at <= now()
              AND sp.is_gm = false
            GROUP BY p.id, p.display_name, p.external_username
            ORDER BY AttendanceRate DESC, ConfirmedCount DESC
            """,
            new { GroupId = groupId })).ToList();
    }

    public async Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        await conn.ExecuteAsync(
            """
            INSERT INTO session_audit_log (session_id, actor_external_user_id, actor_name, change_type, old_value, new_value)
            VALUES (@SessionId, @ActorExternalUserId, @ActorName, @ChangeType, @OldValue, @NewValue)
            """,
            new { SessionId = sessionId, ActorExternalUserId = actorExternalUserId, ActorName = actorName, ChangeType = changeType, OldValue = oldValue, NewValue = newValue });
    }

    public async Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        var entries = await conn.QueryAsync<SessionAuditLogEntry>(
            """
            SELECT id, session_id AS SessionId, actor_external_user_id AS ActorExternalUserId, actor_name AS ActorName,
                   change_type AS ChangeType, old_value AS OldValue, new_value AS NewValue, changed_at AS ChangedAt
            FROM session_audit_log
            WHERE session_id = @SessionId
            ORDER BY changed_at DESC
            """,
            new { SessionId = sessionId });
        return entries.ToList();
    }

    public async Task AddGroupCoGmAsync(
        Guid groupId,
        string ownerPlatform, string ownerExternalUserId,
        string coGmPlatform, string coGmExternalUserId,
        string displayName, string? externalUsername)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        await using var transaction = await conn.BeginTransactionAsync();

        var ownerPlayerId = await _ResolveEffectivePlayerIdAsync(conn, ownerPlatform, ownerExternalUserId);
        if (ownerPlayerId is null)
            throw new InvalidOperationException("Owner player not found.");

        var coGmPlayerId = await _UpsertPlayerAndGetIdAsync(conn, coGmPlatform, coGmExternalUserId, displayName, externalUsername, transaction);

        await conn.ExecuteAsync(
            """
            INSERT INTO group_managers (group_id, player_id, role, added_by_player_id)
            VALUES (@GroupId, @CoGmPlayerId, @CoGmRole, @OwnerPlayerId)
            ON CONFLICT (group_id, player_id) DO UPDATE
            SET role = CASE
                    WHEN group_managers.role = @OwnerRole THEN group_managers.role
                    ELSE EXCLUDED.role
                END,
                added_by_player_id = EXCLUDED.added_by_player_id
            """,
            new
            {
                GroupId = groupId,
                OwnerPlayerId = ownerPlayerId.Value,
                CoGmPlayerId = coGmPlayerId,
                OwnerRole = GroupManagerRoleExtensions.OwnerValue,
                CoGmRole = GroupManagerRoleExtensions.CoGmValue
            },
            transaction);

        await transaction.CommitAsync();
    }

    public async Task RemoveGroupCoGmAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        var coGmPlayerId = await _ResolveEffectivePlayerIdAsync(conn, coGmPlatform, coGmExternalUserId);
        if (coGmPlayerId is null)
            return;

        await conn.ExecuteAsync(
            """
            DELETE FROM group_managers
            WHERE group_id = @GroupId
              AND player_id = @PlayerId
              AND role = @CoGmRole
            """,
            new
            {
                GroupId = groupId,
                PlayerId = coGmPlayerId.Value,
                CoGmRole = GroupManagerRoleExtensions.CoGmValue
            });
    }

    public async Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        return (await conn.QueryAsync<WebSession>(
            @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
                     s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
                     g.external_group_id::BIGINT AS TelegramChatId,
                     s.max_players AS MaxPlayers,
                     COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
                     COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
                     s.notification_mode AS NotificationMode,
                     s.thread_id AS ThreadId,
                     s.publication_mode AS PublicationMode
              FROM sessions s
              JOIN game_groups g ON g.id = s.group_id
              LEFT JOIN LATERAL (
                  SELECT COUNT(*) AS count
                  FROM session_participants sp
                  WHERE sp.session_id = s.id
                    AND sp.is_gm = false
                    AND sp.registration_status = @Active
              ) active_counts ON true
              LEFT JOIN LATERAL (
                  SELECT COUNT(*) AS count
                  FROM session_participants sp
                  WHERE sp.session_id = s.id
                    AND sp.is_gm = false
                    AND sp.registration_status = @Waitlisted
              ) waitlist_counts ON true
              WHERE s.group_id = @GroupId AND s.scheduled_at > now() - interval '4 hours'
              ORDER BY s.scheduled_at",
            new
            {
                GroupId = groupId,
                Active = ParticipantRegistrationStatus.Active,
                Waitlisted = ParticipantRegistrationStatus.Waitlisted
            })).ToList();
    }

    public async Task<WebSession?> GetSessionAsync(Guid sessionId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        return await conn.QuerySingleOrDefaultAsync<WebSession>(
            @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
                     s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
                     g.external_group_id::BIGINT AS TelegramChatId,
                     s.max_players AS MaxPlayers,
                     COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
                     COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
                     s.notification_mode AS NotificationMode,
                     s.thread_id AS ThreadId,
                     s.publication_mode AS PublicationMode
              FROM sessions s
              JOIN game_groups g ON g.id = s.group_id
              LEFT JOIN LATERAL (
                  SELECT COUNT(*) AS count
                  FROM session_participants sp
                  WHERE sp.session_id = s.id
                    AND sp.is_gm = false
                    AND sp.registration_status = @Active
              ) active_counts ON true
              LEFT JOIN LATERAL (
                  SELECT COUNT(*) AS count
                  FROM session_participants sp
                  WHERE sp.session_id = s.id
                    AND sp.is_gm = false
                    AND sp.registration_status = @Waitlisted
              ) waitlist_counts ON true
              WHERE s.id = @SessionId",
            new
            {
                SessionId = sessionId,
                Active = ParticipantRegistrationStatus.Active,
                Waitlisted = ParticipantRegistrationStatus.Waitlisted
            });
    }

    public async Task<WebSessionBatch?> GetBatchAsync(Guid batchId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        return await conn.QuerySingleOrDefaultAsync<WebSessionBatch>(
            """
            SELECT s.batch_id AS Id,
                   s.group_id AS GroupId,
                   (array_agg(s.title ORDER BY s.scheduled_at))[1] AS Title,
                   (array_agg(s.join_link ORDER BY s.scheduled_at))[1] AS JoinLink,
                   MIN(s.scheduled_at) AS FirstScheduledAt,
                   MAX(s.scheduled_at) AS LastScheduledAt,
                   COUNT(*)::int AS SessionCount,
                   (array_agg(s.notification_mode ORDER BY s.scheduled_at))[1] AS NotificationMode
            FROM sessions s
            WHERE s.batch_id = @BatchId
            GROUP BY s.batch_id, s.group_id
            """,
            new { BatchId = batchId });
    }

    public async Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        await using var transaction = await conn.BeginTransactionAsync();

        var oldSession = await conn.QuerySingleOrDefaultAsync<WebSession>(
            @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
                     s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
                     g.external_group_id::BIGINT AS TelegramChatId,
                     s.max_players AS MaxPlayers,
                     0 AS ActivePlayerCount,
                     0 AS WaitlistedPlayerCount,
                     s.notification_mode AS NotificationMode,
                     s.thread_id AS ThreadId,
                     s.publication_mode AS PublicationMode
              FROM sessions s
              JOIN game_groups g ON g.id = s.group_id
              WHERE s.id = @Id AND s.group_id = @GroupId",
            new { Id = sessionId, GroupId = groupId },
            transaction);

        if (oldSession is null)
        {
            throw new SessionAccessDeniedException(sessionId, "0");
        }

        var updatedRows = await conn.ExecuteAsync(
            @"UPDATE sessions
              SET title = @Title,
                  scheduled_at = @ScheduledAt,
                  join_link = @JoinLink,
                  max_players = @MaxPlayers,
                  one_hour_reminder_processed_at = CASE
                      WHEN scheduled_at <> @ScheduledAt THEN NULL
                      ELSE one_hour_reminder_processed_at
                  END,
                  updated_at = now()
              WHERE id = @Id AND group_id = @GroupId",
            new
            {
                Id = sessionId,
                GroupId = groupId,
                Title = title,
                ScheduledAt = scheduledAt,
                JoinLink = joinLink,
                MaxPlayers = maxPlayers
            },
            transaction);

        if (updatedRows == 0)
        {
            throw new SessionAccessDeniedException(sessionId, "0");
        }

        await conn.ExecuteAsync(
            "UPDATE sessions SET title = @Title WHERE batch_id = @BatchId",
            new { Title = title, BatchId = oldSession.BatchId },
            transaction);

        await transaction.CommitAsync();

        var timeChanged = oldSession.ScheduledAt != scheduledAt;
        var notification = $"🔄 <b>Мастер обновил игру!</b>\n\n" +
                           $"📌 <b>{System.Net.WebUtility.HtmlEncode(title)}</b>\n" +
                           $"📅 Время: <b>{scheduledAt.FormatMoscow()}</b> (МСК)" + (timeChanged ? " (изменено)" : "") +
                           "\n" +
                           $"👥 Мест: <b>{(maxPlayers.HasValue ? maxPlayers.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "без лимита")}</b>";

        await bot.SendMessage(
            chatId: oldSession.TelegramChatId,
            messageThreadId: oldSession.ThreadId,
            text: notification,
            parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);

        var mode = SessionNotificationModeExtensions.FromDatabaseValue(oldSession.NotificationMode);
        if (mode.ShouldSendDirectMessages())
        {
            var recipients = await LoadSessionDirectRecipientsAsync(conn, sessionId);
            await SendDirectNotificationsAsync(recipients, notification, "web-session-updated", sessionId);
        }

        if (oldSession.BatchMessageId.HasValue)
        {
            await TryUpdateBatchMessageAsync(oldSession.BatchId, oldSession.TelegramChatId, oldSession.BatchMessageId.Value, title);
        }
    }

    public async Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        await using var transaction = await conn.BeginTransactionAsync();

        var session = await conn.QuerySingleOrDefaultAsync<WebSession>(
            @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
                     s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
                     g.external_group_id::BIGINT AS TelegramChatId,
                     s.max_players AS MaxPlayers,
                     0 AS ActivePlayerCount,
                     0 AS WaitlistedPlayerCount,
                     s.notification_mode AS NotificationMode,
                     s.thread_id AS ThreadId,
                     s.publication_mode AS PublicationMode
              FROM sessions s
              JOIN game_groups g ON g.id = s.group_id
              WHERE s.id = @SessionId AND s.group_id = @GroupId
              FOR UPDATE",
            new { SessionId = sessionId, GroupId = groupId },
            transaction);

        if (session is null)
        {
            throw new SessionAccessDeniedException(sessionId, "0");
        }

        var activeParticipants = await conn.ExecuteScalarAsync<int>(
            """
            SELECT COUNT(*)
            FROM session_participants
            WHERE session_id = @SessionId
              AND is_gm = false
              AND registration_status = @Active
            """,
            new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active },
            transaction);

        var waitlistedParticipants = await conn.ExecuteScalarAsync<int>(
            """
            SELECT COUNT(*)
            FROM session_participants
            WHERE session_id = @SessionId
              AND is_gm = false
              AND registration_status = @Waitlisted
            """,
            new { SessionId = sessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
            transaction);

        if (!SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, activeParticipants, waitlistedParticipants))
        {
            throw new InvalidOperationException(waitlistedParticipants == 0
                ? "Лист ожидания пуст."
                : "Нет свободных мест для повышения игрока.");
        }

        var promoted = await conn.QuerySingleAsync<WebPromotedParticipantDto>(
            """
            SELECT sp.id AS ParticipantRowId,
                   p.display_name AS DisplayName
            FROM session_participants sp
            JOIN players p ON p.id = sp.player_id
            WHERE sp.session_id = @SessionId
              AND sp.is_gm = false
              AND sp.registration_status = @Waitlisted
            ORDER BY sp.created_at ASC, sp.id ASC
            LIMIT 1
            FOR UPDATE OF sp
            """,
            new { SessionId = sessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
            transaction);

        await conn.ExecuteAsync(
            """
            UPDATE session_participants
            SET registration_status = @Active,
                rsvp_status = @Pending,
                responded_at = NULL
            WHERE id = @ParticipantRowId
            """,
            new
            {
                promoted.ParticipantRowId,
                Active = ParticipantRegistrationStatus.Active,
                Pending = RsvpStatus.Pending
            },
            transaction);

        await transaction.CommitAsync();

        await bot.SendMessage(
            chatId: session.TelegramChatId,
            messageThreadId: session.ThreadId,
            text: $"⬆️ <b>{System.Net.WebUtility.HtmlEncode(promoted.DisplayName)}</b> переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
            parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);

        if (session.BatchMessageId.HasValue)
        {
            await TryUpdateBatchMessageAsync(session.BatchId, session.TelegramChatId, session.BatchMessageId.Value, session.Title);
        }
    }

    public async Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        return (await conn.QueryAsync<WebParticipant>(
            """
            SELECT sp.id AS Id,
                   COALESCE(p.external_user_id::BIGINT, 0) AS TelegramId,
                   p.external_user_id AS ExternalUserId,
                   p.display_name AS DisplayName,
                   p.external_username AS TelegramUsername,
                   p.external_username AS ExternalUsername,
                   sp.rsvp_status AS RsvpStatus,
                   sp.registration_status AS RegistrationStatus,
                   sp.is_gm AS IsGm,
                   sp.responded_at AS RespondedAt
            FROM session_participants sp
            JOIN players p ON p.id = sp.player_id
            WHERE sp.session_id = @SessionId
            ORDER BY sp.is_gm DESC,
                     CASE sp.registration_status WHEN 'Active' THEN 0 ELSE 1 END,
                     sp.created_at
            """,
            new { SessionId = sessionId })).ToList();
    }

    public async Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        await using var transaction = await conn.BeginTransactionAsync();

        var session = await conn.QuerySingleOrDefaultAsync<WebSession>(
            @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
                     s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
                     g.external_group_id::BIGINT AS TelegramChatId,
                     s.max_players AS MaxPlayers,
                     0 AS ActivePlayerCount,
                     0 AS WaitlistedPlayerCount,
                     s.notification_mode AS NotificationMode,
                     s.thread_id AS ThreadId,
                     s.publication_mode AS PublicationMode
              FROM sessions s
              JOIN game_groups g ON g.id = s.group_id
              WHERE s.id = @SessionId AND s.group_id = @GroupId
              FOR UPDATE",
            new { SessionId = sessionId, GroupId = groupId },
            transaction);

        if (session is null)
        {
            throw new SessionAccessDeniedException(sessionId, "0");
        }

        var participant = await conn.QuerySingleOrDefaultAsync<WebParticipant>(
            """
            SELECT sp.id AS Id,
                   p.external_user_id::BIGINT AS TelegramId,
                   p.display_name AS DisplayName,
                   p.external_username AS TelegramUsername,
                   sp.rsvp_status AS RsvpStatus,
                   sp.registration_status AS RegistrationStatus,
                   sp.is_gm AS IsGm,
                   sp.responded_at AS RespondedAt
            FROM session_participants sp
            JOIN players p ON p.id = sp.player_id
            WHERE sp.id = @ParticipantId AND sp.session_id = @SessionId
            """,
            new { ParticipantId = participantId, SessionId = sessionId },
            transaction);

        if (participant is null)
        {
            throw new InvalidOperationException("Участник не найден в этой сессии.");
        }

        bool wasActive = participant.RegistrationStatus == ParticipantRegistrationStatus.Active;

        await conn.ExecuteAsync(
            "DELETE FROM session_participants WHERE id = @ParticipantId",
            new { ParticipantId = participantId },
            transaction);

        WebPromotedParticipantDto? promoted = null;

        if (wasActive)
        {
            promoted = await conn.QuerySingleOrDefaultAsync<WebPromotedParticipantDto>(
                """
                SELECT sp.id AS ParticipantRowId,
                       p.display_name AS DisplayName
                FROM session_participants sp
                JOIN players p ON p.id = sp.player_id
                WHERE sp.session_id = @SessionId
                  AND sp.is_gm = false
                  AND sp.registration_status = @Waitlisted
                ORDER BY sp.created_at ASC, sp.id ASC
                LIMIT 1
                FOR UPDATE OF sp
                """,
                new { SessionId = sessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
                transaction);

            if (promoted is not null)
            {
                await conn.ExecuteAsync(
                    """
                    UPDATE session_participants
                    SET registration_status = @Active,
                        rsvp_status = @Pending,
                        responded_at = NULL
                    WHERE id = @ParticipantRowId
                    """,
                    new
                    {
                        promoted.ParticipantRowId,
                        Active = ParticipantRegistrationStatus.Active,
                        Pending = RsvpStatus.Pending
                    },
                    transaction);
            }
        }

        await transaction.CommitAsync();

        await bot.SendMessage(
            chatId: session.TelegramChatId,
            messageThreadId: session.ThreadId,
            text: $"🚪 <b>{System.Net.WebUtility.HtmlEncode(participant.DisplayName)}</b> удален(а) из сессии «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
            parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);

        if (promoted is not null)
        {
            await bot.SendMessage(
                chatId: session.TelegramChatId,
                messageThreadId: session.ThreadId,
                text: $"⬆️ <b>{System.Net.WebUtility.HtmlEncode(promoted.DisplayName)}</b> переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
                parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);

            if (session.BatchMessageId.HasValue)
            {
                await TryUpdateBatchMessageAsync(session.BatchId, session.TelegramChatId, session.BatchMessageId.Value, session.Title);
            }
        }
        else if (session.BatchMessageId.HasValue)
        {
            await TryUpdateBatchMessageAsync(session.BatchId, session.TelegramChatId, session.BatchMessageId.Value, session.Title);
        }
    }

    public async Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        await using var transaction = await conn.BeginTransactionAsync();

        var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction);
        if (batch is null)
        {
            throw new SessionAccessDeniedException(batchId, "0");
        }

        var updatedRows = await conn.ExecuteAsync(
            """
            UPDATE sessions
            SET title = @Title,
                join_link = @JoinLink,
                updated_at = now()
            WHERE batch_id = @BatchId
              AND group_id = @GroupId
            """,
            new
            {
                BatchId = batchId,
                GroupId = groupId,
                Title = title,
                JoinLink = joinLink
            },
            transaction);

        if (updatedRows == 0)
        {
            throw new SessionAccessDeniedException(batchId, "0");
        }

        await transaction.CommitAsync();

        if (batch.BatchMessageId.HasValue)
        {
            await TryUpdateBatchMessageAsync(batchId, batch.TelegramChatId, batch.BatchMessageId.Value, title);
        }
    }

    public async Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        await using var transaction = await conn.BeginTransactionAsync();

        var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction);
        if (batch is null)
        {
            throw new SessionAccessDeniedException(batchId, "0");
        }

        var updatedRows = await conn.ExecuteAsync(
            """
            UPDATE sessions
            SET notification_mode = @NotificationMode,
                updated_at = now()
            WHERE batch_id = @BatchId
              AND group_id = @GroupId
            """,
            new
            {
                BatchId = batchId,
                GroupId = groupId,
                NotificationMode = notificationMode.ToDatabaseValue()
            },
            transaction);

        if (updatedRows == 0)
        {
            throw new SessionAccessDeniedException(batchId, "0");
        }

        await transaction.CommitAsync();
    }

    public async Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        await using var transaction = await conn.BeginTransactionAsync();

        var batchSessions = (await conn.QueryAsync<WebBatchSessionRow>(
            """
            SELECT s.id AS Id,
                   s.group_id AS GroupId,
                   s.title AS Title,
                   s.join_link AS JoinLink,
                   s.scheduled_at AS ScheduledAt,
                   s.status AS Status,
                   s.max_players AS MaxPlayers,
                   s.batch_message_id AS BatchMessageId,
                   g.external_group_id::BIGINT AS TelegramChatId,
                   s.thread_id AS ThreadId,
                   s.topic_created_by_bot AS TopicCreatedByBot,
                   s.notification_mode AS NotificationMode
            FROM sessions s
            JOIN game_groups g ON g.id = s.group_id
            WHERE s.batch_id = @BatchId
              AND s.group_id = @GroupId
            ORDER BY s.scheduled_at
            FOR UPDATE
            """,
            new { BatchId = batchId, GroupId = groupId },
            transaction)).ToList();

        if (batchSessions.Count == 0)
        {
            throw new SessionAccessDeniedException(batchId, "0");
        }

        var newSchedule = BatchSchedulePlanner.BuildFixedIntervalSchedule(
            batchSessions.Select(session => session.ScheduledAt),
            firstScheduledAt,
            intervalDays);

        for (var index = 0; index < batchSessions.Count; index++)
        {
            await conn.ExecuteAsync(
                """
                UPDATE sessions
                SET scheduled_at = @ScheduledAt,
                    one_hour_reminder_processed_at = NULL,
                    updated_at = now()
                WHERE id = @SessionId
                """,
                new
                {
                    SessionId = batchSessions[index].Id,
                    ScheduledAt = newSchedule[index]
                },
                transaction);
        }

        await transaction.CommitAsync();

        var firstSession = batchSessions[0];
        if (firstSession.BatchMessageId.HasValue)
        {
            await TryUpdateBatchMessageAsync(batchId, firstSession.TelegramChatId, firstSession.BatchMessageId.Value, firstSession.Title);
        }

        var notification = $"🔄 <b>Мастер обновил расписание пачки</b>\n\n" +
                           $"📌 <b>{System.Net.WebUtility.HtmlEncode(firstSession.Title)}</b>\n" +
                           $"🗓 Новое начало: <b>{firstScheduledAt.FormatMoscow()}</b> (МСК)\n" +
                           $"↔️ Шаг: <b>{intervalDays} дн.</b>";

        await bot.SendMessage(
            chatId: firstSession.TelegramChatId,
            messageThreadId: firstSession.ThreadId,
            text: notification,
            parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);

        var mode = SessionNotificationModeExtensions.FromDatabaseValue(firstSession.NotificationMode);
        if (mode.ShouldSendDirectMessages())
        {
            var recipients = await LoadBatchDirectRecipientsAsync(conn, batchId);
            await SendDirectNotificationsAsync(recipients, notification, "web-batch-rescheduled", batchId);
        }
    }

    public async Task<WebSessionBatch> CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        await using var transaction = await conn.BeginTransactionAsync();

        var sourceSessions = (await conn.QueryAsync<WebBatchSessionRow>(
            """
            SELECT s.id AS Id,
                   s.group_id AS GroupId,
                   s.title AS Title,
                   s.join_link AS JoinLink,
                   s.scheduled_at AS ScheduledAt,
                   s.status AS Status,
                   s.max_players AS MaxPlayers,
                   s.batch_message_id AS BatchMessageId,
                   g.external_group_id::BIGINT AS TelegramChatId,
                   s.thread_id AS ThreadId,
                   s.topic_created_by_bot AS TopicCreatedByBot,
                   s.notification_mode AS NotificationMode
            FROM sessions s
            JOIN game_groups g ON g.id = s.group_id
            WHERE s.batch_id = @BatchId
              AND s.group_id = @GroupId
            ORDER BY s.scheduled_at
            FOR UPDATE
            """,
            new { BatchId = batchId, GroupId = groupId },
            transaction)).ToList();

        if (sourceSessions.Count == 0)
        {
            throw new SessionAccessDeniedException(batchId, "0");
        }

        var newBatchId = Guid.NewGuid();
        var batchTitle = sourceSessions[0].Title;
        var batchJoinLink = sourceSessions[0].JoinLink;
        var chatId = sourceSessions[0].TelegramChatId;
        var threadId = sourceSessions[0].ThreadId;
        var renderedSessions = new List<SessionBatchDto>();

        foreach (var sourceSession in sourceSessions)
        {
            var scheduledAt = BatchSchedulePlanner.ShiftForClone(sourceSession.ScheduledAt, interval);
            var sessionId = await conn.ExecuteScalarAsync<Guid>(
                """
                INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, topic_created_by_bot, max_players, notification_mode)
                VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers, @NotificationMode)
                RETURNING id
                """,
                new
                {
                    BatchId = newBatchId,
                    sourceSession.GroupId,
                    Title = batchTitle,
                    JoinLink = batchJoinLink,
                    ScheduledAt = scheduledAt,
                    Status = SessionStatus.Planned,
                    ThreadId = threadId,
                    sourceSession.TopicCreatedByBot,
                    sourceSession.MaxPlayers,
                    sourceSession.NotificationMode
                },
                transaction);

            renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, sourceSession.MaxPlayers, batchJoinLink));
        }

        await transaction.CommitAsync();

        var view = SessionBatchViewBuilder.Build(batchTitle, renderedSessions, Array.Empty<ParticipantBatchDto>());
        var renderResult = TelegramSessionBatchRenderer.Render(view);
        var batchMessage = await bot.SendMessage(
            chatId: chatId,
            messageThreadId: threadId,
            text: renderResult.Text,
            parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
            replyMarkup: renderResult.Markup);

        await conn.ExecuteAsync(
            "UPDATE sessions SET batch_message_id = @MessageId WHERE batch_id = @BatchId",
            new { MessageId = batchMessage.MessageId, BatchId = newBatchId });

        return new WebSessionBatch(
            newBatchId,
            groupId,
            batchTitle,
            batchJoinLink,
            renderedSessions.Min(session => session.ScheduledAt),
            renderedSessions.Max(session => session.ScheduledAt),
            renderedSessions.Count,
            sourceSessions[0].NotificationMode);
    }

    public async Task<List<WebCampaignTemplate>> GetCampaignTemplatesAsync(Guid groupId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        return (await conn.QueryAsync<WebCampaignTemplate>(
            """
            SELECT id AS Id,
                   group_id AS GroupId,
                   name AS Name,
                   title AS Title,
                   join_link AS JoinLink,
                   session_count AS SessionCount,
                   interval_days AS IntervalDays,
                   max_players AS MaxPlayers,
                   notification_mode AS NotificationMode,
                   created_at AS CreatedAt,
                   updated_at AS UpdatedAt
            FROM campaign_templates
            WHERE group_id = @GroupId
            ORDER BY created_at DESC, name
            """,
            new { GroupId = groupId })).ToList();
    }

    public async Task<WebCampaignTemplate?> GetCampaignTemplateAsync(Guid templateId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        return await conn.QuerySingleOrDefaultAsync<WebCampaignTemplate>(
            """
            SELECT id AS Id,
                   group_id AS GroupId,
                   name AS Name,
                   title AS Title,
                   join_link AS JoinLink,
                   session_count AS SessionCount,
                   interval_days AS IntervalDays,
                   max_players AS MaxPlayers,
                   notification_mode AS NotificationMode,
                   created_at AS CreatedAt,
                   updated_at AS UpdatedAt
            FROM campaign_templates
            WHERE id = @TemplateId
            """,
            new { TemplateId = templateId });
    }

    public async Task<WebCampaignTemplate> CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        return await conn.QuerySingleAsync<WebCampaignTemplate>(
            """
            INSERT INTO campaign_templates (
                group_id,
                name,
                title,
                join_link,
                session_count,
                interval_days,
                max_players,
                notification_mode
            )
            VALUES (
                @GroupId,
                @Name,
                @Title,
                @JoinLink,
                @SessionCount,
                @IntervalDays,
                @MaxPlayers,
                @NotificationMode
            )
            ON CONFLICT (group_id, name) DO UPDATE
            SET title = EXCLUDED.title,
                join_link = EXCLUDED.join_link,
                session_count = EXCLUDED.session_count,
                interval_days = EXCLUDED.interval_days,
                max_players = EXCLUDED.max_players,
                notification_mode = EXCLUDED.notification_mode,
                updated_at = now()
            RETURNING id AS Id,
                      group_id AS GroupId,
                      name AS Name,
                      title AS Title,
                      join_link AS JoinLink,
                      session_count AS SessionCount,
                      interval_days AS IntervalDays,
                      max_players AS MaxPlayers,
                      notification_mode AS NotificationMode,
                      created_at AS CreatedAt,
                      updated_at AS UpdatedAt
            """,
            new
            {
                GroupId = groupId,
                request.Name,
                request.Title,
                request.JoinLink,
                request.SessionCount,
                request.IntervalDays,
                request.MaxPlayers,
                NotificationMode = request.NotificationMode.ToDatabaseValue()
            });
    }

    public async Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        await conn.ExecuteAsync(
            "DELETE FROM campaign_templates WHERE id = @TemplateId AND group_id = @GroupId",
            new { TemplateId = templateId, GroupId = groupId });
    }

    public async Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        await using var transaction = await conn.BeginTransactionAsync();

        var template = await conn.QuerySingleOrDefaultAsync<WebCampaignTemplate>(
            """
            SELECT id AS Id,
                   group_id AS GroupId,
                   name AS Name,
                   title AS Title,
                   join_link AS JoinLink,
                   session_count AS SessionCount,
                   interval_days AS IntervalDays,
                   max_players AS MaxPlayers,
                   notification_mode AS NotificationMode,
                   created_at AS CreatedAt,
                   updated_at AS UpdatedAt
            FROM campaign_templates
            WHERE id = @TemplateId
              AND group_id = @GroupId
            FOR UPDATE
            """,
            new { TemplateId = templateId, GroupId = groupId },
            transaction);

        if (template is null)
        {
            throw new SessionAccessDeniedException(templateId, "0");
        }

        var group = await conn.QuerySingleOrDefaultAsync<WebTemplateGroupDto>(
            "SELECT external_group_id::BIGINT AS TelegramChatId FROM game_groups WHERE id = @GroupId",
            new { GroupId = groupId },
            transaction);

        if (group is null)
        {
            throw new SessionAccessDeniedException(groupId, "0");
        }

        var topicDestination = await ResolveTemplateBatchTopicAsync(group.TelegramChatId, template.Title);
        var messageThreadId = topicDestination.MessageThreadId;
        var topicCreatedByBot = topicDestination.TopicCreatedByBot;

        var schedule = BatchSchedulePlanner.BuildRecurringSchedule(
            firstScheduledAt,
            template.SessionCount,
            template.IntervalDays);
        var batchId = Guid.NewGuid();
        var renderedSessions = new List<SessionBatchDto>();

        foreach (var scheduledAt in schedule)
        {
            var sessionId = await conn.ExecuteScalarAsync<Guid>(
                """
                INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, topic_created_by_bot, max_players, notification_mode)
                VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers, @NotificationMode)
                RETURNING id
                """,
                new
                {
                    BatchId = batchId,
                    GroupId = groupId,
                    template.Title,
                    template.JoinLink,
                    ScheduledAt = scheduledAt,
                    Status = SessionStatus.Planned,
                    ThreadId = messageThreadId,
                    TopicCreatedByBot = topicCreatedByBot,
                    template.MaxPlayers,
                    template.NotificationMode
                },
                transaction);

            renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, template.MaxPlayers, template.JoinLink));
        }

        await transaction.CommitAsync();

        var view = SessionBatchViewBuilder.Build(template.Title, renderedSessions, Array.Empty<ParticipantBatchDto>());
        var renderResult = TelegramSessionBatchRenderer.Render(view);
        var batchMessage = await bot.SendMessage(
            chatId: group.TelegramChatId,
            messageThreadId: messageThreadId,
            text: renderResult.Text,
            parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
            replyMarkup: renderResult.Markup);

        await conn.ExecuteAsync(
            "UPDATE sessions SET batch_message_id = @MessageId WHERE batch_id = @BatchId",
            new { MessageId = batchMessage.MessageId, BatchId = batchId });

        return new WebSessionBatch(
            batchId,
            groupId,
            template.Title,
            template.JoinLink,
            renderedSessions.Min(session => session.ScheduledAt),
            renderedSessions.Max(session => session.ScheduledAt),
            renderedSessions.Count,
            template.NotificationMode);
    }

    private async Task<WebTemplateTopicDestination> ResolveTemplateBatchTopicAsync(long telegramChatId, string title)
    {
        var chat = await bot.GetChat(chatId: telegramChatId);
        if (!chat.IsForum)
        {
            return new WebTemplateTopicDestination(null, TopicCreatedByBot: false);
        }

        try
        {
            var topic = await bot.CreateForumTopic(
                chatId: telegramChatId,
                name: $"🎲 Игры: {title}");
            return new WebTemplateTopicDestination(topic.MessageThreadId, TopicCreatedByBot: true);
        }
        catch (ApiRequestException ex) when (IsMissingForumTopicRightsError(ex.Message))
        {
            throw new InvalidOperationException(
                "Не удалось создать Telegram topic. Сделайте бота admin и включите право Manage Topics, затем повторите действие.",
                ex);
        }
    }

    private static bool IsMissingForumTopicRightsError(string apiError) =>
        apiError.Contains("not enough rights", StringComparison.OrdinalIgnoreCase) ||
        apiError.Contains("CHAT_ADMIN_REQUIRED", StringComparison.OrdinalIgnoreCase) ||
        apiError.Contains("not an administrator", StringComparison.OrdinalIgnoreCase);

    private async Task<List<WebDirectNotificationRecipient>> LoadSessionDirectRecipientsAsync(
        Npgsql.NpgsqlConnection conn,
        Guid sessionId)
    {
        return (await conn.QueryAsync<WebDirectNotificationRecipient>(
            """
            SELECT p.external_user_id::BIGINT AS TelegramId,
                   p.display_name AS DisplayName
            FROM session_participants sp
            JOIN players p ON p.id = sp.player_id
            WHERE sp.session_id = @SessionId
              AND sp.is_gm = false
              AND sp.registration_status = @Active
            """,
            new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
    }

    private async Task<List<WebDirectNotificationRecipient>> LoadBatchDirectRecipientsAsync(
        Npgsql.NpgsqlConnection conn,
        Guid batchId)
    {
        return (await conn.QueryAsync<WebDirectNotificationRecipient>(
            """
            SELECT DISTINCT p.external_user_id::BIGINT AS TelegramId,
                            p.display_name AS DisplayName
            FROM session_participants sp
            JOIN players p ON p.id = sp.player_id
            JOIN sessions s ON s.id = sp.session_id
            WHERE s.batch_id = @BatchId
              AND sp.is_gm = false
              AND sp.registration_status = @Active
            """,
            new { BatchId = batchId, Active = ParticipantRegistrationStatus.Active })).ToList();
    }

    private async Task SendDirectNotificationsAsync(
        IEnumerable<WebDirectNotificationRecipient> recipients,
        string htmlText,
        string notificationKind,
        Guid entityId)
    {
        foreach (var recipient in recipients)
        {
            try
            {
                await bot.SendMessage(
                    chatId: recipient.TelegramId,
                    text: htmlText,
                    parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
            }
            catch (Exception ex)
            {
                logger.LogWarning(
                    ex,
                    "Failed to send {NotificationKind} DM for entity {EntityId} to player {TelegramId} ({DisplayName})",
                    notificationKind,
                    entityId,
                    recipient.TelegramId,
                    recipient.DisplayName);
            }
        }
    }

    private async Task TryUpdateBatchMessageAsync(Guid batchId, long chatId, int messageId, string title)
    {
        try
        {
            await using var conn = await dataSource.OpenConnectionAsync();

            var sessions = (await conn.QueryAsync<SessionBatchDto>(
                "SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
                new { BatchId = batchId })).ToList();

            var participants = (await conn.QueryAsync<ParticipantBatchDto>(
                @"SELECT sp.session_id AS SessionId,
                         p.display_name AS DisplayName,
                         p.external_username AS TelegramUsername,
                         sp.registration_status AS RegistrationStatus
                  FROM session_participants sp
                  JOIN players p ON sp.player_id = p.id
                  JOIN sessions s ON sp.session_id = s.id
                  WHERE s.batch_id = @BatchId AND sp.is_gm = false
                  ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC",
                new { BatchId = batchId })).ToList();

            var view = SessionBatchViewBuilder.Build(title, sessions, participants);
            var renderResult = TelegramSessionBatchRenderer.Render(view);

            await BatchMessageEditor.EditBatchMessageAsync(
                bot,
                chatId: chatId,
                messageId: messageId,
                text: renderResult.Text,
                replyMarkup: renderResult.Markup);
        }
        catch (Exception ex)
        {
            logger.LogWarning(ex, "Failed to update batch message {MessageId} in chat {ChatId}", messageId, chatId);
        }
    }

    public async Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
        if (effectiveId is null)
            return null;

        return await conn.QuerySingleOrDefaultAsync<MasterProfileSettings>(
            """
            SELECT p.id AS PlayerId,
                   COALESCE(mp.display_name, p.display_name) AS DisplayName,
                   mp.public_slug AS PublicSlug,
                   COALESCE(mp.is_public, false) AS IsPublic,
                   mp.bio AS Bio
            FROM players p
            LEFT JOIN master_profiles mp ON mp.player_id = p.id
            WHERE p.id = @PlayerId
            """,
            new { PlayerId = effectiveId.Value });
    }

    public async Task UpdateMasterProfileSettingsAsync(
        string platform,
        string externalUserId,
        string? publicSlug,
        bool isPublic,
        string displayName,
        string? bio)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
        if (effectiveId is null)
            throw new InvalidOperationException("Current player not found.");

        try
        {
            await conn.ExecuteAsync(
                """
                INSERT INTO master_profiles (player_id, public_slug, is_public, display_name, bio)
                VALUES (@PlayerId, @PublicSlug, @IsPublic, @DisplayName, @Bio)
                ON CONFLICT (player_id) DO UPDATE
                SET public_slug = EXCLUDED.public_slug,
                    is_public = EXCLUDED.is_public,
                    display_name = EXCLUDED.display_name,
                    bio = EXCLUDED.bio,
                    updated_at = now()
                """,
                new
                {
                    PlayerId = effectiveId.Value,
                    PublicSlug = string.IsNullOrWhiteSpace(publicSlug) ? null : publicSlug,
                    IsPublic = isPublic,
                    DisplayName = displayName,
                    Bio = string.IsNullOrWhiteSpace(bio) ? null : bio.Trim()
                });
        }
        catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UniqueViolation)
        {
            throw new InvalidOperationException("Master profile slug is already in use.", ex);
        }
    }

    public async Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        var profile = await conn.QuerySingleOrDefaultAsync<PublicMasterProfileRow>(
            """
            SELECT mp.player_id AS PlayerId,
                   mp.public_slug AS Slug,
                   mp.display_name AS DisplayName,
                   mp.bio AS Bio
            FROM master_profiles mp
            WHERE mp.is_public = true
              AND mp.public_slug IS NOT NULL
              AND lower(mp.public_slug) = lower(@Slug)
            """,
            new { Slug = slug });

        if (profile is null)
            return null;

        var clubs = await GetPublicClubsForMasterAsync(conn, profile.PlayerId);
        var sessions = await GetPublicSessionsForMasterAsync(conn, profile.PlayerId, viewerPlayerId);
        return new PublicMasterProfile(profile.Slug, profile.DisplayName, profile.Bio, clubs, sessions);
    }

    private static async Task<List<PublicMasterClub>> GetPublicClubsForMasterAsync(
        NpgsqlConnection conn,
        Guid playerId)
    {
        return (await conn.QueryAsync<PublicMasterClub>(
            """
            SELECT DISTINCT g.id AS GroupId,
                   COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS Name,
                   g.public_slug AS Slug
            FROM game_groups g
            JOIN group_managers gm ON gm.group_id = g.id
            LEFT JOIN player_links manager_link ON manager_link.secondary_player_id = gm.player_id
            LEFT JOIN LATERAL (
                SELECT s.title
                FROM sessions s
                WHERE s.group_id = g.id
                ORDER BY s.scheduled_at DESC
                LIMIT 1
            ) latest_session ON true
            WHERE COALESCE(manager_link.primary_player_id, gm.player_id) = @PlayerId
              AND g.public_schedule_enabled = true
              AND g.public_slug IS NOT NULL
            ORDER BY Name
            """,
            new { PlayerId = playerId })).ToList();
    }

    private static async Task<List<WebPublicSession>> GetPublicSessionsForMasterAsync(
        NpgsqlConnection conn,
        Guid playerId,
        Guid? viewerPlayerId)
    {
        return (await conn.QueryAsync<WebPublicSession>(
            """
            SELECT DISTINCT s.id AS Id,
                   s.group_id AS GroupId,
                   COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS GroupName,
                   g.public_slug AS GroupSlug,
                   s.title AS Title,
                   s.scheduled_at AS ScheduledAt,
                   s.status AS Status,
                   s.max_players AS MaxPlayers,
                   COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
                   COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
                   s.publication_mode AS PublicationMode,
                   (s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
                   mp.public_slug AS MasterProfileSlug,
                   mp.display_name AS MasterDisplayName
            FROM sessions s
            JOIN game_groups g ON g.id = s.group_id
            JOIN group_managers gm ON gm.group_id = g.id
            LEFT JOIN player_links manager_link ON manager_link.secondary_player_id = gm.player_id
            JOIN master_profiles mp ON mp.player_id = COALESCE(manager_link.primary_player_id, gm.player_id)
                AND mp.player_id = @PlayerId
                AND mp.is_public = true
                AND mp.public_slug IS NOT NULL
            LEFT JOIN LATERAL (
                SELECT recent.title
                FROM sessions recent
                WHERE recent.group_id = g.id
                ORDER BY recent.scheduled_at DESC
                LIMIT 1
            ) latest_session ON true
            LEFT JOIN LATERAL (
                SELECT COUNT(*) AS count
                FROM session_participants sp
                WHERE sp.session_id = s.id
                  AND sp.is_gm = false
                  AND sp.registration_status = @Active
            ) active_counts ON true
            LEFT JOIN LATERAL (
                SELECT COUNT(*) AS count
                FROM session_participants sp
                WHERE sp.session_id = s.id
                  AND sp.is_gm = false
                  AND sp.registration_status = @Waitlisted
            ) waitlist_counts ON true
            WHERE g.public_schedule_enabled = true
              AND g.public_slug IS NOT NULL
              AND s.scheduled_at > now() - interval '4 hours'
              AND s.status <> @Cancelled
              AND (
                    s.publication_mode IN ('Catalog', 'Both')
                    OR (
                        s.publication_mode = 'ClubOnly'
                        AND @ViewerPlayerId IS NOT NULL
                        AND EXISTS (
                            SELECT 1 FROM club_memberships cm
                            WHERE cm.group_id = s.group_id
                              AND cm.player_id = @ViewerPlayerId
                              AND cm.status = 'Active'
                        )
                    )
                  )
            ORDER BY s.scheduled_at
            """,
            new
            {
                PlayerId = playerId,
                Active = ParticipantRegistrationStatus.Active,
                Waitlisted = ParticipantRegistrationStatus.Waitlisted,
                Cancelled = SessionStatus.Cancelled,
                ViewerPlayerId = viewerPlayerId
            })).ToList();
    }

    private static async Task<List<WebPublicSession>> GetPublicSessionsForGroupAsync(
        NpgsqlConnection conn,
        Guid groupId,
        Guid? viewerPlayerId)
    {
        return (await conn.QueryAsync<WebPublicSession>(
            """
            SELECT s.id AS Id,
                   s.group_id AS GroupId,
                   COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS GroupName,
                   g.public_slug AS GroupSlug,
                   s.title AS Title,
                   s.scheduled_at AS ScheduledAt,
                   s.status AS Status,
                   s.max_players AS MaxPlayers,
                   COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
                   COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
                   s.publication_mode AS PublicationMode,
                   (s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
                   mp.public_slug AS MasterProfileSlug,
                   mp.display_name AS MasterDisplayName
            FROM sessions s
            JOIN game_groups g ON g.id = s.group_id
            LEFT JOIN LATERAL (
                SELECT recent.title
                FROM sessions recent
                WHERE recent.group_id = g.id
                ORDER BY recent.scheduled_at DESC
                LIMIT 1
            ) latest_session ON true
            LEFT JOIN LATERAL (
                SELECT COUNT(*) AS count
                FROM session_participants sp
                WHERE sp.session_id = s.id
                  AND sp.is_gm = false
                  AND sp.registration_status = @Active
            ) active_counts ON true
            LEFT JOIN LATERAL (
                SELECT COUNT(*) AS count
                FROM session_participants sp
                WHERE sp.session_id = s.id
                  AND sp.is_gm = false
                  AND sp.registration_status = @Waitlisted
            ) waitlist_counts ON true
            LEFT JOIN LATERAL (
                SELECT gm.player_id
                FROM group_managers gm
                WHERE gm.group_id = g.id
                  AND gm.role = @OwnerRole
                ORDER BY gm.created_at
                LIMIT 1
            ) owner_manager ON true
            LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
            LEFT JOIN master_profiles mp ON mp.player_id = COALESCE(owner_link.primary_player_id, owner_manager.player_id)
                AND mp.is_public = true
                AND mp.public_slug IS NOT NULL
            WHERE s.group_id = @GroupId
              AND g.public_schedule_enabled = true
              AND g.public_slug IS NOT NULL
              AND s.scheduled_at > now() - interval '4 hours'
              AND s.status <> @Cancelled
              AND (
                    s.publication_mode IN ('Catalog', 'Both')
                    OR (
                        s.publication_mode = 'ClubOnly'
                        AND @ViewerPlayerId IS NOT NULL
                        AND EXISTS (
                            SELECT 1 FROM club_memberships cm
                            WHERE cm.group_id = s.group_id
                              AND cm.player_id = @ViewerPlayerId
                              AND cm.status = 'Active'
                        )
                    )
                  )
            ORDER BY s.scheduled_at
            """,
            new
            {
                GroupId = groupId,
                Active = ParticipantRegistrationStatus.Active,
                Waitlisted = ParticipantRegistrationStatus.Waitlisted,
                Cancelled = SessionStatus.Cancelled,
                OwnerRole = GroupManagerRoleExtensions.OwnerValue,
                ViewerPlayerId = viewerPlayerId
            })).ToList();
    }

    private static async Task<WebBatchInfo?> GetBatchInfoAsync(
        Npgsql.NpgsqlConnection conn,
        Guid batchId,
        Guid groupId,
        Npgsql.NpgsqlTransaction transaction)
    {
        return await conn.QuerySingleOrDefaultAsync<WebBatchInfo>(
            """
            SELECT s.batch_id AS BatchId,
                   s.group_id AS GroupId,
                   (array_agg(s.title ORDER BY s.scheduled_at))[1] AS Title,
                   (array_agg(s.join_link ORDER BY s.scheduled_at))[1] AS JoinLink,
                   g.external_group_id::BIGINT AS TelegramChatId,
                   (array_agg(s.batch_message_id ORDER BY s.scheduled_at))[1] AS BatchMessageId,
                   (array_agg(s.thread_id ORDER BY s.scheduled_at))[1] AS ThreadId,
                   (array_agg(s.notification_mode ORDER BY s.scheduled_at))[1] AS NotificationMode
            FROM sessions s
            JOIN game_groups g ON g.id = s.group_id
            WHERE s.batch_id = @BatchId
              AND s.group_id = @GroupId
            GROUP BY s.batch_id, s.group_id, g.external_group_id
            """,
            new { BatchId = batchId, GroupId = groupId },
            transaction);
    }

    // --- Identity linking (issue #35) ---

    public async Task<Guid?> ResolveEffectivePlayerIdAsync(string platform, string externalUserId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        return await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
    }

    public async Task<List<LinkedIdentity>> GetLinkedIdentitiesAsync(string platform, string externalUserId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();

        var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
        if (effectiveId is null)
            return [];

        return (await conn.QueryAsync<LinkedIdentity>(
            """
            SELECT p.platform AS Platform,
                   p.external_user_id AS ExternalUserId,
                   p.display_name AS DisplayName,
                   p.external_username AS ExternalUsername,
                   p.avatar_url AS AvatarUrl,
                   COALESCE(pl.linked_at, p.created_at) AS LinkedAt
            FROM players p
            LEFT JOIN player_links pl ON pl.secondary_player_id = p.id
            WHERE pl.primary_player_id = @EffectiveId
               OR p.id = @EffectiveId
            ORDER BY CASE WHEN p.id = @EffectiveId THEN 0 ELSE 1 END,
                     p.platform
            """,
            new { EffectiveId = effectiveId.Value })).ToList();
    }

    public async Task LinkIdentityAsync(
        string currentPlatform, string currentExternalUserId,
        string targetPlatform, string targetExternalUserId,
        string? currentName)
    {
        if (currentPlatform == targetPlatform && currentExternalUserId == targetExternalUserId)
            throw new InvalidOperationException("Cannot link an identity to itself.");

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

        // Resolve current player (must exist — they are logged in)
        var currentPlayerId = await _ResolvePlayerIdAsync(conn, currentPlatform, currentExternalUserId);
        if (currentPlayerId is null)
            throw new InvalidOperationException("Current player not found.");

        // Upsert target player so it exists
        var targetDisplayName = currentName ?? $"{targetPlatform} {targetExternalUserId}";
        var targetPlayerId = await _UpsertPlayerAndGetIdAsync(conn, targetPlatform, targetExternalUserId, targetDisplayName, null, transaction);

        // Check if target is already a primary of another link chain (conflict)
        var targetIsPrimary = await conn.ExecuteScalarAsync<bool>(
            """
            SELECT EXISTS (
                SELECT 1 FROM player_links WHERE primary_player_id = @TargetPlayerId
            )
            """,
            new { TargetPlayerId = targetPlayerId }, transaction);

        if (targetIsPrimary)
        {
            await _LogIdentityAuditAsync(conn, currentPlayerId.Value, "link_attempt_conflict",
                targetPlatform, targetExternalUserId, currentPlayerId.Value, transaction);
            await transaction.CommitAsync();
            throw new InvalidOperationException("Target identity is already the primary account of another linked set.");
        }

        // Check if current is already a secondary (then their primary becomes the effective primary)
        var currentPrimaryId = await conn.QuerySingleOrDefaultAsync<Guid?>(
            """
            SELECT primary_player_id
            FROM player_links
            WHERE secondary_player_id = @CurrentPlayerId
            """,
            new { CurrentPlayerId = currentPlayerId.Value }, transaction);

        var effectiveCurrentPrimary = currentPrimaryId ?? currentPlayerId.Value;

        // Check if target is already linked to someone else as secondary
        var existingLink = await conn.QuerySingleOrDefaultAsync<Guid?>(
            """
            SELECT primary_player_id
            FROM player_links
            WHERE secondary_player_id = @TargetPlayerId
            """,
            new { TargetPlayerId = targetPlayerId }, transaction);

        if (existingLink is not null && existingLink.Value != effectiveCurrentPrimary)
        {
            await _LogIdentityAuditAsync(conn, effectiveCurrentPrimary, "link_attempt_conflict",
                targetPlatform, targetExternalUserId, currentPlayerId.Value, transaction);
            await transaction.CommitAsync();
            throw new InvalidOperationException("Target identity is already linked to another account.");
        }

        var effectivePrimary = currentPrimaryId ?? currentPlayerId.Value;

        // Check if already linked
        var alreadyLinked = await conn.ExecuteScalarAsync<bool>(
            """
            SELECT EXISTS (
                SELECT 1 FROM player_links
                WHERE primary_player_id = @EffectivePrimary AND secondary_player_id = @TargetPlayerId
            )
            """,
            new { EffectivePrimary = effectivePrimary, TargetPlayerId = targetPlayerId }, transaction);

        if (alreadyLinked)
        {
            await transaction.CommitAsync();
            return; // Already linked, idempotent
        }

        await conn.ExecuteAsync(
            """
            INSERT INTO player_links (primary_player_id, secondary_player_id, linked_by_player_id)
            VALUES (@PrimaryPlayerId, @SecondaryPlayerId, @LinkedByPlayerId)
            """,
            new
            {
                PrimaryPlayerId = effectivePrimary,
                SecondaryPlayerId = targetPlayerId,
                LinkedByPlayerId = currentPlayerId.Value
            },
            transaction);

        await _LogIdentityAuditAsync(conn, effectivePrimary, "link",
            targetPlatform, targetExternalUserId, currentPlayerId.Value, transaction);

        await transaction.CommitAsync();
    }

    public async Task UnlinkIdentityAsync(
        string currentPlatform, string currentExternalUserId,
        string targetPlatform, string targetExternalUserId)
    {
        if (currentPlatform == targetPlatform && currentExternalUserId == targetExternalUserId)
            throw new InvalidOperationException("Cannot unlink your own primary identity from itself.");

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

        var currentPlayerId = await _ResolvePlayerIdAsync(conn, currentPlatform, currentExternalUserId);
        if (currentPlayerId is null)
            throw new InvalidOperationException("Current player not found.");

        var targetPlayerId = await _ResolvePlayerIdAsync(conn, targetPlatform, targetExternalUserId);
        if (targetPlayerId is null)
            throw new InvalidOperationException("Target identity not found.");

        var effectivePrimary = await _ResolveEffectivePlayerIdAsync(conn, currentPlatform, currentExternalUserId);
        if (effectivePrimary is null)
            throw new InvalidOperationException("Effective primary not found.");

        // Only the primary account owner (or the linked identity itself) can unlink
        var rows = await conn.ExecuteAsync(
            """
            DELETE FROM player_links
            WHERE primary_player_id = @EffectivePrimary
              AND secondary_player_id = @TargetPlayerId
            """,
            new { EffectivePrimary = effectivePrimary.Value, TargetPlayerId = targetPlayerId.Value },
            transaction);

        if (rows == 0)
        {
            await transaction.RollbackAsync();
            throw new InvalidOperationException("Identity is not linked to your account.");
        }

        await _LogIdentityAuditAsync(conn, effectivePrimary.Value, "unlink",
            targetPlatform, targetExternalUserId, currentPlayerId.Value, transaction);

        await transaction.CommitAsync();
    }

    public async Task UpsertPlayerAsync(string platform, string externalUserId, string displayName, string? avatarUrl)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        await _UpsertPlayerAndGetIdAsync(conn, platform, externalUserId, displayName, avatarUrl, null);
    }

    public async Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        await _UpsertPlayerAndGetIdAsync(conn, "Discord", discordId, displayName, avatarUrl, null);
    }

    // --- Private 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 static async Task<Guid> _UpsertPlayerAndGetIdAsync(
        NpgsqlConnection conn, string platform, string externalUserId,
        string displayName, string? avatarUrl, NpgsqlTransaction? transaction)
    {
        return await conn.QuerySingleAsync<Guid>(
            """
            INSERT INTO players (display_name, platform, external_user_id, external_username, avatar_url)
            VALUES (@DisplayName, @Platform, @ExternalUserId, @DisplayName, @AvatarUrl)
            ON CONFLICT (platform, external_user_id)
            WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
            DO UPDATE
            SET display_name = EXCLUDED.display_name,
                external_username = EXCLUDED.external_username,
                avatar_url = COALESCE(EXCLUDED.avatar_url, players.avatar_url)
            RETURNING id
            """,
            new { DisplayName = displayName, Platform = platform, ExternalUserId = externalUserId, AvatarUrl = avatarUrl },
            transaction);
    }

    private static async Task _LogIdentityAuditAsync(
        NpgsqlConnection conn, Guid playerId, string action,
        string? targetPlatform, string? targetExternalUserId,
        Guid? performedByPlayerId, NpgsqlTransaction? transaction)
    {
        await conn.ExecuteAsync(
            """
            INSERT INTO identity_audit_log (player_id, action, target_platform, target_external_user_id, performed_by_player_id)
            VALUES (@PlayerId, @Action, @TargetPlatform, @TargetExternalUserId, @PerformedByPlayerId)
            """,
            new { PlayerId = playerId, Action = action, TargetPlatform = targetPlatform, TargetExternalUserId = targetExternalUserId, PerformedByPlayerId = performedByPlayerId },
            transaction);
    }

    // --- Private club showcases / memberships (issue #110) ---

    public async Task<bool> IsActiveClubMemberAsync(Guid groupId, Guid playerId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        var count = await conn.ExecuteScalarAsync<long>(
            """
            SELECT COUNT(*) FROM club_memberships
            WHERE group_id = @GroupId
              AND player_id = @PlayerId
              AND status = 'Active'
            """,
            new { GroupId = groupId, PlayerId = playerId });
        return count > 0;
    }

    public async Task<Guid?> GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        return await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
    }

    public async Task<IReadOnlyList<WebClubShowcaseSession>> GetClubShowcaseSessionsAsync(
        Guid groupId, Guid? viewerPlayerId, int page, int pageSize)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        return (await conn.QueryAsync<WebClubShowcaseSession>(
            """
            SELECT s.id AS Id,
                   s.title AS Title,
                   s.scheduled_at AS ScheduledAt,
                   s.status AS Status,
                   s.system AS System,
                   s.is_one_shot AS IsOneShot,
                   s.format AS Format,
                   s.duration_minutes AS DurationMinutes,
                   s.cover_image_url AS CoverImageUrl,
                   s.max_players AS MaxPlayers,
                   COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
                   COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
                   s.publication_mode AS PublicationMode,
                   (s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
                   s.description AS Description,
                   s.allow_direct_registration AS AllowDirectRegistration
            FROM sessions s
            JOIN game_groups g ON g.id = s.group_id
            LEFT JOIN LATERAL (
                SELECT COUNT(*) AS count
                FROM session_participants sp
                WHERE sp.session_id = s.id
                  AND sp.is_gm = false
                  AND sp.registration_status = @Active
            ) active_counts ON true
            LEFT JOIN LATERAL (
                SELECT COUNT(*) AS count
                FROM session_participants sp
                WHERE sp.session_id = s.id
                  AND sp.is_gm = false
                  AND sp.registration_status = @Waitlisted
            ) waitlist_counts ON true
            WHERE s.group_id = @GroupId
              AND g.public_schedule_enabled = true
              AND g.public_slug IS NOT NULL
              AND s.scheduled_at > now() - interval '4 hours'
              AND s.status <> @Cancelled
              AND (
                    s.publication_mode IN ('Catalog', 'Both')
                    OR (
                        s.publication_mode = 'ClubOnly'
                        AND @ViewerPlayerId IS NOT NULL
                        AND EXISTS (
                            SELECT 1 FROM club_memberships cm
                            WHERE cm.group_id = s.group_id
                              AND cm.player_id = @ViewerPlayerId
                              AND cm.status = 'Active'
                        )
                    )
                  )
            ORDER BY s.scheduled_at ASC
            OFFSET @Offset LIMIT @PageSize
            """,
            new
            {
                GroupId = groupId,
                Active = ParticipantRegistrationStatus.Active,
                Waitlisted = ParticipantRegistrationStatus.Waitlisted,
                Cancelled = SessionStatus.Cancelled,
                ViewerPlayerId = viewerPlayerId,
                Offset = page * pageSize,
                PageSize = pageSize
            })).ToList();
    }

    public async Task<int> GetPendingApplicationsCountAsync(Guid groupId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        return await conn.ExecuteScalarAsync<int>(
            """
            SELECT COUNT(*)::int FROM club_memberships
            WHERE group_id = @GroupId AND status = 'Pending'
            """,
            new { GroupId = groupId });
    }

    public async Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        return (await conn.QueryAsync<WebPendingApplication>(
            """
            SELECT cm.id AS MembershipId,
                   p.id AS PlayerId,
                   p.display_name AS DisplayName,
                   p.platform AS Platform,
                   p.external_username AS ExternalUsername,
                   cm.message AS Message,
                   cm.applied_at AS AppliedAt
            FROM club_memberships cm
            JOIN players p ON p.id = cm.player_id
            WHERE cm.group_id = @GroupId
              AND cm.status = 'Pending'
            ORDER BY cm.applied_at ASC
            """,
            new { GroupId = groupId })).ToList();
    }

    public async Task<List<WebMembership>> GetMembershipsForPlayerAsync(Guid playerId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        return (await conn.QueryAsync<WebMembership>(
            """
            SELECT cm.id AS MembershipId,
                   cm.group_id AS GroupId,
                   COALESCE(NULLIF(g.name, g.external_group_id), g.name) AS GroupName,
                   g.public_slug AS GroupSlug,
                   cm.status AS Status,
                   cm.role AS Role,
                   cm.message AS Message,
                   cm.applied_at AS AppliedAt,
                   cm.decided_at AS DecidedAt,
                   decider.display_name AS DecidedByDisplayName
            FROM club_memberships cm
            JOIN game_groups g ON g.id = cm.group_id
            LEFT JOIN players decider ON decider.id = cm.decided_by
            WHERE cm.player_id = @PlayerId
            ORDER BY cm.applied_at DESC
            """,
            new { PlayerId = playerId })).ToList();
    }

    public async Task<Guid> ApplyForMembershipAsync(Guid groupId, Guid playerId, string? message)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        var existing = await conn.ExecuteScalarAsync<int>(
            """
            SELECT COUNT(*)::int FROM club_memberships
            WHERE group_id = @GroupId AND player_id = @PlayerId AND status IN ('Pending', 'Active')
            """,
            new { GroupId = groupId, PlayerId = playerId });
        if (existing > 0)
        {
            throw new InvalidOperationException("Active or pending application already exists for this player.");
        }

        return await conn.ExecuteScalarAsync<Guid>(
            """
            INSERT INTO club_memberships (group_id, player_id, status, message)
            VALUES (@GroupId, @PlayerId, 'Pending', @Message)
            RETURNING id
            """,
            new { GroupId = groupId, PlayerId = playerId, Message = message });
    }

    public async Task ApproveMembershipAsync(Guid membershipId, Guid approverPlayerId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        var rows = await conn.ExecuteAsync(
            """
            UPDATE club_memberships
            SET status = 'Active', decided_at = now(), decided_by = @ApproverPlayerId
            WHERE id = @MembershipId AND status = 'Pending'
            """,
            new { MembershipId = membershipId, ApproverPlayerId = approverPlayerId });
        if (rows == 0)
        {
            throw new InvalidOperationException($"Membership {membershipId} not in Pending state.");
        }
    }

    public async Task RejectMembershipAsync(Guid membershipId, Guid approverPlayerId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        var rows = await conn.ExecuteAsync(
            """
            UPDATE club_memberships
            SET status = 'Rejected', decided_at = now(), decided_by = @ApproverPlayerId
            WHERE id = @MembershipId AND status = 'Pending'
            """,
            new { MembershipId = membershipId, ApproverPlayerId = approverPlayerId });
        if (rows == 0)
        {
            throw new InvalidOperationException($"Membership {membershipId} not in Pending state.");
        }
    }

    public async Task LeaveClubMembershipAsync(Guid membershipId, Guid playerId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        // Active membership: withdraw by setting status = 'Left'.
        var rows = await conn.ExecuteAsync(
            """
            UPDATE club_memberships
            SET status = 'Left', decided_at = now()
            WHERE id = @MembershipId AND player_id = @PlayerId AND status = 'Active'
            """,
            new { MembershipId = membershipId, PlayerId = playerId });
        if (rows > 0)
        {
            return;
        }

        // Pending application: cancel by setting status = 'Rejected' so the user can re-apply later.
        var cancelled = await conn.ExecuteAsync(
            """
            UPDATE club_memberships
            SET status = 'Rejected', decided_at = now()
            WHERE id = @MembershipId AND player_id = @PlayerId AND status = 'Pending'
            """,
            new { MembershipId = membershipId, PlayerId = playerId });
        if (cancelled == 0)
        {
            throw new InvalidOperationException($"Membership {membershipId} is not Active or Pending for this player.");
        }
    }

    public async Task<Guid?> GetGroupIdForMembershipAsync(Guid membershipId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        return await conn.QuerySingleOrDefaultAsync<Guid?>(
            """
            SELECT group_id FROM club_memberships WHERE id = @MembershipId
            """,
            new { MembershipId = membershipId });
    }
}
