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

namespace GmRelay.Web.Services;

public sealed record WebGameGroup(
    Guid Id,
    long TelegramChatId,
    string Name,
    long GmTelegramId,
    string ManagerRole = GroupManagerRoleExtensions.OwnerValue);

public sealed record WebGroupManager(
    long TelegramId,
    string DisplayName,
    string? TelegramUsername,
    string Role,
    DateTime 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);

public sealed record WebParticipant(
    Guid Id,
    long TelegramId,
    string DisplayName,
    string? TelegramUsername,
    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);
internal sealed record WebTemplateGroupDto(long TelegramChatId);

public sealed class SessionService(
    NpgsqlDataSource dataSource,
    ITelegramBotClient bot,
    ILogger<SessionService> logger) : ISessionStore
{
    public async Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        return (await conn.QueryAsync<WebGameGroup>(
            """
            SELECT g.id,
                   g.telegram_chat_id AS TelegramChatId,
                   g.name,
                   g.gm_telegram_id AS GmTelegramId,
                   gm.role AS ManagerRole
            FROM group_managers gm
            JOIN players p ON p.id = gm.player_id
            JOIN game_groups g ON g.id = gm.group_id
            WHERE p.telegram_id = @GmId
            ORDER BY g.name
            """,
            new { GmId = gmId })).ToList();
    }

    public async Task<WebGameGroup?> GetGroupAsync(Guid groupId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        return await conn.QuerySingleOrDefaultAsync<WebGameGroup>(
            """
            SELECT g.id,
                   g.telegram_chat_id AS TelegramChatId,
                   g.name,
                   g.gm_telegram_id AS GmTelegramId,
                   @OwnerRole AS ManagerRole
            FROM game_groups g
            WHERE g.id = @GroupId
            """,
            new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
    }

    public async Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        return await conn.ExecuteScalarAsync<bool>(
            """
            SELECT EXISTS (
                SELECT 1
                FROM group_managers gm
                JOIN players p ON p.id = gm.player_id
                WHERE gm.group_id = @GroupId
                  AND p.telegram_id = @TelegramId
            )
            """,
            new { GroupId = groupId, TelegramId = telegramId });
    }

    public async Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        return await conn.ExecuteScalarAsync<bool>(
            """
            SELECT EXISTS (
                SELECT 1
                FROM group_managers gm
                JOIN players p ON p.id = gm.player_id
                WHERE gm.group_id = @GroupId
                  AND p.telegram_id = @TelegramId
                  AND gm.role = @OwnerRole
            )
            """,
            new { GroupId = groupId, TelegramId = telegramId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
    }

    public async Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        return (await conn.QueryAsync<WebGroupManager>(
            """
            SELECT p.telegram_id AS TelegramId,
                   p.display_name AS DisplayName,
                   p.telegram_username AS TelegramUsername,
                   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.telegram_username AS TelegramUsername,
                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.telegram_username
            ORDER BY AttendanceRate DESC, ConfirmedCount DESC
            """,
            new { GroupId = groupId })).ToList();
    }

    public async Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, 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_telegram_id, actor_name, change_type, old_value, new_value)
            VALUES (@SessionId, @ActorTelegramId, @ActorName, @ChangeType, @OldValue, @NewValue)
            """,
            new { SessionId = sessionId, ActorTelegramId = actorTelegramId, 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_telegram_id AS ActorTelegramId, 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,
        long ownerTelegramId,
        long coGmTelegramId,
        string displayName,
        string? telegramUsername)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        await using var transaction = await conn.BeginTransactionAsync();

        await conn.ExecuteAsync(
            """
            INSERT INTO players (telegram_id, display_name, telegram_username)
            VALUES (@TelegramId, @DisplayName, @TelegramUsername)
            ON CONFLICT (telegram_id) DO UPDATE
            SET display_name = EXCLUDED.display_name,
                telegram_username = EXCLUDED.telegram_username
            """,
            new
            {
                TelegramId = coGmTelegramId,
                DisplayName = displayName,
                TelegramUsername = telegramUsername
            },
            transaction);

        await conn.ExecuteAsync(
            """
            INSERT INTO group_managers (group_id, player_id, role, added_by_player_id)
            SELECT @GroupId,
                   co_gm.id,
                   @CoGmRole,
                   owner_player.id
            FROM players co_gm
            LEFT JOIN players owner_player ON owner_player.telegram_id = @OwnerTelegramId
            WHERE co_gm.telegram_id = @CoGmTelegramId
            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,
                OwnerTelegramId = ownerTelegramId,
                CoGmTelegramId = coGmTelegramId,
                OwnerRole = GroupManagerRoleExtensions.OwnerValue,
                CoGmRole = GroupManagerRoleExtensions.CoGmValue
            },
            transaction);

        await transaction.CommitAsync();
    }

    public async Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId)
    {
        await using var conn = await dataSource.OpenConnectionAsync();
        await conn.ExecuteAsync(
            """
            DELETE FROM group_managers gm
            USING players p
            WHERE gm.player_id = p.id
              AND gm.group_id = @GroupId
              AND p.telegram_id = @CoGmTelegramId
              AND gm.role = @CoGmRole
            """,
            new
            {
                GroupId = groupId,
                CoGmTelegramId = coGmTelegramId,
                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.telegram_chat_id 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
              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.telegram_chat_id 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
              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.telegram_chat_id AS TelegramChatId,
                     s.max_players AS MaxPlayers,
                     0 AS ActivePlayerCount,
                     0 AS WaitlistedPlayerCount,
                     s.notification_mode AS NotificationMode
              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(oldSession.TelegramChatId, 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.telegram_chat_id AS TelegramChatId,
                     s.max_players AS MaxPlayers,
                     0 AS ActivePlayerCount,
                     0 AS WaitlistedPlayerCount,
                     s.notification_mode AS NotificationMode
              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(
            session.TelegramChatId,
            $"⬆️ <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,
                   p.telegram_id AS TelegramId,
                   p.display_name AS DisplayName,
                   p.telegram_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.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.telegram_chat_id AS TelegramChatId,
                     s.max_players AS MaxPlayers,
                     0 AS ActivePlayerCount,
                     0 AS WaitlistedPlayerCount,
                     s.notification_mode AS NotificationMode
              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.telegram_id AS TelegramId,
                   p.display_name AS DisplayName,
                   p.telegram_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(
            session.TelegramChatId,
            $"🚪 <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(
                session.TelegramChatId,
                $"⬆️ <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.telegram_chat_id AS TelegramChatId,
                   s.thread_id AS ThreadId,
                   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(firstSession.TelegramChatId, 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.telegram_chat_id AS TelegramChatId,
                   s.thread_id AS ThreadId,
                   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, max_players, notification_mode)
                VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @MaxPlayers, @NotificationMode)
                RETURNING id
                """,
                new
                {
                    BatchId = newBatchId,
                    sourceSession.GroupId,
                    Title = batchTitle,
                    JoinLink = batchJoinLink,
                    ScheduledAt = scheduledAt,
                    Status = SessionStatus.Planned,
                    ThreadId = threadId,
                    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 telegram_chat_id AS TelegramChatId FROM game_groups WHERE id = @GroupId",
            new { GroupId = groupId },
            transaction);

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

        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, max_players, notification_mode)
                VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @MaxPlayers, @NotificationMode)
                RETURNING id
                """,
                new
                {
                    BatchId = batchId,
                    GroupId = groupId,
                    template.Title,
                    template.JoinLink,
                    ScheduledAt = scheduledAt,
                    Status = SessionStatus.Planned,
                    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,
            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<List<WebDirectNotificationRecipient>> LoadSessionDirectRecipientsAsync(
        Npgsql.NpgsqlConnection conn,
        Guid sessionId)
    {
        return (await conn.QueryAsync<WebDirectNotificationRecipient>(
            """
            SELECT p.telegram_id 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.telegram_id 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.telegram_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);
        }
    }

    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.telegram_chat_id 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.telegram_chat_id
            """,
            new { BatchId = batchId, GroupId = groupId },
            transaction);
    }
}
