using System.Security.Cryptography;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Ikar.Server.Data;
using Ikar.Server.Data.Entities;
using Ikar.Server.Infrastructure.Hubs;
using Ikar.Server.Infrastructure.Storage;
using Ikar.Shared;

namespace Ikar.Server.Infrastructure.Admin;

public sealed class AdminService(
    IkarDbContext dbContext,
    IWebHostEnvironment environment,
    ServerRuntimeState runtimeState,
    AttachmentStorageService attachmentStorageService,
    IHubContext<MessengerHub> hubContext)
{
    public async Task<AdminStatusDto> GetStatusAsync(CancellationToken cancellationToken)
    {
        var now = DateTimeOffset.UtcNow;
        var databaseReachable = await dbContext.Database.CanConnectAsync(cancellationToken);
        var serverVersion = typeof(Program).Assembly.GetName().Version?.ToString() ?? "unknown";
        var attachmentBytes = await dbContext.MessageAttachments.SumAsync(x => (long?)x.FileSizeBytes, cancellationToken) ?? 0;

        return new AdminStatusDto(
            environment.EnvironmentName,
            serverVersion,
            runtimeState.StartedAtUtc,
            now,
            databaseReachable,
            dbContext.Database.GetConnectionString(),
            await dbContext.Users.CountAsync(cancellationToken),
            await dbContext.Users.CountAsync(x => x.IsBlocked, cancellationToken),
            await dbContext.Users.CountAsync(x => x.IsBot, cancellationToken),
            await dbContext.Bots.CountAsync(cancellationToken),
            await dbContext.Bots.CountAsync(x => x.IsEnabled, cancellationToken),
            await dbContext.Chats.CountAsync(cancellationToken),
            await dbContext.Chats.CountAsync(x => x.Type == ChatType.Direct, cancellationToken),
            await dbContext.Chats.CountAsync(x => x.Type == ChatType.Group, cancellationToken),
            await dbContext.Chats.CountAsync(x => x.Type == ChatType.Channel, cancellationToken),
            await dbContext.Chats.CountAsync(x => x.HasProtectedContent, cancellationToken),
            await dbContext.Messages.CountAsync(cancellationToken),
            await dbContext.Messages.CountAsync(x => x.HasProtectedContent, cancellationToken),
            await dbContext.Messages.CountAsync(x => x.ExpiresAt != null && x.ExpiresAt > now && x.DeletedAt == null, cancellationToken),
            await dbContext.Messages.CountAsync(x => x.PinnedAt != null && x.DeletedAt == null, cancellationToken),
            await dbContext.MessageAttachments.CountAsync(cancellationToken),
            attachmentBytes,
            await dbContext.Stories.CountAsync(cancellationToken),
            await dbContext.Stories.CountAsync(x => x.DeletedAt == null && x.ExpiresAt > now, cancellationToken),
            await dbContext.Stories.CountAsync(x => x.HasProtectedContent && x.DeletedAt == null, cancellationToken),
            await dbContext.UserSessions.CountAsync(cancellationToken),
            await dbContext.PushDevices.CountAsync(cancellationToken),
            await dbContext.ChatJoinRequests.CountAsync(x => x.Status == ChatJoinRequestStatus.Pending, cancellationToken),
            await dbContext.ChatInviteLinks.CountAsync(x => !x.IsRevoked, cancellationToken),
            await dbContext.ChatTopics.CountAsync(cancellationToken),
            await dbContext.ModerationLogEntries.CountAsync(cancellationToken),
            Environment.ProcessId);
    }

    public async Task<AdminAndroidUpdateCompatibilityReportDto> GetAndroidUpdateCompatibilityReportAsync(CancellationToken cancellationToken)
    {
        var now = DateTimeOffset.UtcNow;
        var since24Hours = now.AddHours(-24);
        var since7Days = now.AddDays(-7);

        var allHits = await dbContext.AndroidUpdateCompatibilityHits
            .AsNoTracking()
            .ToListAsync(cancellationToken);

        var hitsLast7Days = allHits
            .Where(x => x.HitAt >= since7Days)
            .ToList();

        var totalHitsLast24Hours = hitsLast7Days.Count(x => x.HitAt >= since24Hours);
        var totalHitsLast7Days = hitsLast7Days.Count;
        var hitsWithoutVersionLast24Hours = hitsLast7Days.Count(x => x.HitAt >= since24Hours && x.ClientVersionCode == null);
        var hitsWithoutVersionLast7Days = hitsLast7Days.Count(x => x.ClientVersionCode == null);
        var latestHitAt = hitsLast7Days
            .Select(x => (DateTimeOffset?)x.HitAt)
            .DefaultIfEmpty(null)
            .Max();

        var routeBuckets = hitsLast7Days
            .GroupBy(x => x.Route)
            .Select(group => new AdminAndroidUpdateCompatibilityRouteDto(
                group.Key,
                group.Count(x => x.HitAt >= since24Hours),
                group.Count(),
                group.Max(x => (DateTimeOffset?)x.HitAt)))
            .OrderByDescending(x => x.HitsLast7Days)
            .ThenByDescending(x => x.LatestHitAt)
            .ToList();

        var versionBuckets = hitsLast7Days
            .GroupBy(x => new { x.ClientPlatform, x.ClientVersionCode, x.ClientVersionName })
            .Select(group => new AdminAndroidUpdateCompatibilityVersionDto(
                group.Key.ClientPlatform ?? "unknown",
                group.Key.ClientVersionCode,
                group.Key.ClientVersionName,
                group.Count(x => x.HitAt >= since24Hours),
                group.Count(),
                group.Max(x => (DateTimeOffset?)x.HitAt)))
            .OrderByDescending(x => x.HitsLast7Days)
            .ThenByDescending(x => x.LatestHitAt)
            .ToList();

        var recentHits = hitsLast7Days
            .OrderByDescending(x => x.HitAt)
            .Take(50)
            .Select(x => new AdminAndroidUpdateCompatibilityHitDto(
                x.HitAt,
                x.Route,
                x.RequestPath,
                x.RequestedFileName,
                x.ClientPlatform,
                x.ClientVersionCode,
                x.ClientVersionName,
                x.RemoteIp,
                x.UserAgent))
            .ToList();

        return new AdminAndroidUpdateCompatibilityReportDto(
            now,
            totalHitsLast24Hours,
            totalHitsLast7Days,
            hitsWithoutVersionLast24Hours,
            hitsWithoutVersionLast7Days,
            latestHitAt,
            routeBuckets,
            versionBuckets,
            recentHits);
    }

    public async Task<IReadOnlyList<AdminUserDto>> GetUsersAsync(string? query, CancellationToken cancellationToken)
    {
        var usersQuery = dbContext.Users.AsNoTracking();
        if (!string.IsNullOrWhiteSpace(query))
        {
            var normalized = query.Trim().ToUpperInvariant();
            var like = $"%{query.Trim()}%";
            usersQuery = usersQuery.Where(x =>
                x.NormalizedUsername.Contains(normalized) ||
                EF.Functions.Like(x.DisplayName, like) ||
                (x.PhoneNumber != null && EF.Functions.Like(x.PhoneNumber, like)));
        }

        var users = await usersQuery
            .OrderByDescending(x => x.CreatedAt)
            .Take(250)
            .Select(x => new
            {
                x.Id,
                x.Username,
                x.DisplayName,
                x.IsBlocked,
                x.IsBot,
                x.CreatedAt,
                x.BlockedAt,
                LastSeenAt = x.Sessions
                    .OrderByDescending(session => session.LastSeenAt)
                    .Select(session => (DateTimeOffset?)session.LastSeenAt)
                    .FirstOrDefault(),
                x.PhoneNumber,
                x.About,
                ActiveSessions = x.Sessions.Count,
                PushDevices = x.PushDevices.Count,
                ChatMemberships = x.ChatMemberships.Count,
                MessagesSent = x.Messages.Count,
                Reactions = x.MessageReactions.Count,
                PasscodeLockEnabled = x.PrivacySettings != null && x.PrivacySettings.PasscodeLockEnabled,
                ShowMessagePreview = x.PrivacySettings == null || x.PrivacySettings.ShowMessagePreview
            })
            .ToListAsync(cancellationToken);

        var userIds = users.Select(x => x.Id).ToList();
        var sessionVersions = userIds.Count == 0
            ? new List<AdminUserClientVersionProjection>()
            : await dbContext.UserSessions
                .AsNoTracking()
                .Where(x => userIds.Contains(x.UserId))
                .Select(x => new AdminUserClientVersionProjection(
                    x.UserId,
                    x.ClientPlatform,
                    x.ClientVersionCode,
                    x.ClientVersionName,
                    x.LastSeenAt,
                    false))
                .ToListAsync(cancellationToken);

        var pushDeviceVersions = userIds.Count == 0
            ? new List<AdminUserClientVersionProjection>()
            : await dbContext.PushDevices
                .AsNoTracking()
                .Where(x => userIds.Contains(x.UserId))
                .Select(x => new AdminUserClientVersionProjection(
                    x.UserId,
                    x.ClientPlatform,
                    x.ClientVersionCode,
                    x.ClientVersionName,
                    x.UpdatedAt,
                    true))
                .ToListAsync(cancellationToken);

        var versionsByUser = sessionVersions
            .Concat(pushDeviceVersions)
            .GroupBy(x => x.UserId)
            .ToDictionary(x => x.Key, x => x.ToList());

        return users
            .Select(user =>
            {
                versionsByUser.TryGetValue(user.Id, out var versions);
                versions ??= new List<AdminUserClientVersionProjection>();

                var latestClientVersion = versions
                    .Where(HasClientVersion)
                    .OrderByDescending(x => x.LastSeenAt)
                    .FirstOrDefault();

                return new AdminUserDto(
                    user.Id,
                    user.Username,
                    user.DisplayName,
                    user.IsBlocked,
                    user.IsBot,
                    user.CreatedAt,
                    user.BlockedAt,
                    user.LastSeenAt,
                    user.PhoneNumber,
                    user.About,
                    latestClientVersion?.ClientPlatform,
                    latestClientVersion?.ClientVersionCode,
                    latestClientVersion?.ClientVersionName,
                    BuildClientVersions(versions),
                    user.ActiveSessions,
                    user.PushDevices,
                    user.ChatMemberships,
                    user.MessagesSent,
                    user.Reactions,
                    user.PasscodeLockEnabled,
                    user.ShowMessagePreview);
            })
            .ToList();
    }

    public async Task<IReadOnlyList<AdminChatDto>> GetChatsAsync(string? query, CancellationToken cancellationToken)
    {
        var chatsQuery = BuildChatsQuery(query);
        var projections = await ProjectChats(chatsQuery.OrderByDescending(x => x.LastActivityAt ?? x.CreatedAt))
            .Take(250)
            .ToListAsync(cancellationToken);

        var directTitles = await LoadDirectTitlesAsync(projections, cancellationToken);
        return projections
            .Select(x => ToAdminChatDto(x, directTitles))
            .ToList();
    }

    public async Task<AdminChatDetailsDto?> GetChatDetailsAsync(Guid chatId, CancellationToken cancellationToken)
    {
        var projection = await ProjectChats(dbContext.Chats.AsNoTracking().Where(x => x.Id == chatId))
            .SingleOrDefaultAsync(cancellationToken);
        if (projection is null)
        {
            return null;
        }

        var directTitles = await LoadDirectTitlesAsync([projection], cancellationToken);
        var chatDto = ToAdminChatDto(projection, directTitles);

        var members = await dbContext.ChatMembers
            .AsNoTracking()
            .Where(x => x.ChatId == chatId)
            .Include(x => x.User)
            .OrderByDescending(x => x.IsOwner)
            .ThenByDescending(x => x.Role)
            .ThenBy(x => x.User.DisplayName)
            .Select(x => new AdminChatMemberDto(
                x.UserId,
                x.User.Username,
                x.User.DisplayName,
                x.User.IsBot,
                x.Role,
                x.IsOwner,
                x.CanPinMessages,
                x.CanDeleteMessages,
                x.CanInviteUsers,
                x.CanPostMessages,
                x.IsArchived,
                x.IsPinned,
                x.MutedUntil,
                x.FolderKey,
                x.JoinedAt,
                x.LastReadAt))
            .ToListAsync(cancellationToken);

        var inviteLinks = await dbContext.ChatInviteLinks
            .AsNoTracking()
            .Where(x => x.ChatId == chatId)
            .OrderByDescending(x => x.CreatedAt)
            .Select(x => new AdminChatInviteLinkDto(
                x.Id,
                x.Token,
                $"/join/{x.Token}",
                x.Title,
                x.CreatesJoinRequest,
                x.IsRevoked,
                x.CreatedAt,
                x.RevokedAt))
            .ToListAsync(cancellationToken);

        var joinRequests = await dbContext.ChatJoinRequests
            .AsNoTracking()
            .Where(x => x.ChatId == chatId)
            .Include(x => x.User)
            .Include(x => x.ResolvedByUser)
            .OrderByDescending(x => x.Status == ChatJoinRequestStatus.Pending)
            .ThenByDescending(x => x.RequestedAt)
            .Select(x => new AdminChatJoinRequestDto(
                x.Id,
                x.InviteLinkId,
                x.User.Username,
                x.User.DisplayName,
                x.Status,
                x.RequestedAt,
                x.ResolvedAt,
                x.ResolvedByUser == null ? null : x.ResolvedByUser.DisplayName))
            .ToListAsync(cancellationToken);

        var topics = await dbContext.ChatTopics
            .AsNoTracking()
            .Where(x => x.ChatId == chatId)
            .OrderByDescending(x => x.IsPinned)
            .ThenByDescending(x => x.LastActivityAt)
            .Select(x => new AdminChatTopicDto(
                x.Id,
                x.Title,
                x.Icon,
                x.IsPinned,
                x.IsClosed,
                x.CreatedAt,
                x.LastActivityAt,
                dbContext.Messages.Count(message => message.TopicId == x.Id && message.DeletedAt == null)))
            .ToListAsync(cancellationToken);

        var recentMessages = await dbContext.Messages
            .AsNoTracking()
            .Where(x => x.ChatId == chatId)
            .Include(x => x.Sender)
            .OrderByDescending(x => x.SentAt)
            .Take(30)
            .Select(x => new AdminMessagePreviewDto(
                x.Id,
                x.Sender.DisplayName,
                x.DeletedAt == null ? x.Text : string.Empty,
                x.SentAt,
                x.EditedAt,
                x.DeletedAt,
                x.HasProtectedContent,
                x.MediaAlbumId,
                x.Attachments.Count,
                x.Reactions.Count))
            .ToListAsync(cancellationToken);

        var moderationLog = await GetModerationLogItemsAsync(chatId, 80, cancellationToken);

        return new AdminChatDetailsDto(
            chatDto,
            members,
            inviteLinks,
            joinRequests,
            topics,
            recentMessages,
            moderationLog);
    }

    public async Task<AdminChatDetailsDto?> UpdateChatSettingsAsync(Guid chatId, AdminChatSettingsRequest request, CancellationToken cancellationToken)
    {
        var chat = await dbContext.Chats
            .Include(x => x.Members)
            .SingleOrDefaultAsync(x => x.Id == chatId, cancellationToken);
        if (chat is null)
        {
            return null;
        }

        var publicUsername = NormalizePublicUsername(request.PublicUsername);
        if (publicUsername is not null)
        {
            var duplicate = await dbContext.Chats
                .AnyAsync(x => x.Id != chatId && x.PublicUsername == publicUsername, cancellationToken);
            if (duplicate)
            {
                throw new InvalidOperationException("Публичное имя уже занято другим чатом.");
            }
        }

        chat.PublicUsername = publicUsername;
        chat.TopicsEnabled = request.TopicsEnabled;
        chat.HasProtectedContent = request.HasProtectedContent;
        chat.DefaultMessageTtlSeconds = NormalizeTtlSeconds(request.DefaultMessageTtlSeconds);
        chat.LastActivityAt ??= DateTimeOffset.UtcNow;

        await dbContext.SaveChangesAsync(cancellationToken);
        await NotifyChatsChangedAsync(chat.Members.Select(x => x.UserId), cancellationToken);
        return await GetChatDetailsAsync(chatId, cancellationToken);
    }

    public async Task<AdminChatDetailsDto?> UpdateMemberPermissionsAsync(
        Guid chatId,
        Guid memberUserId,
        AdminChatMemberPermissionsRequest request,
        CancellationToken cancellationToken)
    {
        var chat = await dbContext.Chats
            .Include(x => x.Members)
            .SingleOrDefaultAsync(x => x.Id == chatId, cancellationToken);
        if (chat is null)
        {
            return null;
        }

        var member = chat.Members.FirstOrDefault(x => x.UserId == memberUserId);
        if (member is null)
        {
            return null;
        }

        member.Role = request.Role;
        member.IsOwner = request.Role == ChatMemberRole.Owner;
        member.CanPinMessages = request.CanPinMessages || member.IsOwner;
        member.CanDeleteMessages = request.CanDeleteMessages || member.IsOwner;
        member.CanInviteUsers = request.CanInviteUsers || member.IsOwner;
        member.CanPostMessages = request.CanPostMessages || member.IsOwner;

        await dbContext.SaveChangesAsync(cancellationToken);
        await NotifyChatsChangedAsync(chat.Members.Select(x => x.UserId), cancellationToken);
        return await GetChatDetailsAsync(chatId, cancellationToken);
    }

    public async Task<AdminChatDetailsDto?> CreateInviteLinkAsync(Guid chatId, AdminCreateInviteLinkRequest request, CancellationToken cancellationToken)
    {
        var chat = await dbContext.Chats
            .Include(x => x.Members)
            .SingleOrDefaultAsync(x => x.Id == chatId, cancellationToken);
        if (chat is null)
        {
            return null;
        }

        if (chat.Type == ChatType.Direct)
        {
            throw new InvalidOperationException("Личные чаты не поддерживают ссылки-приглашения.");
        }

        var link = new ChatInviteLink
        {
            ChatId = chatId,
            CreatedById = chat.CreatedById,
            Token = await GenerateInviteTokenAsync(cancellationToken),
            Title = TrimToNull(request.Title, 64),
            CreatesJoinRequest = request.CreatesJoinRequest,
            CreatedAt = DateTimeOffset.UtcNow
        };

        dbContext.ChatInviteLinks.Add(link);
        await dbContext.SaveChangesAsync(cancellationToken);
        await NotifyChatsChangedAsync(chat.Members.Select(x => x.UserId), cancellationToken);
        return await GetChatDetailsAsync(chatId, cancellationToken);
    }

    public async Task<AdminChatDetailsDto?> RevokeInviteLinkAsync(Guid chatId, Guid inviteLinkId, CancellationToken cancellationToken)
    {
        var chat = await dbContext.Chats
            .Include(x => x.Members)
            .SingleOrDefaultAsync(x => x.Id == chatId, cancellationToken);
        if (chat is null)
        {
            return null;
        }

        var link = await dbContext.ChatInviteLinks
            .SingleOrDefaultAsync(x => x.Id == inviteLinkId && x.ChatId == chatId, cancellationToken);
        if (link is null)
        {
            return null;
        }

        link.IsRevoked = true;
        link.RevokedAt = DateTimeOffset.UtcNow;

        await dbContext.SaveChangesAsync(cancellationToken);
        await NotifyChatsChangedAsync(chat.Members.Select(x => x.UserId), cancellationToken);
        return await GetChatDetailsAsync(chatId, cancellationToken);
    }

    public async Task<AdminChatDetailsDto?> ResolveJoinRequestAsync(
        Guid chatId,
        Guid joinRequestId,
        bool approve,
        CancellationToken cancellationToken)
    {
        var chat = await dbContext.Chats
            .Include(x => x.Members)
            .SingleOrDefaultAsync(x => x.Id == chatId, cancellationToken);
        if (chat is null)
        {
            return null;
        }

        var joinRequest = await dbContext.ChatJoinRequests
            .Include(x => x.User)
            .SingleOrDefaultAsync(x => x.Id == joinRequestId && x.ChatId == chatId, cancellationToken);
        if (joinRequest is null)
        {
            return null;
        }

        if (joinRequest.Status == ChatJoinRequestStatus.Pending)
        {
            joinRequest.Status = approve ? ChatJoinRequestStatus.Approved : ChatJoinRequestStatus.Declined;
            joinRequest.ResolvedAt = DateTimeOffset.UtcNow;
            joinRequest.ResolvedByUserId = null;

            if (approve && chat.Members.All(x => x.UserId != joinRequest.UserId))
            {
                chat.Members.Add(new ChatMember
                {
                    ChatId = chatId,
                    UserId = joinRequest.UserId,
                    Role = ChatMemberRole.Member,
                    JoinedAt = DateTimeOffset.UtcNow
                });
                chat.LastActivityAt = DateTimeOffset.UtcNow;
            }

            await dbContext.SaveChangesAsync(cancellationToken);
            await NotifyChatsChangedAsync(chat.Members.Select(x => x.UserId).Append(joinRequest.UserId), cancellationToken);
        }

        return await GetChatDetailsAsync(chatId, cancellationToken);
    }

    public async Task<IReadOnlyList<AdminAttachmentDto>> GetAttachmentsAsync(string? query, CancellationToken cancellationToken)
    {
        var attachmentsQuery = dbContext.MessageAttachments
            .AsNoTracking()
            .Include(x => x.Message)
                .ThenInclude(x => x.Chat)
            .Include(x => x.Message)
                .ThenInclude(x => x.Sender)
            .AsQueryable();

        if (!string.IsNullOrWhiteSpace(query))
        {
            var like = $"%{query.Trim()}%";
            attachmentsQuery = attachmentsQuery.Where(x =>
                EF.Functions.Like(x.OriginalFileName, like) ||
                EF.Functions.Like(x.ContentType, like) ||
                EF.Functions.Like(x.Message.Sender.DisplayName, like) ||
                (x.Message.Chat.Title != null && EF.Functions.Like(x.Message.Chat.Title, like)));
        }

        var attachments = await attachmentsQuery.ToListAsync(cancellationToken);

        return attachments
            .OrderByDescending(x => x.UploadedAt)
            .Take(250)
            .Select(x => new AdminAttachmentDto(
                x.Id,
                x.Message.ChatId,
                x.MessageId,
                string.IsNullOrWhiteSpace(x.Message.Chat.Title) ? x.Message.Chat.Type.ToString() : x.Message.Chat.Title!,
                x.Message.Sender.DisplayName,
                x.OriginalFileName,
                x.ContentType,
                x.FileSizeBytes,
                x.SortOrder,
                x.Message.MediaAlbumId,
                x.UploadedAt,
                File.Exists(x.StoragePath),
                x.Message.HasProtectedContent || x.Message.Chat.HasProtectedContent,
                $"/admin/api/attachments/{x.Id}/content"))
            .ToList();
    }

    public async Task<bool> DeleteAttachmentAsync(Guid attachmentId, CancellationToken cancellationToken)
    {
        var attachment = await dbContext.MessageAttachments
            .Include(x => x.Message)
                .ThenInclude(x => x.Chat)
                    .ThenInclude(x => x.Members)
            .SingleOrDefaultAsync(x => x.Id == attachmentId, cancellationToken);
        if (attachment is null)
        {
            return false;
        }

        var affectedUserIds = attachment.Message.Chat.Members.Select(x => x.UserId).ToList();
        var path = attachment.StoragePath;
        dbContext.MessageAttachments.Remove(attachment);
        await dbContext.SaveChangesAsync(cancellationToken);

        if (!string.IsNullOrWhiteSpace(path))
        {
            attachmentStorageService.DeleteIfExists(path);
        }

        await NotifyChatsChangedAsync(affectedUserIds, cancellationToken);
        return true;
    }

    public async Task<IReadOnlyList<AdminModerationLogItemDto>> GetModerationLogAsync(Guid? chatId, int limit, CancellationToken cancellationToken) =>
        await GetModerationLogItemsAsync(chatId, Math.Clamp(limit, 1, 250), cancellationToken);

    public async Task<AdminExpiredMessagesCleanupDto> CleanupExpiredMessagesAsync(CancellationToken cancellationToken)
    {
        var now = DateTimeOffset.UtcNow;
        var messages = await dbContext.Messages
            .Include(x => x.Chat)
                .ThenInclude(x => x.Members)
            .Include(x => x.Attachments)
            .Include(x => x.Reactions)
            .Where(x => x.ExpiresAt != null && x.ExpiresAt <= now && x.DeletedAt == null)
            .Take(500)
            .ToListAsync(cancellationToken);

        var deletedAttachments = 0;
        var affectedUserIds = messages
            .SelectMany(x => x.Chat.Members.Select(member => member.UserId))
            .Distinct()
            .ToList();

        foreach (var message in messages)
        {
            DeleteFiles(message.Attachments.Select(x => x.StoragePath));
            deletedAttachments += message.Attachments.Count;
            dbContext.MessageAttachments.RemoveRange(message.Attachments);
            dbContext.MessageReactions.RemoveRange(message.Reactions);
            message.Attachments.Clear();
            message.Reactions.Clear();
            message.Text = string.Empty;
            message.EditedAt = null;
            message.DeletedAt = now;
            message.PinnedAt = null;
            message.PinnedByUserId = null;
        }

        await dbContext.SaveChangesAsync(cancellationToken);
        await NotifyChatsChangedAsync(affectedUserIds, cancellationToken);
        return new AdminExpiredMessagesCleanupDto(messages.Count, deletedAttachments);
    }

    public async Task<bool> SetUserBlockedAsync(Guid userId, bool isBlocked, CancellationToken cancellationToken)
    {
        var user = await dbContext.Users
            .Include(x => x.Sessions)
            .SingleOrDefaultAsync(x => x.Id == userId, cancellationToken);

        if (user is null)
        {
            return false;
        }

        user.IsBlocked = isBlocked;
        user.BlockedAt = isBlocked ? DateTimeOffset.UtcNow : null;

        if (isBlocked && user.Sessions.Count > 0)
        {
            dbContext.UserSessions.RemoveRange(user.Sessions);
        }

        await dbContext.SaveChangesAsync(cancellationToken);
        return true;
    }

    public async Task<bool> DeleteChatAsync(Guid chatId, CancellationToken cancellationToken)
    {
        var chat = await dbContext.Chats
            .Include(x => x.Members)
            .Include(x => x.Messages)
                .ThenInclude(x => x.Attachments)
            .SingleOrDefaultAsync(x => x.Id == chatId, cancellationToken);

        if (chat is null)
        {
            return false;
        }

        var affectedUserIds = chat.Members.Select(x => x.UserId).Distinct().ToList();
        var attachmentPaths = chat.Messages
            .SelectMany(x => x.Attachments)
            .Select(x => x.StoragePath)
            .Where(x => !string.IsNullOrWhiteSpace(x))
            .Distinct()
            .ToList();

        dbContext.Chats.Remove(chat);
        await dbContext.SaveChangesAsync(cancellationToken);

        DeleteFiles(attachmentPaths);
        await NotifyChatsChangedAsync(affectedUserIds, cancellationToken);

        return true;
    }

    public async Task<bool> DeleteUserAsync(Guid userId, CancellationToken cancellationToken)
    {
        var user = await dbContext.Users
            .Include(x => x.Sessions)
            .Include(x => x.PushDevices)
            .SingleOrDefaultAsync(x => x.Id == userId, cancellationToken);

        if (user is null)
        {
            return false;
        }

        var chats = await dbContext.Chats
            .Include(x => x.Members)
            .Include(x => x.Messages)
                .ThenInclude(x => x.Attachments)
            .Where(x => x.CreatedById == userId || x.Members.Any(member => member.UserId == userId))
            .ToListAsync(cancellationToken);

        var affectedUserIds = new HashSet<Guid>();
        var attachmentPathsToDelete = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var chatsToDelete = new List<Chat>();

        foreach (var chat in chats)
        {
            foreach (var memberId in chat.Members.Select(x => x.UserId))
            {
                if (memberId != userId)
                {
                    affectedUserIds.Add(memberId);
                }
            }

            if (chat.Type == ChatType.Direct && chat.Members.Any(x => x.UserId == userId))
            {
                CollectAttachmentPaths(chat.Messages.SelectMany(x => x.Attachments), attachmentPathsToDelete);
                chatsToDelete.Add(chat);
                continue;
            }

            var messagesToDelete = chat.Messages
                .Where(x => x.SenderId == userId)
                .ToList();

            CollectAttachmentPaths(messagesToDelete.SelectMany(x => x.Attachments), attachmentPathsToDelete);

            if (messagesToDelete.Count > 0)
            {
                dbContext.Messages.RemoveRange(messagesToDelete);
            }

            var membership = chat.Members.FirstOrDefault(x => x.UserId == userId);
            if (membership is not null)
            {
                dbContext.ChatMembers.Remove(membership);
            }

            var remainingMembers = chat.Members
                .Where(x => x.UserId != userId)
                .OrderByDescending(x => x.IsOwner)
                .ThenBy(x => x.JoinedAt)
                .ToList();

            if (remainingMembers.Count == 0)
            {
                chatsToDelete.Add(chat);
                continue;
            }

            if (chat.CreatedById == userId)
            {
                var replacementOwner = remainingMembers.First();
                replacementOwner.IsOwner = true;
                replacementOwner.Role = ChatMemberRole.Owner;
                chat.CreatedById = replacementOwner.UserId;
            }

            if (chat.Type == ChatType.Channel && remainingMembers.All(x => !x.IsOwner))
            {
                remainingMembers[0].IsOwner = true;
                remainingMembers[0].Role = ChatMemberRole.Owner;
            }

            var latestMessageSentAt = chat.Messages
                .Where(x => x.SenderId != userId)
                .OrderByDescending(x => x.SentAt)
                .Select(x => (DateTimeOffset?)x.SentAt)
                .FirstOrDefault();
            chat.LastActivityAt = latestMessageSentAt ?? chat.CreatedAt;
        }

        foreach (var chat in chatsToDelete.DistinctBy(x => x.Id))
        {
            CollectAttachmentPaths(chat.Messages.SelectMany(x => x.Attachments), attachmentPathsToDelete);
            dbContext.Chats.Remove(chat);
        }

        if (user.Sessions.Count > 0)
        {
            dbContext.UserSessions.RemoveRange(user.Sessions);
        }

        if (user.PushDevices.Count > 0)
        {
            dbContext.PushDevices.RemoveRange(user.PushDevices);
        }

        dbContext.Users.Remove(user);
        await dbContext.SaveChangesAsync(cancellationToken);

        DeleteFiles(attachmentPathsToDelete);
        await NotifyChatsChangedAsync(affectedUserIds, cancellationToken);

        return true;
    }

    private IQueryable<Chat> BuildChatsQuery(string? query)
    {
        var chatsQuery = dbContext.Chats.AsNoTracking();
        if (string.IsNullOrWhiteSpace(query))
        {
            return chatsQuery;
        }

        var like = $"%{query.Trim()}%";
        return chatsQuery.Where(x =>
            (x.Title != null && EF.Functions.Like(x.Title, like)) ||
            (x.PublicUsername != null && EF.Functions.Like(x.PublicUsername, like)) ||
            EF.Functions.Like(x.CreatedBy.DisplayName, like) ||
            x.Members.Any(member =>
                EF.Functions.Like(member.User.DisplayName, like) ||
                EF.Functions.Like(member.User.Username, like)));
    }

    private IQueryable<AdminChatProjection> ProjectChats(IQueryable<Chat> chatsQuery) =>
        chatsQuery.Select(x => new AdminChatProjection(
            x.Id,
            x.Type,
            x.Title,
            x.PublicUsername,
            x.TopicsEnabled,
            x.HasProtectedContent,
            x.DefaultMessageTtlSeconds,
            x.CreatedBy.DisplayName,
            x.CreatedAt,
            x.LastActivityAt,
            x.Members.Count,
            x.Messages.Count,
            x.Messages.SelectMany(m => m.Attachments).Count(),
            x.Messages.Count(m => m.PinnedAt != null && m.DeletedAt == null),
            x.Messages.Count(m => m.HasProtectedContent && m.DeletedAt == null),
            x.InviteLinks.Count(m => !m.IsRevoked),
            x.JoinRequests.Count(m => m.Status == ChatJoinRequestStatus.Pending),
            x.Topics.Count));

    private async Task<Dictionary<Guid, string>> LoadDirectTitlesAsync(IEnumerable<AdminChatProjection> chats, CancellationToken cancellationToken)
    {
        var directChatIds = chats
            .Where(x => x.Type == ChatType.Direct)
            .Select(x => x.Id)
            .ToList();

        if (directChatIds.Count == 0)
        {
            return [];
        }

        var rows = await dbContext.ChatMembers
            .AsNoTracking()
            .Where(x => directChatIds.Contains(x.ChatId))
            .Select(x => new { x.ChatId, x.User.DisplayName, x.User.Username })
            .ToListAsync(cancellationToken);

        return rows
            .GroupBy(x => x.ChatId)
            .ToDictionary(
                x => x.Key,
                x => string.Join(" / ", x.Select(member => DisplayName(member.DisplayName, member.Username))));
    }

    private static AdminChatDto ToAdminChatDto(AdminChatProjection chat, IReadOnlyDictionary<Guid, string> directTitles)
    {
        var title = chat.Type == ChatType.Direct
            ? directTitles.GetValueOrDefault(chat.Id, "Личный чат")
            : string.IsNullOrWhiteSpace(chat.Title) ? "Без названия" : chat.Title!;

        return new AdminChatDto(
            chat.Id,
            chat.Type.ToString(),
            title,
            chat.CreatedBy,
            chat.CreatedAt,
            chat.LastActivityAt,
            chat.Members,
            chat.Messages,
            chat.Attachments,
            chat.PinnedMessages,
            chat.ProtectedMessages,
            chat.InviteLinks,
            chat.PendingJoinRequests,
            chat.Topics,
            chat.PublicUsername,
            chat.TopicsEnabled,
            chat.HasProtectedContent,
            chat.DefaultMessageTtlSeconds);
    }

    private async Task<IReadOnlyList<AdminModerationLogItemDto>> GetModerationLogItemsAsync(Guid? chatId, int limit, CancellationToken cancellationToken)
    {
        var take = Math.Clamp(limit, 1, 250);
        var query = dbContext.ModerationLogEntries
            .AsNoTracking()
            .Include(x => x.ActorUser)
            .AsQueryable();

        if (chatId is not null)
        {
            query = query.Where(x => x.ChatId == chatId.Value);
        }

        return await query
            .OrderByDescending(x => x.CreatedAt)
            .Take(take)
            .Select(x => new AdminModerationLogItemDto(
                x.Id,
                x.ChatId,
                string.IsNullOrWhiteSpace(x.ActorUser.DisplayName) ? x.ActorUser.Username : x.ActorUser.DisplayName,
                x.TargetType,
                x.TargetId,
                x.Action,
                x.Reason,
                x.CreatedAt))
            .ToListAsync(cancellationToken);
    }

    private async Task<string> GenerateInviteTokenAsync(CancellationToken cancellationToken)
    {
        while (true)
        {
            var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(18))
                .TrimEnd('=')
                .Replace('+', '-')
                .Replace('/', '_');
            var exists = await dbContext.ChatInviteLinks
                .AnyAsync(x => x.Token == token, cancellationToken);
            if (!exists)
            {
                return token;
            }
        }
    }

    private static string? NormalizePublicUsername(string? value)
    {
        var trimmed = value?.Trim().TrimStart('@');
        if (string.IsNullOrWhiteSpace(trimmed))
        {
            return null;
        }

        var normalized = new string(trimmed
            .Where(ch => ch is >= 'a' and <= 'z' || ch is >= 'A' and <= 'Z' || char.IsDigit(ch) || ch == '_')
            .Take(32)
            .ToArray())
            .ToLowerInvariant();

        if (normalized.Length < 5)
        {
            throw new InvalidOperationException("Публичное имя должно содержать минимум 5 символов.");
        }

        return normalized;
    }

    private static int? NormalizeTtlSeconds(int? ttlSeconds)
    {
        if (ttlSeconds is null || ttlSeconds <= 0)
        {
            return null;
        }

        return Math.Clamp(ttlSeconds.Value, 5, 31 * 24 * 60 * 60);
    }

    private static string? TrimToNull(string? value, int maxLength)
    {
        var trimmed = value?.Trim();
        if (string.IsNullOrWhiteSpace(trimmed))
        {
            return null;
        }

        return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength];
    }

    private static string DisplayName(string? displayName, string username) =>
        string.IsNullOrWhiteSpace(displayName) ? $"@{username}" : displayName!;

    private static IReadOnlyList<AdminUserClientVersionDto> BuildClientVersions(
        IEnumerable<AdminUserClientVersionProjection> versions)
    {
        return versions
            .Where(HasClientVersion)
            .GroupBy(x => new
            {
                x.ClientPlatform,
                x.ClientVersionCode,
                x.ClientVersionName
            })
            .Select(x => new AdminUserClientVersionDto(
                x.Key.ClientPlatform,
                x.Key.ClientVersionCode,
                x.Key.ClientVersionName,
                x.Max(version => version.LastSeenAt),
                x.Count(version => !version.IsPushDevice),
                x.Count(version => version.IsPushDevice)))
            .OrderByDescending(x => x.LastSeenAt)
            .ToList();
    }

    private static bool HasClientVersion(AdminUserClientVersionProjection version) =>
        !string.IsNullOrWhiteSpace(version.ClientPlatform) ||
        version.ClientVersionCode is not null ||
        !string.IsNullOrWhiteSpace(version.ClientVersionName);

    private static void CollectAttachmentPaths(IEnumerable<MessageAttachment> attachments, ISet<string> target)
    {
        foreach (var path in attachments
                     .Select(x => x.StoragePath)
                     .Where(x => !string.IsNullOrWhiteSpace(x)))
        {
            target.Add(path);
        }
    }

    private void DeleteFiles(IEnumerable<string> attachmentPaths)
    {
        foreach (var path in attachmentPaths)
        {
            attachmentStorageService.DeleteIfExists(path);
        }
    }

    private Task NotifyChatsChangedAsync(IEnumerable<Guid> userIds, CancellationToken cancellationToken)
    {
        var affectedUsers = userIds
            .Distinct()
            .Select(x => x.ToString())
            .ToList();

        return affectedUsers.Count == 0
            ? Task.CompletedTask
            : hubContext.Clients.Users(affectedUsers).SendAsync("ChatsChanged", cancellationToken);
    }

    private sealed record AdminChatProjection(
        Guid Id,
        ChatType Type,
        string? Title,
        string? PublicUsername,
        bool TopicsEnabled,
        bool HasProtectedContent,
        int? DefaultMessageTtlSeconds,
        string CreatedBy,
        DateTimeOffset CreatedAt,
        DateTimeOffset? LastActivityAt,
        int Members,
        int Messages,
        int Attachments,
        int PinnedMessages,
        int ProtectedMessages,
        int InviteLinks,
        int PendingJoinRequests,
        int Topics);

    private sealed record AdminUserClientVersionProjection(
        Guid UserId,
        string? ClientPlatform,
        int? ClientVersionCode,
        string? ClientVersionName,
        DateTimeOffset LastSeenAt,
        bool IsPushDevice);
}
