using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Ikar.Server.Data;
using Ikar.Server.Data.Entities;
using Ikar.Server.Infrastructure;
using Ikar.Server.Infrastructure.Bots;
using Ikar.Server.Infrastructure.Hubs;
using Ikar.Server.Infrastructure.Push;
using Ikar.Server.Infrastructure.Storage;
using Ikar.Shared;
using System.Net.Mime;
using System.Security.Cryptography;

namespace Ikar.Server.Controllers;

[ApiController]
[Authorize]
[Route("api/[controller]")]
public sealed class ChatsController(
    IkarDbContext dbContext,
    PresenceTracker presenceTracker,
    IHubContext<MessengerHub> hubContext,
    AttachmentStorageService attachmentStorageService,
    PushDispatchQueue pushDispatchQueue,
    BotUpdateService botUpdateService) : ControllerBase
{
    private const long MaxAttachmentSizeBytes = 25 * 1024 * 1024;
    private const long MaxAlbumSizeBytes = 100 * 1024 * 1024;
    private const int MaxAlbumAttachments = 10;
    private const int DefaultMessagePageSize = 50;
    private const int MaxMessagePageSize = 100;

    [HttpGet]
    public async Task<ActionResult<IReadOnlyList<ChatSummaryDto>>> GetChats(CancellationToken cancellationToken)
    {
        var userId = User.GetRequiredUserId();

        var chats = await dbContext.Chats
            .Where(x => x.Members.Any(member => member.UserId == userId))
            .Include(x => x.Members)
                .ThenInclude(x => x.User)
            .AsNoTracking()
            .AsSplitQuery()
            .ToListAsync(cancellationToken);

        var chatIds = chats.Select(x => x.Id).ToList();
        var latestMessagesByChatId = await LoadLatestMessagesByChatIdAsync(chatIds, cancellationToken);
        var unreadCountsByChatId = await LoadUnreadCountsByChatIdAsync(userId, chatIds, cancellationToken);

        return Ok(chats
            .OrderByDescending(x => x.LastActivityAt ?? x.CreatedAt)
            .Select(x => x.ToSummaryDto(
                userId,
                presenceTracker,
                latestMessagesByChatId.GetValueOrDefault(x.Id),
                unreadCountsByChatId.GetValueOrDefault(x.Id)))
            .ToList());
    }

    [HttpGet("folders")]
    public async Task<ActionResult<IReadOnlyList<ChatFolderDto>>> GetFolders(CancellationToken cancellationToken)
    {
        var userId = User.GetRequiredUserId();
        var memberships = await dbContext.ChatMembers
            .Where(member => member.UserId == userId)
            .Select(member => new
            {
                member.ChatId,
                member.IsArchived,
                member.IsPinned,
                member.FolderKey,
                member.LastReadAt
            })
            .ToListAsync(cancellationToken);

        var chatIds = memberships.Select(x => x.ChatId).ToList();
        var unreadCounts = await LoadUnreadCountsByChatIdAsync(userId, chatIds, cancellationToken);
        var folders = new List<ChatFolderDto>
        {
            new("all", "All chats", memberships.Count, unreadCounts.Values.Sum()),
            new("unread", "Unread", unreadCounts.Count(x => x.Value > 0), unreadCounts.Values.Sum()),
            new("pinned", "Pinned", memberships.Count(x => x.IsPinned), memberships.Where(x => x.IsPinned).Sum(x => unreadCounts.GetValueOrDefault(x.ChatId))),
            new("archive", "Archive", memberships.Count(x => x.IsArchived), memberships.Where(x => x.IsArchived).Sum(x => unreadCounts.GetValueOrDefault(x.ChatId)))
        };

        folders.AddRange(memberships
            .Where(x => !string.IsNullOrWhiteSpace(x.FolderKey))
            .GroupBy(x => x.FolderKey!)
            .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)
            .Select(group => new ChatFolderDto(
                group.Key,
                group.Key,
                group.Count(),
                group.Sum(x => unreadCounts.GetValueOrDefault(x.ChatId)))));

        return Ok(folders);
    }

    [HttpGet("search")]
    public async Task<ActionResult<IReadOnlyList<ChatSearchResultDto>>> Search(
        [FromQuery] string q,
        [FromQuery] string? filter = null,
        [FromQuery] string? type = null,
        [FromQuery] int page = 1,
        [FromQuery] int limit = 50,
        [FromQuery] DateTimeOffset? date = null,
        [FromQuery] DateTimeOffset? dateFrom = null,
        [FromQuery] DateTimeOffset? dateTo = null,
        [FromQuery] Guid? authorId = null,
        [FromQuery] string? author = null,
        CancellationToken cancellationToken = default)
    {
        var userId = User.GetRequiredUserId();
        var searchPage = await SearchChatsPageAsync(
            userId,
            q,
            BuildSearchFilters(filter, type, date, dateFrom, dateTo, authorId, author),
            page,
            limit,
            cancellationToken);
        WriteSearchPageHeaders(searchPage.Page, searchPage.Limit, searchPage.HasMore);
        return Ok(searchPage.Results);
    }

    [HttpGet("search/page")]
    public async Task<ActionResult<ChatSearchPageDto>> SearchPage(
        [FromQuery] string q,
        [FromQuery] string? filter = null,
        [FromQuery] string? type = null,
        [FromQuery] int page = 1,
        [FromQuery] int limit = 50,
        [FromQuery] DateTimeOffset? date = null,
        [FromQuery] DateTimeOffset? dateFrom = null,
        [FromQuery] DateTimeOffset? dateTo = null,
        [FromQuery] Guid? authorId = null,
        [FromQuery] string? author = null,
        CancellationToken cancellationToken = default)
    {
        var userId = User.GetRequiredUserId();
        var searchPage = await SearchChatsPageAsync(
            userId,
            q,
            BuildSearchFilters(filter, type, date, dateFrom, dateTo, authorId, author),
            page,
            limit,
            cancellationToken);
        return Ok(searchPage);
    }

    [HttpGet("{chatId:guid}")]
    public async Task<ActionResult<ChatDetailsDto>> GetChat(Guid chatId, CancellationToken cancellationToken)
    {
        var userId = User.GetRequiredUserId();
        var chat = await LoadChatMetadata(
            chatId,
            userId,
            cancellationToken,
            asNoTracking: false,
            allowPublicChannelPreview: true);

        if (chat is null)
        {
            return NotFound();
        }

        var currentMembership = chat.Members.FirstOrDefault(member => member.UserId == userId);
        var previousReadAt = currentMembership?.LastReadAt;
        var firstUnreadMessageId = previousReadAt is null
            ? null
            : await dbContext.Messages
                .Where(message =>
                    message.ChatId == chatId &&
                    message.SenderId != userId &&
                    message.SentAt > previousReadAt.Value)
                .OrderBy(message => message.SentAt)
                .Select(message => (Guid?)message.Id)
                .FirstOrDefaultAsync(cancellationToken);

        if (currentMembership is not null && chat.Type != ChatType.Channel)
        {
            await UpdateReadReceiptForOpenedChatAsync(chat, userId, cancellationToken);
        }

        var page = await LoadMessagesPageAsync(chat, beforeMessageId: null, DefaultMessagePageSize, cancellationToken);
        await MarkChannelMessagesViewedAsync(chat, userId, page.Messages, cancellationToken);
        var pinnedMessages = await LoadPinnedMessagesAsync(chat, cancellationToken);
        return Ok(chat.ToDetailsDto(
            userId,
            presenceTracker,
            firstUnreadMessageId,
            page.Messages,
            page.HasMoreMessages,
            pinnedMessages));
    }

    [HttpGet("{chatId:guid}/info")]
    public async Task<ActionResult<ChatInfoDto>> GetChatInfo(Guid chatId, CancellationToken cancellationToken)
    {
        var userId = User.GetRequiredUserId();
        var chat = await LoadChatMetadata(
            chatId,
            userId,
            cancellationToken,
            allowPublicChannelPreview: true);
        if (chat is null)
        {
            return NotFound();
        }

        var page = await LoadMessagesPageAsync(chat, beforeMessageId: null, DefaultMessagePageSize, cancellationToken);
        var pinnedMessages = await LoadPinnedMessagesAsync(chat, cancellationToken);
        var details = chat.ToDetailsDto(
            userId,
            presenceTracker,
            messagesOverride: page.Messages,
            hasMoreMessages: page.HasMoreMessages,
            pinnedMessagesOverride: pinnedMessages);

        var mediaCount = await dbContext.MessageAttachments
            .CountAsync(x =>
                x.Message.ChatId == chatId &&
                x.Message.DeletedAt == null &&
                (x.ContentType.StartsWith("image/") || x.ContentType.StartsWith("video/")),
                cancellationToken);
        var fileCount = await dbContext.MessageAttachments
            .CountAsync(x =>
                x.Message.ChatId == chatId &&
                x.Message.DeletedAt == null &&
                !x.ContentType.StartsWith("image/") &&
                !x.ContentType.StartsWith("video/") &&
                !x.ContentType.StartsWith("audio/"),
                cancellationToken);
        var voiceCount = await dbContext.MessageAttachments
            .CountAsync(x => x.Message.ChatId == chatId && x.Message.DeletedAt == null && x.ContentType.StartsWith("audio/"), cancellationToken);
        var linkCount = await dbContext.Messages
            .CountAsync(x =>
                x.ChatId == chatId &&
                x.DeletedAt == null &&
                (x.Text.Contains("http://") || x.Text.Contains("https://")),
                cancellationToken);
        var pinnedCount = await dbContext.Messages
            .CountAsync(x => x.ChatId == chatId && x.DeletedAt == null && x.PinnedAt != null, cancellationToken);
        var canInspectInviteLinks = ResolvePermissions(chat, userId).CanInviteUsers;
        var inviteLinks = canInspectInviteLinks
            ? await dbContext.ChatInviteLinks
                .AsNoTracking()
                .Where(x => x.ChatId == chatId)
                .OrderByDescending(x => x.CreatedAt)
                .Take(20)
                .ToListAsync(cancellationToken)
            : [];
        var topics = await LoadChatTopicDtosAsync(chatId, cancellationToken);

        return Ok(new ChatInfoDto(
            details,
            mediaCount,
            fileCount,
            linkCount,
            voiceCount,
            pinnedCount,
            topics.Count,
            chat.PublicUsername,
            inviteLinks.Select(ToInviteLinkDto).ToList(),
            topics,
            details.SubscriberCount,
            details.AdminCount,
            chat.LinkedDiscussionChatId,
            chat.ChannelSignaturesEnabled,
            details.CanManageChannel,
            details.CanPostAsChannel,
            chat.Description));
    }

    [HttpGet("{chatId:guid}/invite-links")]
    public async Task<ActionResult<IReadOnlyList<ChatInviteLinkDto>>> GetInviteLinks(Guid chatId, CancellationToken cancellationToken)
    {
        var userId = User.GetRequiredUserId();
        var chat = await LoadChatMetadata(chatId, userId, cancellationToken);
        if (chat is null)
        {
            return NotFound();
        }

        if (!ResolvePermissions(chat, userId).CanInviteUsers)
        {
            return StatusCode(StatusCodes.Status403Forbidden, "You cannot manage invite links in this chat.");
        }

        var links = await dbContext.ChatInviteLinks
            .AsNoTracking()
            .Where(x => x.ChatId == chatId)
            .OrderByDescending(x => x.CreatedAt)
            .ToListAsync(cancellationToken);
        return Ok(links.Select(ToInviteLinkDto).ToList());
    }

    [HttpPost("{chatId:guid}/invite-links")]
    public async Task<ActionResult<ChatInviteLinkDto>> CreateInviteLink(
        Guid chatId,
        CreateChatInviteLinkRequest request,
        CancellationToken cancellationToken)
    {
        var userId = User.GetRequiredUserId();
        var chat = await LoadChatMetadata(chatId, userId, cancellationToken, asNoTracking: false);
        if (chat is null)
        {
            return NotFound();
        }

        if (!ResolvePermissions(chat, userId).CanInviteUsers)
        {
            return StatusCode(StatusCodes.Status403Forbidden, "You cannot create invite links in this chat.");
        }

        if (chat.Type == ChatType.Direct)
        {
            return BadRequest("Direct chats do not support invite links.");
        }

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

        dbContext.ChatInviteLinks.Add(link);
        await dbContext.SaveChangesAsync(cancellationToken);
        return Ok(ToInviteLinkDto(link));
    }

    [HttpDelete("{chatId:guid}/invite-links/{inviteLinkId:guid}")]
    public async Task<IActionResult> RevokeInviteLink(Guid chatId, Guid inviteLinkId, CancellationToken cancellationToken)
    {
        var userId = User.GetRequiredUserId();
        var chat = await LoadChatMetadata(chatId, userId, cancellationToken);
        if (chat is null)
        {
            return NotFound();
        }

        if (!ResolvePermissions(chat, userId).CanInviteUsers)
        {
            return StatusCode(StatusCodes.Status403Forbidden, "You cannot revoke invite links in this chat.");
        }

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

        link.IsRevoked = true;
        link.RevokedAt = DateTimeOffset.UtcNow;
        await dbContext.SaveChangesAsync(cancellationToken);
        return NoContent();
    }

    [HttpGet("{chatId:guid}/join-requests")]
    public async Task<ActionResult<IReadOnlyList<ChatJoinRequestDto>>> GetJoinRequests(Guid chatId, CancellationToken cancellationToken)
    {
        var userId = User.GetRequiredUserId();
        var chat = await LoadChatMetadata(chatId, userId, cancellationToken);
        if (chat is null)
        {
            return NotFound();
        }

        if (!CanManageMembers(chat, userId))
        {
            return StatusCode(StatusCodes.Status403Forbidden, "Only admins can review join requests.");
        }

        var requests = await dbContext.ChatJoinRequests
            .AsNoTracking()
            .Include(x => x.User)
                .ThenInclude(x => x.Sessions)
            .Include(x => x.ResolvedByUser)
                .ThenInclude(x => x!.Sessions)
            .Where(x => x.ChatId == chatId)
            .OrderByDescending(x => x.RequestedAt)
            .Take(100)
            .ToListAsync(cancellationToken);

        return Ok(requests.Select(ToJoinRequestDto).ToList());
    }

    [HttpPost("{chatId:guid}/join-requests/{joinRequestId:guid}")]
    public async Task<ActionResult<ChatJoinRequestDto>> ResolveJoinRequest(
        Guid chatId,
        Guid joinRequestId,
        ResolveChatJoinRequestRequest request,
        CancellationToken cancellationToken)
    {
        var userId = User.GetRequiredUserId();
        var chat = await dbContext.Chats
            .Include(x => x.Members)
                .ThenInclude(x => x.User)
            .SingleOrDefaultAsync(x => x.Id == chatId && x.Members.Any(member => member.UserId == userId), cancellationToken);
        if (chat is null)
        {
            return NotFound();
        }

        if (!CanManageMembers(chat, userId))
        {
            return StatusCode(StatusCodes.Status403Forbidden, "Only admins can resolve join requests.");
        }

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

        if (joinRequest.Status != ChatJoinRequestStatus.Pending)
        {
            return Ok(ToJoinRequestDto(joinRequest));
        }

        joinRequest.Status = request.Approve ? ChatJoinRequestStatus.Approved : ChatJoinRequestStatus.Declined;
        joinRequest.ResolvedAt = DateTimeOffset.UtcNow;
        joinRequest.ResolvedByUserId = userId;

        if (request.Approve && chat.Members.All(member => member.UserId != joinRequest.UserId))
        {
            chat.Members.Add(CreateSubscriberMembership(chat, joinRequest.UserId));
            chat.LastActivityAt = DateTimeOffset.UtcNow;
        }

        await AddModerationLogAsync(
            userId,
            chatId,
            ModerationLogTargetType.JoinRequest,
            joinRequest.Id,
            request.Approve ? "join_request.approved" : "join_request.declined",
            null,
            cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        await NotifyChatsChangedAsync(chat.Members.Select(member => member.UserId).Append(joinRequest.UserId), cancellationToken);
        return Ok(ToJoinRequestDto(joinRequest));
    }

    [HttpPost("join/{token}")]
    public async Task<ActionResult<ChatDetailsDto>> JoinByInviteLink(string token, CancellationToken cancellationToken)
    {
        var userId = User.GetRequiredUserId();
        var normalizedToken = token?.Trim() ?? string.Empty;
        var invite = await dbContext.ChatInviteLinks
            .Include(x => x.Chat)
                .ThenInclude(x => x.Members)
                    .ThenInclude(x => x.User)
            .SingleOrDefaultAsync(x => x.Token == normalizedToken && !x.IsRevoked, cancellationToken);
        if (invite is null)
        {
            return NotFound();
        }

        if (invite.Chat.Type == ChatType.Direct)
        {
            return BadRequest("Direct chats do not support invite links.");
        }

        if (invite.CreatesJoinRequest)
        {
            if (invite.Chat.Members.Any(x => x.UserId == userId))
            {
                var existingChat = await LoadChatWithMessages(invite.ChatId, userId, cancellationToken)
                    ?? throw new InvalidOperationException("Joined chat could not be loaded.");
                return Ok(existingChat.ToDetailsDto(userId, presenceTracker));
            }

            var existingRequest = await dbContext.ChatJoinRequests
                .SingleOrDefaultAsync(x => x.InviteLinkId == invite.Id && x.UserId == userId, cancellationToken);
            if (existingRequest is null)
            {
                existingRequest = new ChatJoinRequest
                {
                    ChatId = invite.ChatId,
                    InviteLinkId = invite.Id,
                    UserId = userId,
                    RequestedAt = DateTimeOffset.UtcNow
                };
                dbContext.ChatJoinRequests.Add(existingRequest);
                await AddModerationLogAsync(
                    userId,
                    invite.ChatId,
                    ModerationLogTargetType.JoinRequest,
                    existingRequest.Id,
                    "join_request.created",
                    null,
                    cancellationToken);
                await dbContext.SaveChangesAsync(cancellationToken);
            }

            return Accepted(new { joinRequestId = existingRequest.Id, status = existingRequest.Status.ToString() });
        }

        if (!invite.Chat.Members.Any(x => x.UserId == userId))
        {
            invite.Chat.Members.Add(CreateSubscriberMembership(invite.Chat, userId));
            invite.Chat.LastActivityAt = DateTimeOffset.UtcNow;
            await AddModerationLogAsync(userId, invite.ChatId, ModerationLogTargetType.InviteLink, invite.Id, "invite_link.joined", null, cancellationToken);
            await dbContext.SaveChangesAsync(cancellationToken);
            var joined = await LoadChatWithMessages(invite.ChatId, userId, cancellationToken)
                ?? throw new InvalidOperationException("Joined chat could not be loaded.");
            await NotifyChatCreatedAsync(joined, cancellationToken);
            return Ok(joined.ToDetailsDto(userId, presenceTracker));
        }

        var loaded = await LoadChatWithMessages(invite.ChatId, userId, cancellationToken)
            ?? throw new InvalidOperationException("Joined chat could not be loaded.");
        return Ok(loaded.ToDetailsDto(userId, presenceTracker));
    }

    [HttpPost("{chatId:guid}/subscribe")]
    public async Task<ActionResult<ChatDetailsDto>> SubscribeToChannel(Guid chatId, CancellationToken cancellationToken)
    {
        var userId = User.GetRequiredUserId();
        var chat = await dbContext.Chats
            .Include(x => x.Members)
                .ThenInclude(x => x.User)
            .SingleOrDefaultAsync(x => x.Id == chatId && x.Type == ChatType.Channel, cancellationToken);
        if (chat is null)
        {
            return NotFound();
        }

        if (chat.Members.Any(member => member.UserId == userId))
        {
            var existing = await LoadChatWithMessages(chatId, userId, cancellationToken)
                ?? throw new InvalidOperationException("Subscribed channel could not be loaded.");
            return Ok(existing.ToDetailsDto(userId, presenceTracker));
        }

        if (string.IsNullOrWhiteSpace(chat.PublicUsername))
        {
            return StatusCode(StatusCodes.Status403Forbidden, "Private channels require an invite link.");
        }

        chat.Members.Add(CreateSubscriberMembership(chat, userId));
        chat.LastActivityAt = DateTimeOffset.UtcNow;
        await AddModerationLogAsync(userId, chatId, ModerationLogTargetType.User, userId, "channel.subscribed", null, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);

        var loaded = await LoadChatWithMessages(chatId, userId, cancellationToken)
            ?? throw new InvalidOperationException("Subscribed channel could not be loaded.");
        await NotifyChatCreatedAsync(loaded, cancellationToken);
        return Ok(loaded.ToDetailsDto(userId, presenceTracker));
    }

    [HttpDelete("{chatId:guid}/subscribe")]
    public async Task<IActionResult> UnsubscribeFromChannel(Guid chatId, CancellationToken cancellationToken)
    {
        var userId = User.GetRequiredUserId();
        var chat = await dbContext.Chats
            .Include(x => x.Members)
            .SingleOrDefaultAsync(x => x.Id == chatId && x.Type == ChatType.Channel, cancellationToken);
        if (chat is null)
        {
            return NotFound();
        }

        var membership = chat.Members.FirstOrDefault(member => member.UserId == userId);
        if (membership is null)
        {
            return NoContent();
        }

        if (membership.IsOwner || membership.Role == ChatMemberRole.Owner)
        {
            return BadRequest("Transfer channel ownership before leaving the channel.");
        }

        dbContext.ChatMembers.Remove(membership);
        await AddModerationLogAsync(userId, chatId, ModerationLogTargetType.User, userId, "channel.unsubscribed", null, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        await NotifyChatsChangedAsync([userId], cancellationToken);
        return NoContent();
    }

    [HttpGet("{chatId:guid}/topics")]
    public async Task<ActionResult<IReadOnlyList<ChatTopicDto>>> GetTopics(Guid chatId, CancellationToken cancellationToken)
    {
        var userId = User.GetRequiredUserId();
        var chat = await LoadChatMetadata(chatId, userId, cancellationToken);
        if (chat is null)
        {
            return NotFound();
        }

        return Ok(await LoadChatTopicDtosAsync(chatId, cancellationToken));
    }

    [HttpPost("{chatId:guid}/topics")]
    public async Task<ActionResult<ChatTopicDto>> CreateTopic(
        Guid chatId,
        CreateChatTopicRequest request,
        CancellationToken cancellationToken)
    {
        var userId = User.GetRequiredUserId();
        var chat = await LoadChatMetadata(chatId, userId, cancellationToken, asNoTracking: false);
        if (chat is null)
        {
            return NotFound();
        }

        if (chat.Type == ChatType.Direct)
        {
            return BadRequest("Direct chats do not support topics.");
        }

        if (!CanManageMembers(chat, userId))
        {
            return StatusCode(StatusCodes.Status403Forbidden, "Only admins can create topics.");
        }

        var title = request.Title?.Trim() ?? string.Empty;
        if (title.Length is < 2 or > 80)
        {
            return ValidationProblem("Topic title must be between 2 and 80 characters.");
        }

        var topic = new ChatTopic
        {
            ChatId = chatId,
            CreatedById = userId,
            Title = title,
            Icon = string.IsNullOrWhiteSpace(request.Icon) ? "💬" : request.Icon.Trim()[..Math.Min(request.Icon.Trim().Length, 8)],
            CreatedAt = DateTimeOffset.UtcNow,
            LastActivityAt = DateTimeOffset.UtcNow
        };
        chat.TopicsEnabled = true;
        dbContext.ChatTopics.Add(topic);
        await dbContext.SaveChangesAsync(cancellationToken);
        return Ok(new ChatTopicDto(topic.Id, topic.ChatId, topic.Title, topic.Icon, topic.IsPinned, topic.IsClosed, topic.CreatedAt, topic.LastActivityAt, 0));
    }

    [HttpGet("{chatId:guid}/messages")]
    public async Task<ActionResult<ChatMessagesPageDto>> GetMessages(
        Guid chatId,
        [FromQuery] Guid? beforeMessageId = null,
        [FromQuery] int limit = DefaultMessagePageSize,
        CancellationToken cancellationToken = default)
    {
        var userId = User.GetRequiredUserId();
        var chat = await LoadChatMetadata(chatId, userId, cancellationToken, allowPublicChannelPreview: true);
        if (chat is null)
        {
            return NotFound();
        }

        var page = await LoadMessagesPageAsync(chat, beforeMessageId, limit, cancellationToken);
        await MarkChannelMessagesViewedAsync(chat, userId, page.Messages, cancellationToken);
        var messages = page.Messages
            .Select(message => message.ToDto(presenceTracker, userId))
            .ToList();

        return Ok(new ChatMessagesPageDto(messages, page.HasMoreMessages));
    }

    [HttpGet("{chatId:guid}/messages/search")]
    public async Task<ActionResult<IReadOnlyList<MessageDto>>> SearchMessages(
        Guid chatId,
        [FromQuery] string q,
        [FromQuery] string? filter = null,
        [FromQuery] string? type = null,
        [FromQuery] int page = 1,
        [FromQuery] int limit = 50,
        [FromQuery] DateTimeOffset? date = null,
        [FromQuery] DateTimeOffset? dateFrom = null,
        [FromQuery] DateTimeOffset? dateTo = null,
        [FromQuery] Guid? authorId = null,
        [FromQuery] string? author = null,
        CancellationToken cancellationToken = default)
    {
        var userId = User.GetRequiredUserId();
        var chat = await LoadChatMetadata(chatId, userId, cancellationToken, allowPublicChannelPreview: true);
        if (chat is null)
        {
            return NotFound();
        }

        var searchPage = await SearchMessagesPageAsync(
            chat,
            userId,
            q,
            BuildSearchFilters(filter, type, date, dateFrom, dateTo, authorId, author),
            page,
            limit,
            cancellationToken);
        WriteSearchPageHeaders(searchPage.Page, searchPage.Limit, searchPage.HasMore);
        return Ok(searchPage.Messages);
    }

    [HttpGet("{chatId:guid}/messages/search/page")]
    public async Task<ActionResult<MessageSearchPageDto>> SearchMessagesPage(
        Guid chatId,
        [FromQuery] string q,
        [FromQuery] string? filter = null,
        [FromQuery] string? type = null,
        [FromQuery] int page = 1,
        [FromQuery] int limit = 50,
        [FromQuery] DateTimeOffset? date = null,
        [FromQuery] DateTimeOffset? dateFrom = null,
        [FromQuery] DateTimeOffset? dateTo = null,
        [FromQuery] Guid? authorId = null,
        [FromQuery] string? author = null,
        CancellationToken cancellationToken = default)
    {
        var userId = User.GetRequiredUserId();
        var chat = await LoadChatMetadata(chatId, userId, cancellationToken, allowPublicChannelPreview: true);
        if (chat is null)
        {
            return NotFound();
        }

        return Ok(await SearchMessagesPageAsync(
            chat,
            userId,
            q,
            BuildSearchFilters(filter, type, date, dateFrom, dateTo, authorId, author),
            page,
            limit,
            cancellationToken));
    }

    [HttpPut("{chatId:guid}/state")]
    public async Task<ActionResult<ChatSummaryDto>> UpdateChatState(
        Guid chatId,
        ChatStateRequest request,
        CancellationToken cancellationToken)
    {
        var userId = User.GetRequiredUserId();
        var chat = await dbContext.Chats
            .Include(x => x.Members)
                .ThenInclude(x => x.User)
            .SingleOrDefaultAsync(
                x => x.Id == chatId && x.Members.Any(member => member.UserId == userId),
                cancellationToken);

        if (chat is null)
        {
            return NotFound();
        }

        var membership = chat.Members.First(member => member.UserId == userId);
        membership.IsPinned = request.IsPinned ?? membership.IsPinned;
        membership.IsArchived = request.IsArchived ?? membership.IsArchived;
        membership.MutedUntil = request.IsMuted switch
        {
            false => null,
            true => request.MutedUntil ?? DateTimeOffset.UtcNow.AddYears(1),
            _ => request.MutedUntil ?? membership.MutedUntil
        };

        if (request.FolderKey is not null)
        {
            membership.FolderKey = NormalizeFolderKey(request.FolderKey);
        }

        if (request.HasProtectedContent is not null || request.DefaultMessageTtlSeconds is not null)
        {
            if (!CanManageMembers(chat, userId))
            {
                return StatusCode(StatusCodes.Status403Forbidden, "Only admins can update chat security settings.");
            }

            chat.HasProtectedContent = request.HasProtectedContent ?? chat.HasProtectedContent;
            if (request.DefaultMessageTtlSeconds is not null)
            {
                chat.DefaultMessageTtlSeconds = NormalizeTtlSeconds(request.DefaultMessageTtlSeconds, allowNull: true);
            }
            await AddModerationLogAsync(
                userId,
                chatId,
                ModerationLogTargetType.Chat,
                chatId,
                "chat.security.updated",
                null,
                cancellationToken);
        }

        if (request.ChannelSignaturesEnabled is not null || request.LinkedDiscussionChatId is not null)
        {
            if (chat.Type != ChatType.Channel)
            {
                return BadRequest("Only channels support channel settings.");
            }

            if (!CanManageMembers(chat, userId))
            {
                return StatusCode(StatusCodes.Status403Forbidden, "Only admins can update channel settings.");
            }

            if (request.ChannelSignaturesEnabled is not null)
            {
                chat.ChannelSignaturesEnabled = request.ChannelSignaturesEnabled.Value;
            }

            if (request.LinkedDiscussionChatId is not null)
            {
                var discussionChatExists = await dbContext.Chats
                    .AsNoTracking()
                    .AnyAsync(x =>
                        x.Id == request.LinkedDiscussionChatId.Value &&
                        x.Type == ChatType.Group &&
                        x.Members.Any(member => member.UserId == userId),
                        cancellationToken);
                if (!discussionChatExists)
                {
                    return BadRequest("Linked discussion chat must be an accessible group chat.");
                }

                chat.LinkedDiscussionChatId = request.LinkedDiscussionChatId;
            }

            await AddModerationLogAsync(
                userId,
                chatId,
                ModerationLogTargetType.Chat,
                chatId,
                "channel.settings.updated",
                null,
                cancellationToken);
        }

        await dbContext.SaveChangesAsync(cancellationToken);

        var latestMessages = await LoadLatestMessagesByChatIdAsync([chatId], cancellationToken);
        var unreadCounts = await LoadUnreadCountsByChatIdAsync(userId, [chatId], cancellationToken);
        await NotifyChatsChangedAsync([userId], cancellationToken);
        return Ok(chat.ToSummaryDto(
            userId,
            presenceTracker,
            latestMessages.GetValueOrDefault(chatId),
            unreadCounts.GetValueOrDefault(chatId)));
    }

    [HttpPost("{chatId:guid}/members")]
    public async Task<ActionResult<ChatDetailsDto>> AddMembers(
        Guid chatId,
        AddChatMembersRequest request,
        CancellationToken cancellationToken)
    {
        var userId = User.GetRequiredUserId();
        var requestedMemberIds = (request.MemberIds ?? Array.Empty<Guid>())
            .Where(id => id != Guid.Empty)
            .Distinct()
            .ToList();

        if (requestedMemberIds.Count == 0)
        {
            return BadRequest("At least one member is required.");
        }

        if (request.Role is not ChatMemberRole.Member and not ChatMemberRole.Admin)
        {
            return BadRequest("Members can only be added as a member or admin.");
        }

        var chat = await dbContext.Chats
            .Include(x => x.Members)
                .ThenInclude(x => x.User)
            .SingleOrDefaultAsync(
                x => x.Id == chatId && x.Members.Any(member => member.UserId == userId),
                cancellationToken);

        if (chat is null)
        {
            return NotFound();
        }

        if (chat.Type != ChatType.Channel)
        {
            return BadRequest("Only channels support adding members with this endpoint.");
        }

        if (!CanManageMembers(chat, userId))
        {
            return StatusCode(StatusCodes.Status403Forbidden, "Only admins can add members.");
        }

        var existingMemberIds = chat.Members
            .Select(member => member.UserId)
            .ToHashSet();
        var newMemberIds = requestedMemberIds
            .Where(id => !existingMemberIds.Contains(id))
            .ToList();

        if (newMemberIds.Count == 0)
        {
            var existing = await LoadChatWithMessages(chatId, userId, cancellationToken)
                ?? throw new InvalidOperationException("The chat could not be reloaded.");
            var existingPage = await LoadMessagesPageAsync(existing, beforeMessageId: null, DefaultMessagePageSize, cancellationToken);
            var existingPinnedMessages = await LoadPinnedMessagesAsync(existing, cancellationToken);
            return Ok(existing.ToDetailsDto(
                userId,
                presenceTracker,
                messagesOverride: existingPage.Messages,
                hasMoreMessages: existingPage.HasMoreMessages,
                pinnedMessagesOverride: existingPinnedMessages));
        }

        var users = await dbContext.Users
            .Where(user => newMemberIds.Contains(user.Id) && !user.IsBlocked)
            .Select(user => user.Id)
            .ToListAsync(cancellationToken);

        if (users.Count != newMemberIds.Count)
        {
            return BadRequest("Some members do not exist.");
        }

        var role = request.Role;
        var isAdmin = role == ChatMemberRole.Admin;
        var joinedAt = DateTimeOffset.UtcNow;

        foreach (var memberId in newMemberIds)
        {
            chat.Members.Add(new ChatMember
            {
                ChatId = chat.Id,
                Chat = chat,
                UserId = memberId,
                Role = role,
                IsOwner = false,
                CanPinMessages = isAdmin || request.CanPinMessages,
                CanDeleteMessages = isAdmin || request.CanDeleteMessages,
                CanInviteUsers = isAdmin || request.CanInviteUsers,
                CanPostMessages = isAdmin || request.CanPostMessages,
                JoinedAt = joinedAt
            });

            await AddModerationLogAsync(
                userId,
                chatId,
                ModerationLogTargetType.User,
                memberId,
                isAdmin ? "member.admin.added" : "member.added",
                null,
                cancellationToken);
        }

        chat.LastActivityAt = DateTimeOffset.UtcNow;
        await dbContext.SaveChangesAsync(cancellationToken);
        await NotifyChatsChangedAsync(chat.Members.Select(member => member.UserId).Concat(newMemberIds), cancellationToken);

        var loaded = await LoadChatWithMessages(chatId, userId, cancellationToken)
            ?? throw new InvalidOperationException("The chat could not be reloaded.");
        var page = await LoadMessagesPageAsync(loaded, beforeMessageId: null, DefaultMessagePageSize, cancellationToken);
        var pinnedMessages = await LoadPinnedMessagesAsync(loaded, cancellationToken);
        return Ok(loaded.ToDetailsDto(
            userId,
            presenceTracker,
            messagesOverride: page.Messages,
            hasMoreMessages: page.HasMoreMessages,
            pinnedMessagesOverride: pinnedMessages));
    }

    [HttpPut("{chatId:guid}/members/{memberUserId:guid}/permissions")]
    public async Task<ActionResult<ChatDetailsDto>> UpdateMemberPermissions(
        Guid chatId,
        Guid memberUserId,
        UpdateChatMemberPermissionsRequest request,
        CancellationToken cancellationToken)
    {
        var userId = User.GetRequiredUserId();
        var chat = await dbContext.Chats
            .Include(x => x.Members)
                .ThenInclude(x => x.User)
            .SingleOrDefaultAsync(
                x => x.Id == chatId &&
                     x.Members.Any(member => member.UserId == userId) &&
                     x.Members.Any(member => member.UserId == memberUserId),
                cancellationToken);

        if (chat is null)
        {
            return NotFound();
        }

        if (!CanManageMembers(chat, userId))
        {
            return StatusCode(StatusCodes.Status403Forbidden, "Only admins can update member permissions.");
        }

        var target = chat.Members.First(member => member.UserId == memberUserId);
        if (target.IsOwner && target.UserId != userId)
        {
            return BadRequest("Owner permissions cannot be changed by another member.");
        }

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

        await AddModerationLogAsync(userId, chatId, ModerationLogTargetType.User, memberUserId, "member.permissions.updated", null, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        await NotifyChatsChangedAsync(chat.Members.Select(member => member.UserId), cancellationToken);

        var loaded = await LoadChatWithMessages(chatId, userId, cancellationToken)
            ?? throw new InvalidOperationException("The chat could not be reloaded.");
        var page = await LoadMessagesPageAsync(loaded, beforeMessageId: null, DefaultMessagePageSize, cancellationToken);
        var pinnedMessages = await LoadPinnedMessagesAsync(loaded, cancellationToken);
        return Ok(loaded.ToDetailsDto(userId, presenceTracker, messagesOverride: page.Messages, hasMoreMessages: page.HasMoreMessages, pinnedMessagesOverride: pinnedMessages));
    }

    [HttpDelete("{chatId:guid}")]
    public async Task<IActionResult> DeleteChat(
        Guid chatId,
        [FromQuery] bool forEveryone = false,
        CancellationToken cancellationToken = default)
    {
        var userId = User.GetRequiredUserId();
        var chat = await dbContext.Chats
            .Include(x => x.Members)
                .ThenInclude(x => x.User)
            .Include(x => x.Messages)
                .ThenInclude(x => x.Attachments)
            .SingleOrDefaultAsync(x => x.Id == chatId && x.Members.Any(member => member.UserId == userId), cancellationToken);

        if (chat is null)
        {
            return NotFound();
        }

        var affectedUserIds = chat.Members
            .Select(x => x.UserId)
            .ToHashSet();

        if (forEveryone)
        {
            var currentMembership = chat.Members.First(x => x.UserId == userId);
            if (chat.Type != ChatType.Direct && !currentMembership.IsOwner)
            {
                return StatusCode(StatusCodes.Status403Forbidden, "Only chat owners can delete this chat for everyone.");
            }

            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 NoContent();
        }

        var membership = chat.Members.FirstOrDefault(x => x.UserId == userId);
        if (membership is null)
        {
            return NotFound();
        }

        dbContext.ChatMembers.Remove(membership);
        chat.Members.Remove(membership);

        if (chat.Members.Count == 0)
        {
            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 NoContent();
        }

        if (chat.Type == ChatType.Direct && chat.Members.Count == 1)
        {
            chat.Title = string.IsNullOrWhiteSpace(membership.User.DisplayName)
                ? membership.User.PhoneNumber ?? "Dialog"
                : membership.User.DisplayName;
        }

        if (chat.CreatedById == userId)
        {
            var replacementOwner = chat.Members
                .OrderByDescending(x => x.IsOwner)
                .ThenBy(x => x.JoinedAt)
                .First();

            replacementOwner.IsOwner = true;
            replacementOwner.Role = ChatMemberRole.Owner;
            replacementOwner.CanDeleteMessages = true;
            replacementOwner.CanPinMessages = true;
            replacementOwner.CanInviteUsers = true;
            replacementOwner.CanPostMessages = true;
            chat.CreatedById = replacementOwner.UserId;
        }

        if (chat.Type == ChatType.Channel && chat.Members.All(x => !x.IsOwner))
        {
            var replacementChannelOwner = chat.Members
                .OrderBy(x => x.JoinedAt)
                .First();
            replacementChannelOwner.IsOwner = true;
            replacementChannelOwner.Role = ChatMemberRole.Owner;
            replacementChannelOwner.CanDeleteMessages = true;
            replacementChannelOwner.CanPinMessages = true;
            replacementChannelOwner.CanInviteUsers = true;
            replacementChannelOwner.CanPostMessages = true;
        }

        chat.LastActivityAt = chat.Messages
            .OrderByDescending(x => x.SentAt)
            .Select(x => (DateTimeOffset?)x.SentAt)
            .FirstOrDefault() ?? chat.CreatedAt;

        await dbContext.SaveChangesAsync(cancellationToken);
        await NotifyChatsChangedAsync(affectedUserIds, cancellationToken);
        return NoContent();
    }

    [HttpDelete("{chatId:guid}/history")]
    public async Task<IActionResult> ClearChatHistory(Guid chatId, CancellationToken cancellationToken = default)
    {
        var userId = User.GetRequiredUserId();
        var chat = await dbContext.Chats
            .Include(x => x.Members)
                .ThenInclude(x => x.User)
            .Include(x => x.Messages)
                .ThenInclude(x => x.Attachments)
            .SingleOrDefaultAsync(x => x.Id == chatId && x.Members.Any(member => member.UserId == userId), cancellationToken);

        if (chat is null)
        {
            return NotFound();
        }

        var hasBotPeer = chat.Type == ChatType.Direct &&
                         chat.Members.Any(member => member.UserId != userId && member.User.IsBot);
        if (!hasBotPeer)
        {
            return StatusCode(
                StatusCodes.Status403Forbidden,
                "Chat history can only be cleared in direct chats with bots.");
        }

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

        var attachments = chat.Messages
            .SelectMany(x => x.Attachments)
            .ToList();
        var messages = chat.Messages.ToList();

        dbContext.MessageAttachments.RemoveRange(attachments);
        dbContext.Messages.RemoveRange(messages);
        chat.LastActivityAt = chat.CreatedAt;

        await dbContext.SaveChangesAsync(cancellationToken);

        DeleteFiles(attachmentPaths);
        await NotifyChatsChangedAsync(chat.Members.Select(x => x.UserId), cancellationToken);
        return NoContent();
    }

    [HttpPost("direct")]
    public async Task<ActionResult<ChatDetailsDto>> CreateDirectChat(CreateDirectChatRequest request, CancellationToken cancellationToken)
    {
        var currentUserId = User.GetRequiredUserId();
        if (request.UserId == currentUserId)
        {
            return BadRequest("You cannot create a direct chat with yourself.");
        }

        var otherUserExists = await dbContext.Users.AnyAsync(x => x.Id == request.UserId && !x.IsBlocked, cancellationToken);
        if (!otherUserExists)
        {
            return NotFound("User not found.");
        }

        if (await IsBlockedByPrivacyAsync(currentUserId, request.UserId, cancellationToken) ||
            await IsBlockedByPrivacyAsync(request.UserId, currentUserId, cancellationToken))
        {
            return StatusCode(StatusCodes.Status403Forbidden, "Direct chats are blocked by privacy settings.");
        }

        var existingChat = await dbContext.Chats
            .Include(x => x.Members)
                .ThenInclude(x => x.User)
            .Include(x => x.Messages)
                .ThenInclude(x => x.Sender)
            .Include(x => x.Messages)
                .ThenInclude(x => x.Attachments)
            .AsSplitQuery()
            .SingleOrDefaultAsync(x =>
                x.Type == ChatType.Direct &&
                x.Members.Count == 2 &&
                x.Members.Any(m => m.UserId == currentUserId) &&
                x.Members.Any(m => m.UserId == request.UserId),
                cancellationToken);

        if (existingChat is not null)
        {
            return Ok(existingChat.ToDetailsDto(currentUserId, presenceTracker));
        }

        var chat = new Chat
        {
            Type = ChatType.Direct,
            CreatedById = currentUserId,
            LastActivityAt = DateTimeOffset.UtcNow,
            Members =
            [
                new ChatMember { UserId = currentUserId, IsOwner = true, Role = ChatMemberRole.Owner, CanDeleteMessages = true },
                new ChatMember { UserId = request.UserId, IsOwner = false }
            ]
        };

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

        var loaded = await LoadChatWithMessages(chat.Id, currentUserId, cancellationToken)
            ?? throw new InvalidOperationException("Newly created chat could not be loaded.");

        await NotifyChatCreatedAsync(loaded, cancellationToken);
        return Ok(loaded.ToDetailsDto(currentUserId, presenceTracker));
    }

    [HttpPost("group")]
    public async Task<ActionResult<ChatDetailsDto>> CreateGroupChat(CreateGroupChatRequest request, CancellationToken cancellationToken)
    {
        var currentUserId = User.GetRequiredUserId();
        var title = request.Title?.Trim() ?? string.Empty;
        var memberIds = (request.MemberIds ?? Array.Empty<Guid>())
            .Append(currentUserId)
            .Distinct()
            .ToList();

        if (string.IsNullOrWhiteSpace(title) || memberIds.Count < 3)
        {
            return BadRequest("A title and at least 2 invited members are required for a group chat.");
        }

        var users = await dbContext.Users
            .Where(x => memberIds.Contains(x.Id) && !x.IsBlocked)
            .Select(x => x.Id)
            .ToListAsync(cancellationToken);

        if (users.Count != memberIds.Count)
        {
            return BadRequest("Some group members do not exist.");
        }

        var chat = new Chat
        {
            Type = ChatType.Group,
            Title = title,
            CreatedById = currentUserId,
            LastActivityAt = DateTimeOffset.UtcNow,
            Members = memberIds
                .Select(x => new ChatMember
                {
                    UserId = x,
                    IsOwner = x == currentUserId,
                    Role = x == currentUserId ? ChatMemberRole.Owner : ChatMemberRole.Member,
                    CanDeleteMessages = x == currentUserId
                })
                .ToList()
        };

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

        var loaded = await LoadChatWithMessages(chat.Id, currentUserId, cancellationToken)
            ?? throw new InvalidOperationException("Newly created group chat could not be loaded.");

        await NotifyChatCreatedAsync(loaded, cancellationToken);
        return Ok(loaded.ToDetailsDto(currentUserId, presenceTracker));
    }

    [HttpPost("channel")]
    public async Task<ActionResult<ChatDetailsDto>> CreateChannel(CreateChannelRequest request, CancellationToken cancellationToken)
    {
        var currentUserId = User.GetRequiredUserId();
        var title = request.Title?.Trim() ?? string.Empty;
        var description = TrimToNull(request.Description, 255);
        var memberIds = (request.MemberIds ?? Array.Empty<Guid>())
            .Append(currentUserId)
            .Distinct()
            .ToList();
        string? publicUsername;

        if (string.IsNullOrWhiteSpace(title))
        {
            return BadRequest("Channel title is required.");
        }

        try
        {
            publicUsername = NormalizePublicUsername(request.PublicUsername);
        }
        catch (InvalidOperationException ex)
        {
            return BadRequest(ex.Message);
        }

        if (publicUsername is not null)
        {
            var usernameTaken = await dbContext.Chats
                .AnyAsync(x => x.PublicUsername == publicUsername, cancellationToken);
            if (usernameTaken)
            {
                return Conflict("Public username is already taken.");
            }
        }

        var users = await dbContext.Users
            .Where(x => memberIds.Contains(x.Id) && !x.IsBlocked)
            .Select(x => x.Id)
            .ToListAsync(cancellationToken);

        if (users.Count != memberIds.Count)
        {
            return BadRequest("Some channel members do not exist.");
        }

        var chat = new Chat
        {
            Type = ChatType.Channel,
            Title = title,
            Description = description,
            PublicUsername = publicUsername,
            ChannelSignaturesEnabled = request.ChannelSignaturesEnabled,
            CreatedById = currentUserId,
            LastActivityAt = DateTimeOffset.UtcNow,
            Members = memberIds
                .Select(x => new ChatMember
                {
                    UserId = x,
                    IsOwner = x == currentUserId,
                    Role = x == currentUserId ? ChatMemberRole.Owner : ChatMemberRole.Member,
                    CanPinMessages = x == currentUserId,
                    CanDeleteMessages = x == currentUserId,
                    CanInviteUsers = x == currentUserId,
                    CanPostMessages = x == currentUserId
                })
                .ToList()
        };

        dbContext.Chats.Add(chat);
        if (publicUsername is null && request.CreateInviteLink)
        {
            dbContext.ChatInviteLinks.Add(new ChatInviteLink
            {
                ChatId = chat.Id,
                CreatedById = currentUserId,
                Token = await GenerateInviteTokenAsync(cancellationToken),
                Title = "Основная ссылка",
                CreatedAt = DateTimeOffset.UtcNow
            });
        }

        await dbContext.SaveChangesAsync(cancellationToken);

        var loaded = await LoadChatWithMessages(chat.Id, currentUserId, cancellationToken)
            ?? throw new InvalidOperationException("Newly created channel could not be loaded.");

        await NotifyChatCreatedAsync(loaded, cancellationToken);
        return Ok(loaded.ToDetailsDto(currentUserId, presenceTracker));
    }

    [HttpPost("{chatId:guid}/messages")]
    public async Task<ActionResult<MessageDto>> SendMessage(Guid chatId, SendMessageRequest request, CancellationToken cancellationToken)
    {
        var userId = User.GetRequiredUserId();
        var text = request.Text?.Trim() ?? string.Empty;
        if (string.IsNullOrWhiteSpace(text))
        {
            return BadRequest("Message text is required.");
        }

        var chat = await dbContext.Chats
            .Include(x => x.Members)
            .FirstOrDefaultAsync(x => x.Id == chatId && x.Members.Any(m => m.UserId == userId), cancellationToken);

        if (chat is null)
        {
            return NotFound();
        }

        if (!CanUserPublish(chat, userId))
        {
            return StatusCode(StatusCodes.Status403Forbidden, "Only channel owners can publish.");
        }

        var replyToMessage = await ResolveReplyTargetAsync(chatId, request.ReplyToMessageId, userId, cancellationToken);
        if (request.ReplyToMessageId is not null && replyToMessage is null)
        {
            return NotFound("The replied message was not found.");
        }

        if (request.TopicId is not null)
        {
            var topic = await dbContext.ChatTopics
                .SingleOrDefaultAsync(x => x.Id == request.TopicId.Value && x.ChatId == chatId, cancellationToken);
            if (topic is null || topic.IsClosed)
            {
                return NotFound("The topic was not found.");
            }
            topic.LastActivityAt = DateTimeOffset.UtcNow;
        }

        var sender = await dbContext.Users.SingleAsync(x => x.Id == userId, cancellationToken);
        var ttlSeconds = NormalizeTtlSeconds(request.TtlSeconds ?? chat.DefaultMessageTtlSeconds, allowNull: true);
        var sentAt = DateTimeOffset.UtcNow;
        var message = new Message
        {
            ChatId = chatId,
            Chat = chat,
            SenderId = userId,
            Sender = sender,
            PostedAsChannel = ShouldPostAsChannel(chat),
            AuthorSignature = ResolveChannelAuthorSignature(chat, sender),
            Text = text,
            ReplyToMessageId = replyToMessage?.Id,
            ReplyToMessage = replyToMessage,
            TopicId = request.TopicId,
            HasProtectedContent = request.HasProtectedContent ?? chat.HasProtectedContent,
            TtlSeconds = ttlSeconds,
            ExpiresAt = ttlSeconds is null ? null : sentAt.AddSeconds(ttlSeconds.Value),
            SentAt = sentAt
        };

        chat.LastActivityAt = message.SentAt;
        dbContext.Messages.Add(message);
        await dbContext.SaveChangesAsync(cancellationToken);

        var loadedMessage = await LoadMessageForMemberAsync(chatId, message.Id, userId, cancellationToken)
            ?? throw new InvalidOperationException("The created message could not be reloaded.");

        var dto = loadedMessage.ToDto(presenceTracker, userId);
        await NotifyMessageCreatedAsync(loadedMessage.Chat, loadedMessage, cancellationToken);
        await botUpdateService.EnqueueIncomingMessageAsync(loadedMessage.Id, cancellationToken);
        await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id);

        return Ok(dto);
    }

    [HttpPost("{chatId:guid}/messages/{messageId:guid}/forward")]
    public async Task<ActionResult<MessageDto>> ForwardMessage(
        Guid chatId,
        Guid messageId,
        ForwardMessageRequest request,
        CancellationToken cancellationToken)
    {
        var userId = User.GetRequiredUserId();
        var sourceMessage = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken);
        if (sourceMessage is null)
        {
            return NotFound();
        }

        if (sourceMessage.DeletedAt is not null)
        {
            return BadRequest("Deleted messages cannot be forwarded.");
        }

        if (sourceMessage.HasProtectedContent || sourceMessage.Chat.HasProtectedContent)
        {
            return BadRequest("Protected messages cannot be forwarded.");
        }

        var targetChat = await dbContext.Chats
            .Include(x => x.Members)
            .FirstOrDefaultAsync(
                x => x.Id == request.TargetChatId &&
                     x.Members.Any(member => member.UserId == userId),
                cancellationToken);
        if (targetChat is null)
        {
            return NotFound();
        }

        if (!CanUserPublish(targetChat, userId))
        {
            return StatusCode(StatusCodes.Status403Forbidden, "Only channel owners can publish.");
        }

        var sender = await dbContext.Users.SingleAsync(x => x.Id == userId, cancellationToken);
        var ttlSeconds = NormalizeTtlSeconds(targetChat.DefaultMessageTtlSeconds, allowNull: true);
        var sentAt = DateTimeOffset.UtcNow;
        var forwardedMessage = new Message
        {
            ChatId = targetChat.Id,
            Chat = targetChat,
            SenderId = userId,
            Sender = sender,
            PostedAsChannel = ShouldPostAsChannel(targetChat),
            AuthorSignature = ResolveChannelAuthorSignature(targetChat, sender),
            Text = sourceMessage.Text,
            ForwardedFromDisplayName = ResolveForwardedFromDisplayName(sourceMessage),
            MediaAlbumId = sourceMessage.MediaAlbumId is null ? null : Guid.NewGuid(),
            HasProtectedContent = targetChat.HasProtectedContent,
            TtlSeconds = ttlSeconds,
            ExpiresAt = ttlSeconds is null ? null : sentAt.AddSeconds(ttlSeconds.Value),
            SentAt = sentAt
        };

        var clonedAttachments = new List<MessageAttachment>();
        try
        {
            foreach (var sourceAttachment in sourceMessage.Attachments)
            {
                var clonedAttachment = await attachmentStorageService.CloneAsync(
                    forwardedMessage.Id,
                    sourceAttachment,
                    cancellationToken);
                clonedAttachments.Add(clonedAttachment);
                forwardedMessage.Attachments.Add(clonedAttachment);
            }

            targetChat.LastActivityAt = forwardedMessage.SentAt;
            dbContext.Messages.Add(forwardedMessage);
            await dbContext.SaveChangesAsync(cancellationToken);
        }
        catch
        {
            foreach (var clonedAttachment in clonedAttachments)
            {
                attachmentStorageService.DeleteIfExists(clonedAttachment.StoragePath);
            }

            throw;
        }

        var loadedForwardedMessage = await LoadMessageForMemberAsync(targetChat.Id, forwardedMessage.Id, userId, cancellationToken)
            ?? throw new InvalidOperationException("The forwarded message could not be reloaded.");

        var dto = loadedForwardedMessage.ToDto(presenceTracker, userId);
        await NotifyMessageCreatedAsync(loadedForwardedMessage.Chat, loadedForwardedMessage, cancellationToken);
        await botUpdateService.EnqueueIncomingMessageAsync(loadedForwardedMessage.Id, cancellationToken);
        await pushDispatchQueue.EnqueueAsync(targetChat.Id, forwardedMessage.Id);
        return Ok(dto);
    }

    [HttpPost("{chatId:guid}/messages/delivered")]
    public Task<IActionResult> AcknowledgeDelivered(
        Guid chatId,
        UpdateMessageReceiptRequest request,
        CancellationToken cancellationToken) =>
        AcknowledgeMessageReceiptAsync(chatId, request.MessageId, User.GetRequiredUserId(), markRead: false, cancellationToken);

    [HttpPost("{chatId:guid}/messages/read")]
    public Task<IActionResult> AcknowledgeRead(
        Guid chatId,
        UpdateMessageReceiptRequest request,
        CancellationToken cancellationToken) =>
        AcknowledgeMessageReceiptAsync(chatId, request.MessageId, User.GetRequiredUserId(), markRead: true, cancellationToken);

    [HttpPut("{chatId:guid}/messages/{messageId:guid}/reaction")]
    public async Task<ActionResult<MessageDto>> SetReaction(
        Guid chatId,
        Guid messageId,
        SetMessageReactionRequest request,
        CancellationToken cancellationToken)
    {
        var userId = User.GetRequiredUserId();
        var emoji = request.Emoji?.Trim() ?? string.Empty;
        if (string.IsNullOrWhiteSpace(emoji))
        {
            return BadRequest("Reaction emoji is required.");
        }

        if (emoji.Length > 24)
        {
            return BadRequest("Reaction emoji is too long.");
        }

        var message = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken);
        if (message is null)
        {
            return NotFound();
        }

        if (message.DeletedAt is not null)
        {
            return BadRequest("Deleted messages cannot be reacted to.");
        }

        var shouldNotifyReaction = false;
        var existingReaction = message.Reactions.FirstOrDefault(reaction => reaction.UserId == userId);
        if (existingReaction is not null && string.Equals(existingReaction.Emoji, emoji, StringComparison.Ordinal))
        {
            dbContext.MessageReactions.Remove(existingReaction);
        }
        else if (existingReaction is not null)
        {
            existingReaction.Emoji = emoji;
            existingReaction.CreatedAt = DateTimeOffset.UtcNow;
            shouldNotifyReaction = message.SenderId != userId;
        }
        else
        {
            dbContext.MessageReactions.Add(new MessageReaction
            {
                MessageId = message.Id,
                UserId = userId,
                Emoji = emoji,
                CreatedAt = DateTimeOffset.UtcNow
            });
            shouldNotifyReaction = message.SenderId != userId;
        }

        await dbContext.SaveChangesAsync(cancellationToken);

        var updatedMessage = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken)
            ?? throw new InvalidOperationException("The reacted message could not be reloaded.");

        var dto = updatedMessage.ToDto(presenceTracker, userId);
        await NotifyMessageUpdatedAsync(updatedMessage.Chat, [updatedMessage], cancellationToken);
        if (shouldNotifyReaction)
        {
            await pushDispatchQueue.EnqueueReactionAsync(
                chatId,
                messageId,
                userId,
                emoji,
                cancellationToken);
        }

        return Ok(dto);
    }

    [HttpPut("{chatId:guid}/messages/{messageId:guid}/pin")]
    public Task<ActionResult<MessageDto>> PinMessage(Guid chatId, Guid messageId, CancellationToken cancellationToken) =>
        SetMessagePinAsync(chatId, messageId, isPinned: true, cancellationToken);

    [HttpDelete("{chatId:guid}/messages/{messageId:guid}/pin")]
    public Task<ActionResult<MessageDto>> UnpinMessage(Guid chatId, Guid messageId, CancellationToken cancellationToken) =>
        SetMessagePinAsync(chatId, messageId, isPinned: false, cancellationToken);

    [HttpPost("{chatId:guid}/attachments")]
    [RequestFormLimits(MultipartBodyLengthLimit = MaxAttachmentSizeBytes)]
    [RequestSizeLimit(MaxAttachmentSizeBytes)]
    public async Task<ActionResult<MessageDto>> UploadAttachment(
        Guid chatId,
        [FromForm] UploadAttachmentRequest request,
        CancellationToken cancellationToken)
    {
        var userId = User.GetRequiredUserId();
        if (request.File is null)
        {
            return BadRequest("File is required.");
        }

        if (request.File.Length <= 0)
        {
            return BadRequest("File is empty.");
        }

        if (request.File.Length > MaxAttachmentSizeBytes)
        {
            return BadRequest($"File exceeds {MaxAttachmentSizeBytes} bytes.");
        }

        var chat = await dbContext.Chats
            .Include(x => x.Members)
            .FirstOrDefaultAsync(x => x.Id == chatId && x.Members.Any(m => m.UserId == userId), cancellationToken);

        if (chat is null)
        {
            return NotFound();
        }

        if (!CanUserPublish(chat, userId))
        {
            return StatusCode(StatusCodes.Status403Forbidden, "Only channel owners can publish.");
        }

        var replyToMessage = await ResolveReplyTargetAsync(chatId, request.ReplyToMessageId, userId, cancellationToken);
        if (request.ReplyToMessageId is not null && replyToMessage is null)
        {
            return NotFound("The replied message was not found.");
        }

        if (request.TopicId is not null)
        {
            var topic = await dbContext.ChatTopics
                .SingleOrDefaultAsync(x => x.Id == request.TopicId.Value && x.ChatId == chatId, cancellationToken);
            if (topic is null || topic.IsClosed)
            {
                return NotFound("The topic was not found.");
            }
            topic.LastActivityAt = DateTimeOffset.UtcNow;
        }

        var sender = await dbContext.Users.SingleAsync(x => x.Id == userId, cancellationToken);
        var text = request.Text?.Trim() ?? string.Empty;
        var ttlSeconds = NormalizeTtlSeconds(request.TtlSeconds ?? chat.DefaultMessageTtlSeconds, allowNull: true);
        var sentAt = DateTimeOffset.UtcNow;
        var message = new Message
        {
            ChatId = chatId,
            Chat = chat,
            SenderId = userId,
            Sender = sender,
            PostedAsChannel = ShouldPostAsChannel(chat),
            AuthorSignature = ResolveChannelAuthorSignature(chat, sender),
            Text = text,
            ReplyToMessageId = replyToMessage?.Id,
            ReplyToMessage = replyToMessage,
            TopicId = request.TopicId,
            HasProtectedContent = request.HasProtectedContent ?? chat.HasProtectedContent,
            TtlSeconds = ttlSeconds,
            ExpiresAt = ttlSeconds is null ? null : sentAt.AddSeconds(ttlSeconds.Value),
            SentAt = sentAt
        };

        MessageAttachment? attachment = null;
        try
        {
            attachment = await attachmentStorageService.SaveAsync(message.Id, request.File, cancellationToken);
            message.Attachments.Add(attachment);
            chat.LastActivityAt = message.SentAt;
            dbContext.Messages.Add(message);
            await dbContext.SaveChangesAsync(cancellationToken);
        }
        catch
        {
            if (attachment is not null)
            {
                attachmentStorageService.DeleteIfExists(attachment.StoragePath);
            }

            throw;
        }

        var loadedMessage = await LoadMessageForMemberAsync(chatId, message.Id, userId, cancellationToken)
            ?? throw new InvalidOperationException("The uploaded message could not be reloaded.");

        var dto = loadedMessage.ToDto(presenceTracker, userId);
        await NotifyMessageCreatedAsync(loadedMessage.Chat, loadedMessage, cancellationToken);
        await botUpdateService.EnqueueIncomingMessageAsync(loadedMessage.Id, cancellationToken);
        await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id);

        return Ok(dto);
    }

    [HttpPost("{chatId:guid}/attachments/album")]
    [RequestFormLimits(MultipartBodyLengthLimit = MaxAlbumSizeBytes)]
    [RequestSizeLimit(MaxAlbumSizeBytes)]
    public async Task<ActionResult<MessageDto>> UploadAttachmentAlbum(
        Guid chatId,
        [FromForm] UploadAttachmentAlbumRequest request,
        CancellationToken cancellationToken)
    {
        var userId = User.GetRequiredUserId();
        var files = request.Files
            .Where(file => file is not null)
            .ToList();

        if (files.Count == 0)
        {
            return BadRequest("At least one file is required.");
        }

        if (files.Count > MaxAlbumAttachments)
        {
            return BadRequest($"Albums can contain up to {MaxAlbumAttachments} files.");
        }

        if (files.Any(file => file.Length <= 0))
        {
            return BadRequest("Album contains an empty file.");
        }

        if (files.Any(file => file.Length > MaxAttachmentSizeBytes) || files.Sum(file => file.Length) > MaxAlbumSizeBytes)
        {
            return BadRequest($"Album exceeds {MaxAlbumSizeBytes} bytes or contains a file over {MaxAttachmentSizeBytes} bytes.");
        }

        var chat = await dbContext.Chats
            .Include(x => x.Members)
            .FirstOrDefaultAsync(x => x.Id == chatId && x.Members.Any(m => m.UserId == userId), cancellationToken);

        if (chat is null)
        {
            return NotFound();
        }

        if (!CanUserPublish(chat, userId))
        {
            return StatusCode(StatusCodes.Status403Forbidden, "You cannot publish in this chat.");
        }

        var replyToMessage = await ResolveReplyTargetAsync(chatId, request.ReplyToMessageId, userId, cancellationToken);
        if (request.ReplyToMessageId is not null && replyToMessage is null)
        {
            return NotFound("The replied message was not found.");
        }

        if (request.TopicId is not null)
        {
            var topic = await dbContext.ChatTopics
                .SingleOrDefaultAsync(x => x.Id == request.TopicId.Value && x.ChatId == chatId, cancellationToken);
            if (topic is null || topic.IsClosed)
            {
                return NotFound("The topic was not found.");
            }
            topic.LastActivityAt = DateTimeOffset.UtcNow;
        }

        var sender = await dbContext.Users.SingleAsync(x => x.Id == userId, cancellationToken);
        var ttlSeconds = NormalizeTtlSeconds(request.TtlSeconds ?? chat.DefaultMessageTtlSeconds, allowNull: true);
        var sentAt = DateTimeOffset.UtcNow;
        var message = new Message
        {
            ChatId = chatId,
            Chat = chat,
            SenderId = userId,
            Sender = sender,
            PostedAsChannel = ShouldPostAsChannel(chat),
            AuthorSignature = ResolveChannelAuthorSignature(chat, sender),
            Text = request.Text?.Trim() ?? string.Empty,
            ReplyToMessageId = replyToMessage?.Id,
            ReplyToMessage = replyToMessage,
            TopicId = request.TopicId,
            MediaAlbumId = Guid.NewGuid(),
            HasProtectedContent = request.HasProtectedContent ?? chat.HasProtectedContent,
            TtlSeconds = ttlSeconds,
            ExpiresAt = ttlSeconds is null ? null : sentAt.AddSeconds(ttlSeconds.Value),
            SentAt = sentAt
        };

        var savedAttachments = new List<MessageAttachment>();
        try
        {
            for (var index = 0; index < files.Count; index++)
            {
                var attachment = await attachmentStorageService.SaveAsync(message.Id, files[index], cancellationToken, sortOrder: index);
                savedAttachments.Add(attachment);
                message.Attachments.Add(attachment);
            }

            chat.LastActivityAt = message.SentAt;
            dbContext.Messages.Add(message);
            await dbContext.SaveChangesAsync(cancellationToken);
        }
        catch
        {
            foreach (var attachment in savedAttachments)
            {
                attachmentStorageService.DeleteIfExists(attachment.StoragePath);
            }

            throw;
        }

        var loadedMessage = await LoadMessageForMemberAsync(chatId, message.Id, userId, cancellationToken)
            ?? throw new InvalidOperationException("The uploaded album could not be reloaded.");

        var dto = loadedMessage.ToDto(presenceTracker, userId);
        await NotifyMessageCreatedAsync(loadedMessage.Chat, loadedMessage, cancellationToken);
        await botUpdateService.EnqueueIncomingMessageAsync(loadedMessage.Id, cancellationToken);
        await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id);

        return Ok(dto);
    }

    [HttpPut("{chatId:guid}/messages/{messageId:guid}")]
    public async Task<ActionResult<MessageDto>> UpdateMessage(
        Guid chatId,
        Guid messageId,
        UpdateMessageRequest request,
        CancellationToken cancellationToken)
    {
        var userId = User.GetRequiredUserId();
        var message = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken);
        if (message is null)
        {
            return NotFound();
        }

        if (message.SenderId != userId)
        {
            return StatusCode(StatusCodes.Status403Forbidden, "Only the message author can edit it.");
        }

        if (message.DeletedAt is not null)
        {
            return BadRequest("Deleted messages cannot be edited.");
        }

        var text = request.Text?.Trim() ?? string.Empty;
        if (string.IsNullOrWhiteSpace(text) && message.Attachments.Count == 0)
        {
            return BadRequest("Message text is required.");
        }

        message.Text = text;
        message.EditedAt = DateTimeOffset.UtcNow;
        await dbContext.SaveChangesAsync(cancellationToken);

        var updatedMessage = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken)
            ?? throw new InvalidOperationException("The updated message could not be reloaded.");

        var dto = updatedMessage.ToDto(presenceTracker, userId);
        await NotifyMessageUpdatedAsync(updatedMessage.Chat, [updatedMessage], cancellationToken);
        return Ok(dto);
    }

    [HttpDelete("{chatId:guid}/messages/{messageId:guid}")]
    public async Task<ActionResult<MessageDto>> DeleteMessage(Guid chatId, Guid messageId, CancellationToken cancellationToken)
    {
        var userId = User.GetRequiredUserId();
        var message = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken);
        if (message is null)
        {
            return NotFound();
        }

        if (message.DeletedAt is not null)
        {
            return Ok(message.ToDto(presenceTracker, userId));
        }

        if (message.SenderId != userId && !ResolvePermissions(message.Chat, userId).CanDeleteMessages)
        {
            return StatusCode(StatusCodes.Status403Forbidden, "You cannot delete this message.");
        }

        var attachments = message.Attachments.ToList();
        foreach (var attachment in attachments)
        {
            attachmentStorageService.DeleteIfExists(attachment.StoragePath);
        }

        if (message.Reactions.Count > 0)
        {
            dbContext.MessageReactions.RemoveRange(message.Reactions);
            message.Reactions.Clear();
        }

        dbContext.MessageAttachments.RemoveRange(attachments);
        message.Attachments.Clear();
        message.Text = string.Empty;
        message.EditedAt = null;
        message.DeletedAt = DateTimeOffset.UtcNow;
        message.PinnedAt = null;
        message.PinnedByUserId = null;
        message.PinnedByUser = null;
        await AddModerationLogAsync(userId, chatId, ModerationLogTargetType.Message, messageId, "message.deleted", null, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);

        var updatedMessage = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken)
            ?? throw new InvalidOperationException("The deleted message could not be reloaded.");

        var dto = updatedMessage.ToDto(presenceTracker, userId);
        await NotifyMessageUpdatedAsync(updatedMessage.Chat, [updatedMessage], cancellationToken);
        return Ok(dto);
    }

    [HttpGet("{chatId:guid}/attachments/{attachmentId:guid}/content")]
    public async Task<IActionResult> DownloadAttachment(Guid chatId, Guid attachmentId, CancellationToken cancellationToken)
    {
        var userId = User.GetRequiredUserId();
        var attachment = await dbContext.MessageAttachments
            .Include(x => x.Message)
                .ThenInclude(x => x.Chat)
                    .ThenInclude(x => x.Members)
            .SingleOrDefaultAsync(
                x => x.Id == attachmentId &&
                     x.Message.ChatId == chatId &&
                     x.Message.Chat.Members.Any(m => m.UserId == userId),
                cancellationToken);

        if (attachment is null || !System.IO.File.Exists(attachment.StoragePath))
        {
            return NotFound();
        }

        var stream = System.IO.File.OpenRead(attachment.StoragePath);
        return File(stream, string.IsNullOrWhiteSpace(attachment.ContentType) ? MediaTypeNames.Application.Octet : attachment.ContentType, attachment.OriginalFileName);
    }

    [HttpPost("messages/cleanup-expired")]
    public async Task<ActionResult<ExpiredMessagesCleanupDto>> CleanupExpiredMessages(CancellationToken cancellationToken)
    {
        var userId = User.GetRequiredUserId();
        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)
            .Include(x => x.Views)
            .Where(x =>
                x.ExpiresAt != null &&
                x.ExpiresAt <= now &&
                x.DeletedAt == null &&
                x.Chat.Members.Any(member => member.UserId == userId))
            .Take(250)
            .ToListAsync(cancellationToken);

        var affectedChats = messages
            .Select(x => x.Chat)
            .DistinctBy(x => x.Id)
            .ToList();

        foreach (var message in messages)
        {
            DeleteFiles(message.Attachments.Select(x => x.StoragePath));
            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);

        foreach (var chat in affectedChats)
        {
            await NotifyMessageUpdatedAsync(chat, messages.Where(x => x.ChatId == chat.Id).ToList(), cancellationToken);
        }

        return Ok(new ExpiredMessagesCleanupDto(messages.Count, now));
    }

    [HttpGet("{chatId:guid}/moderation-log")]
    public async Task<ActionResult<IReadOnlyList<ModerationLogDto>>> GetModerationLog(
        Guid chatId,
        [FromQuery] int limit = 100,
        CancellationToken cancellationToken = default)
    {
        var userId = User.GetRequiredUserId();
        var chat = await LoadChatMetadata(chatId, userId, cancellationToken);
        if (chat is null)
        {
            return NotFound();
        }

        if (!CanManageMembers(chat, userId))
        {
            return StatusCode(StatusCodes.Status403Forbidden, "Only admins can view moderation logs.");
        }

        var take = Math.Clamp(limit, 1, 250);
        var entries = await dbContext.ModerationLogEntries
            .AsNoTracking()
            .Include(x => x.ActorUser)
                .ThenInclude(x => x.Sessions)
            .Where(x => x.ChatId == chatId)
            .OrderByDescending(x => x.CreatedAt)
            .Take(take)
            .ToListAsync(cancellationToken);

        return Ok(entries.Select(x => x.ToDto(presenceTracker)).ToList());
    }

    private async Task<ActionResult<MessageDto>> SetMessagePinAsync(
        Guid chatId,
        Guid messageId,
        bool isPinned,
        CancellationToken cancellationToken)
    {
        var userId = User.GetRequiredUserId();
        var message = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken);
        if (message is null)
        {
            return NotFound();
        }

        if (message.DeletedAt is not null)
        {
            return BadRequest("Deleted messages cannot be pinned.");
        }

        if (!ResolvePermissions(message.Chat, userId).CanPinMessages)
        {
            return StatusCode(StatusCodes.Status403Forbidden, "You cannot pin messages in this chat.");
        }

        if (isPinned)
        {
            message.PinnedAt = DateTimeOffset.UtcNow;
            message.PinnedByUserId = userId;
        }
        else
        {
            message.PinnedAt = null;
            message.PinnedByUserId = null;
            message.PinnedByUser = null;
        }

        await AddModerationLogAsync(
            userId,
            chatId,
            ModerationLogTargetType.Message,
            messageId,
            isPinned ? "message.pinned" : "message.unpinned",
            null,
            cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);

        var updatedMessage = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken)
            ?? throw new InvalidOperationException("The pinned message could not be reloaded.");

        var dto = updatedMessage.ToDto(presenceTracker, userId);
        await NotifyMessageUpdatedAsync(updatedMessage.Chat, [updatedMessage], cancellationToken);
        return Ok(dto);
    }

    private async Task<IActionResult> AcknowledgeMessageReceiptAsync(
        Guid chatId,
        Guid messageId,
        Guid userId,
        bool markRead,
        CancellationToken cancellationToken)
    {
        var targetMessage = await dbContext.Messages
            .Include(x => x.Chat)
                .ThenInclude(x => x.Members)
            .Include(x => x.Sender)
            .Include(x => x.PinnedByUser)
            .Include(x => x.Attachments)
            .Include(x => x.Reactions)
            .Include(x => x.Views)
            .Include(x => x.ReplyToMessage)
                .ThenInclude(x => x!.Sender)
            .Include(x => x.ReplyToMessage)
                .ThenInclude(x => x!.Attachments)
            .SingleOrDefaultAsync(
                x => x.Id == messageId &&
                     x.ChatId == chatId &&
                     x.Chat.Members.Any(member => member.UserId == userId),
                cancellationToken);

        if (targetMessage is null)
        {
            return NotFound();
        }

        if (targetMessage.SenderId == userId)
        {
            return Ok();
        }

        var membership = targetMessage.Chat.Members.First(member => member.UserId == userId);
        var previousDeliveredAt = membership.LastDeliveredAt;
        var previousReadAt = membership.LastReadAt;

        var changed = false;
        if (membership.LastDeliveredAt is null || membership.LastDeliveredAt.Value < targetMessage.SentAt)
        {
            membership.LastDeliveredAt = targetMessage.SentAt;
            changed = true;
        }

        if (markRead && (membership.LastReadAt is null || membership.LastReadAt.Value < targetMessage.SentAt))
        {
            membership.LastReadAt = targetMessage.SentAt;
            changed = true;
        }

        if (!changed)
        {
            return Ok();
        }

        await dbContext.SaveChangesAsync(cancellationToken);

        var changedAfter = markRead ? previousReadAt : previousDeliveredAt;
        var changedMessages = await LoadReceiptAffectedMessagesAsync(
            chatId,
            userId,
            changedAfter,
            targetMessage.SentAt,
            cancellationToken);

        if (changedMessages.Count > 0)
        {
            await NotifyMessageUpdatedAsync(targetMessage.Chat, changedMessages, cancellationToken);
        }

        await NotifyChatsChangedAsync([userId], cancellationToken);

        return Ok();
    }

    private async Task UpdateReadReceiptForOpenedChatAsync(Chat chat, Guid userId, CancellationToken cancellationToken)
    {
        var latestIncomingAt = await dbContext.Messages
            .Where(message => message.ChatId == chat.Id && message.SenderId != userId)
            .Select(message => (DateTimeOffset?)message.SentAt)
            .MaxAsync(cancellationToken);

        if (latestIncomingAt is null)
        {
            return;
        }

        var membership = chat.Members.First(member => member.UserId == userId);
        var previousReadAt = membership.LastReadAt;
        var previousDeliveredAt = membership.LastDeliveredAt;

        var changed = false;
        if (membership.LastDeliveredAt is null || membership.LastDeliveredAt.Value < latestIncomingAt.Value)
        {
            membership.LastDeliveredAt = latestIncomingAt.Value;
            changed = true;
        }

        if (membership.LastReadAt is null || membership.LastReadAt.Value < latestIncomingAt.Value)
        {
            membership.LastReadAt = latestIncomingAt.Value;
            changed = true;
        }

        if (!changed)
        {
            return;
        }

        await dbContext.SaveChangesAsync(cancellationToken);

        var changedAfter = previousReadAt ?? previousDeliveredAt;
        var changedMessages = await LoadReceiptAffectedMessagesAsync(
            chat.Id,
            userId,
            changedAfter,
            latestIncomingAt.Value,
            cancellationToken);

        if (changedMessages.Count > 0)
        {
            await NotifyMessageUpdatedAsync(chat, changedMessages, cancellationToken);
        }

        await NotifyChatsChangedAsync([userId], cancellationToken);
    }

    private async Task MarkChannelMessagesViewedAsync(
        Chat chat,
        Guid userId,
        IReadOnlyCollection<Message> messages,
        CancellationToken cancellationToken)
    {
        if (chat.Type != ChatType.Channel || messages.Count == 0)
        {
            return;
        }

        var messageIds = messages
            .Where(message => message.PostedAsChannel && message.DeletedAt is null)
            .Select(message => message.Id)
            .Distinct()
            .ToList();
        if (messageIds.Count == 0)
        {
            return;
        }

        var existingViewedMessageIds = await dbContext.MessageViews
            .AsNoTracking()
            .Where(view => view.ViewerUserId == userId && messageIds.Contains(view.MessageId))
            .Select(view => view.MessageId)
            .ToListAsync(cancellationToken);
        var existingSet = existingViewedMessageIds.ToHashSet();
        var viewedAt = DateTimeOffset.UtcNow;
        var changed = false;

        foreach (var message in messages.Where(message => messageIds.Contains(message.Id)))
        {
            if (!existingSet.Add(message.Id))
            {
                continue;
            }

            var view = new MessageView
            {
                MessageId = message.Id,
                ViewerUserId = userId,
                ViewedAt = viewedAt
            };
            dbContext.MessageViews.Add(view);
            message.Views.Add(view);
            changed = true;
        }

        if (changed)
        {
            await dbContext.SaveChangesAsync(cancellationToken);
        }
    }

    private async Task<List<Message>> LoadReceiptAffectedMessagesAsync(
        Guid chatId,
        Guid recipientUserId,
        DateTimeOffset? changedAfter,
        DateTimeOffset changedBeforeOrAt,
        CancellationToken cancellationToken)
    {
        var messages = await dbContext.Messages
            .Include(x => x.Chat)
                .ThenInclude(x => x.Members)
            .Include(x => x.Sender)
            .Include(x => x.PinnedByUser)
            .Include(x => x.Attachments)
            .Include(x => x.Reactions)
            .Include(x => x.Views)
            .Include(x => x.ReplyToMessage)
                .ThenInclude(x => x!.Sender)
            .Include(x => x.ReplyToMessage)
                .ThenInclude(x => x!.Attachments)
            .Where(x =>
                x.ChatId == chatId &&
                x.SenderId != recipientUserId)
            .ToListAsync(cancellationToken);

        return messages
            .Where(x =>
                x.SentAt <= changedBeforeOrAt &&
                (changedAfter is null || x.SentAt > changedAfter.Value))
            .OrderBy(x => x.SentAt)
            .ToList();
    }

    private async Task<IReadOnlyDictionary<Guid, Message>> LoadLatestMessagesByChatIdAsync(
        IReadOnlyCollection<Guid> chatIds,
        CancellationToken cancellationToken)
    {
        if (chatIds.Count == 0)
        {
            return new Dictionary<Guid, Message>();
        }

        var latestSentAtByChatQuery = dbContext.Messages
            .Where(message => chatIds.Contains(message.ChatId))
            .GroupBy(message => message.ChatId)
            .Select(group => new
            {
                ChatId = group.Key,
                SentAt = group.Max(message => message.SentAt)
            });

        var latestMessages = await dbContext.Messages
            .Join(
                latestSentAtByChatQuery,
                message => new { message.ChatId, message.SentAt },
                latest => new { latest.ChatId, latest.SentAt },
                (message, _) => message)
            .Include(message => message.Sender)
            .Include(message => message.PinnedByUser)
            .Include(message => message.Attachments)
            .AsNoTracking()
            .ToListAsync(cancellationToken);

        return latestMessages
            .GroupBy(message => message.ChatId)
            .ToDictionary(
                group => group.Key,
                group => group.OrderByDescending(message => message.SentAt).First());
    }

    private async Task<IReadOnlyDictionary<Guid, int>> LoadUnreadCountsByChatIdAsync(
        Guid userId,
        IReadOnlyCollection<Guid> chatIds,
        CancellationToken cancellationToken)
    {
        if (chatIds.Count == 0)
        {
            return new Dictionary<Guid, int>();
        }

        var unreadCounts = await dbContext.Messages
            .Where(message =>
                chatIds.Contains(message.ChatId) &&
                message.SenderId != userId &&
                message.DeletedAt == null)
            .Join(
                dbContext.ChatMembers.Where(member => member.UserId == userId),
                message => message.ChatId,
                member => member.ChatId,
                (message, member) => new
                {
                    message.ChatId,
                    message.SentAt,
                    member.LastReadAt
                })
            .Where(x => x.LastReadAt == null || x.SentAt > x.LastReadAt.Value)
            .GroupBy(x => x.ChatId)
            .Select(group => new
            {
                ChatId = group.Key,
                Count = group.Count()
            })
            .ToListAsync(cancellationToken);

        return unreadCounts.ToDictionary(x => x.ChatId, x => x.Count);
    }

    private async Task<IReadOnlyList<Message>> LoadPinnedMessagesAsync(Chat chat, CancellationToken cancellationToken)
    {
        var messages = await dbContext.Messages
            .Where(message =>
                message.ChatId == chat.Id &&
                message.PinnedAt != null &&
                message.DeletedAt == null)
            .OrderByDescending(message => message.PinnedAt)
            .Take(5)
            .Include(message => message.Sender)
            .Include(message => message.PinnedByUser)
            .Include(message => message.Attachments)
            .Include(message => message.Reactions)
            .Include(message => message.Views)
            .Include(message => message.ReplyToMessage)
                .ThenInclude(message => message!.Sender)
            .Include(message => message.ReplyToMessage)
                .ThenInclude(message => message!.Attachments)
            .AsNoTracking()
            .ToListAsync(cancellationToken);

        foreach (var message in messages)
        {
            message.Chat = chat;
        }

        return messages;
    }

    private sealed record MessagesPage(IReadOnlyList<Message> Messages, bool HasMoreMessages);

    private async Task<MessagesPage> LoadMessagesPageAsync(
        Chat chat,
        Guid? beforeMessageId,
        int requestedLimit,
        CancellationToken cancellationToken)
    {
        var limit = Math.Clamp(requestedLimit, 1, MaxMessagePageSize);
        var query = dbContext.Messages.Where(message => message.ChatId == chat.Id);

        if (beforeMessageId is not null)
        {
            var boundary = await dbContext.Messages
                .AsNoTracking()
                .Where(message => message.ChatId == chat.Id && message.Id == beforeMessageId.Value)
                .Select(message => new { message.SentAt })
                .SingleOrDefaultAsync(cancellationToken);

            if (boundary is null)
            {
                return new MessagesPage([], HasMoreMessages: false);
            }

            query = query.Where(message => message.SentAt < boundary.SentAt);
        }

        var messages = await query
            .OrderByDescending(message => message.SentAt)
            .Take(limit + 1)
            .Include(message => message.Sender)
            .Include(message => message.PinnedByUser)
            .Include(message => message.Attachments)
            .Include(message => message.Reactions)
            .Include(message => message.Views)
            .Include(message => message.ReplyToMessage)
                .ThenInclude(message => message!.Sender)
            .Include(message => message.ReplyToMessage)
                .ThenInclude(message => message!.Attachments)
            .AsNoTracking()
            .ToListAsync(cancellationToken);

        var hasMoreMessages = messages.Count > limit;
        var pageMessages = messages
            .Take(limit)
            .OrderBy(message => message.SentAt)
            .ToList();

        foreach (var message in pageMessages)
        {
            message.Chat = chat;
        }

        return new MessagesPage(pageMessages, hasMoreMessages);
    }

    private async Task<Chat?> LoadChatMetadata(
        Guid chatId,
        Guid userId,
        CancellationToken cancellationToken,
        bool asNoTracking = true,
        bool allowPublicChannelPreview = false)
    {
        var query = dbContext.Chats
            .Include(x => x.Members)
                .ThenInclude(x => x.User)
            .AsSplitQuery();

        if (asNoTracking)
        {
            query = query.AsNoTracking();
        }

        return await query.SingleOrDefaultAsync(
            x => x.Id == chatId &&
                 (x.Members.Any(m => m.UserId == userId) ||
                  (allowPublicChannelPreview &&
                   x.Type == ChatType.Channel &&
                   x.PublicUsername != null &&
                   x.PublicUsername != string.Empty)),
            cancellationToken);
    }

    private async Task<Chat?> LoadChatWithMessages(Guid chatId, Guid userId, CancellationToken cancellationToken, bool asNoTracking = true)
    {
        var query = dbContext.Chats
            .Include(x => x.Members)
                .ThenInclude(x => x.User)
            .Include(x => x.Messages)
                .ThenInclude(x => x.Sender)
            .Include(x => x.Messages)
                .ThenInclude(x => x.PinnedByUser)
            .Include(x => x.Messages)
                .ThenInclude(x => x.Attachments)
            .Include(x => x.Messages)
                .ThenInclude(x => x.Reactions)
            .Include(x => x.Messages)
                .ThenInclude(x => x.Views)
            .Include(x => x.Messages)
                .ThenInclude(x => x.ReplyToMessage)
                    .ThenInclude(x => x!.Sender)
            .Include(x => x.Messages)
                .ThenInclude(x => x.ReplyToMessage)
                    .ThenInclude(x => x!.Attachments)
            .AsSplitQuery();

        if (asNoTracking)
        {
            query = query.AsNoTracking();
        }

        return await query.SingleOrDefaultAsync(x => x.Id == chatId && x.Members.Any(m => m.UserId == userId), cancellationToken);
    }

    private async Task<Message?> LoadMessageForMemberAsync(Guid chatId, Guid messageId, Guid userId, CancellationToken cancellationToken)
    {
        return await dbContext.Messages
            .Include(x => x.Chat)
                .ThenInclude(x => x.Members)
            .Include(x => x.Sender)
            .Include(x => x.PinnedByUser)
            .Include(x => x.Attachments)
            .Include(x => x.Reactions)
            .Include(x => x.Views)
            .Include(x => x.ReplyToMessage)
                .ThenInclude(x => x!.Sender)
            .Include(x => x.ReplyToMessage)
                .ThenInclude(x => x!.Attachments)
            .SingleOrDefaultAsync(
                x => x.Id == messageId &&
                     x.ChatId == chatId &&
                     x.Chat.Members.Any(m => m.UserId == userId),
                cancellationToken);
    }

    private Task<Message?> ResolveReplyTargetAsync(
        Guid chatId,
        Guid? replyToMessageId,
        Guid userId,
        CancellationToken cancellationToken)
    {
        if (replyToMessageId is null)
        {
            return Task.FromResult<Message?>(null);
        }

        return dbContext.Messages
            .Include(x => x.Sender)
            .Include(x => x.Attachments)
            .SingleOrDefaultAsync(
                x => x.Id == replyToMessageId.Value &&
                     x.ChatId == chatId &&
                     x.Chat.Members.Any(member => member.UserId == userId),
                cancellationToken);
    }

    private async Task<List<Chat>> LoadChatSummariesForUserAsync(Guid userId, CancellationToken cancellationToken) =>
        await dbContext.Chats
            .Where(x => x.Members.Any(member => member.UserId == userId))
            .Include(x => x.Members)
                .ThenInclude(x => x.User)
            .AsNoTracking()
            .AsSplitQuery()
            .ToListAsync(cancellationToken);

    private async Task<ChatSearchPageDto> SearchChatsPageAsync(
        Guid userId,
        string? rawQuery,
        SearchFilters filters,
        int requestedPage,
        int requestedLimit,
        CancellationToken cancellationToken)
    {
        var query = rawQuery?.Trim() ?? string.Empty;
        var page = NormalizeSearchPage(requestedPage);
        var take = NormalizeSearchLimit(requestedLimit);
        if (query.Length < 2 && !filters.HasMessageFilter)
        {
            return new ChatSearchPageDto([], page, take, HasMore: false);
        }

        var skip = (page - 1) * take;
        var chats = await LoadChatSummariesForUserAsync(userId, cancellationToken);
        var chatIds = chats.Select(x => x.Id).ToList();
        var latestMessagesByChatId = await LoadLatestMessagesByChatIdAsync(chatIds, cancellationToken);
        var unreadCountsByChatId = await LoadUnreadCountsByChatIdAsync(userId, chatIds, cancellationToken);
        var summariesByChatId = chats.ToDictionary(
            x => x.Id,
            x => x.ToSummaryDto(
                userId,
                presenceTracker,
                latestMessagesByChatId.GetValueOrDefault(x.Id),
                unreadCountsByChatId.GetValueOrDefault(x.Id)));

        var chatMatches = query.Length >= 2 && !filters.HasMessageFilter
            ? chats
                .Concat(await LoadPublicChannelSearchResultsAsync(query, cancellationToken))
                .GroupBy(chat => chat.Id)
                .Select(group => group.First())
                .Where(chat => MatchesChatSearch(chat, query))
                .OrderByDescending(chat => chat.LastActivityAt ?? chat.CreatedAt)
                .Select(chat =>
                {
                    var summary = summariesByChatId.TryGetValue(chat.Id, out var memberSummary)
                        ? memberSummary
                        : chat.ToSummaryDto(userId, presenceTracker);
                    return new ChatSearchResultDto(
                        summary,
                        null,
                        summary.Title,
                        summary.LastActivityAt,
                        summary.Id);
                })
                .ToList()
            : [];

        var selectedChatMatches = chatMatches
            .Skip(skip)
            .Take(take)
            .ToList();
        var messageSkip = Math.Max(0, skip - chatMatches.Count);
        var messageTake = take - selectedChatMatches.Count;
        var results = new List<ChatSearchResultDto>(selectedChatMatches);
        var messageHasMore = false;

        if (messageTake > 0)
        {
            var messageMatches = await LoadSearchMessagesAsync(
                chatIds,
                query,
                filters,
                messageSkip,
                messageTake + 1,
                cancellationToken);
            messageHasMore = messageMatches.Count > messageTake;

            foreach (var message in messageMatches.Take(messageTake))
            {
                if (!summariesByChatId.TryGetValue(message.ChatId, out var summary))
                {
                    continue;
                }

                results.Add(ToSearchResult(summary, message, userId));
            }
        }

        var hasMore = skip + take < chatMatches.Count || messageHasMore;
        return new ChatSearchPageDto(results, page, take, hasMore);
    }

    private async Task<List<Chat>> LoadPublicChannelSearchResultsAsync(
        string query,
        CancellationToken cancellationToken)
    {
        var pattern = BuildLikePattern(query);
        var usernameQuery = NormalizePublicSearchUsername(query);
        var usernamePattern = usernameQuery is null ? null : BuildLikePattern(usernameQuery);
        return await dbContext.Chats
            .Where(chat =>
                chat.Type == ChatType.Channel &&
                chat.PublicUsername != null &&
                chat.PublicUsername != string.Empty &&
                ((chat.Title != null && EF.Functions.Like(chat.Title, pattern, "\\")) ||
                 EF.Functions.Like(chat.PublicUsername, pattern, "\\") ||
                 (usernamePattern != null && EF.Functions.Like(chat.PublicUsername, usernamePattern, "\\")) ||
                 (chat.Description != null && EF.Functions.Like(chat.Description, pattern, "\\"))))
            .Include(chat => chat.Members)
                .ThenInclude(member => member.User)
            .AsNoTracking()
            .AsSplitQuery()
            .ToListAsync(cancellationToken);
    }

    private async Task<MessageSearchPageDto> SearchMessagesPageAsync(
        Chat chat,
        Guid userId,
        string? rawQuery,
        SearchFilters filters,
        int requestedPage,
        int requestedLimit,
        CancellationToken cancellationToken)
    {
        var query = rawQuery?.Trim() ?? string.Empty;
        var page = NormalizeSearchPage(requestedPage);
        var take = NormalizeSearchLimit(requestedLimit);
        if (query.Length < 2 && !filters.HasMessageFilter)
        {
            return new MessageSearchPageDto([], page, take, HasMore: false);
        }

        var messages = await LoadSearchMessagesAsync(
            [chat.Id],
            query,
            filters,
            (page - 1) * take,
            take + 1,
            cancellationToken);
        var hasMore = messages.Count > take;
        var dtos = messages
            .Take(take)
            .Select(message =>
            {
                message.Chat = chat;
                return message.ToDto(presenceTracker, userId);
            })
            .ToList();

        return new MessageSearchPageDto(dtos, page, take, hasMore);
    }

    private ChatSearchResultDto ToSearchResult(ChatSummaryDto summary, Message message, Guid userId)
    {
        var messageDto = message.ToDto(presenceTracker, userId);
        return new ChatSearchResultDto(
            summary,
            messageDto,
            BuildSearchMatchText(message),
            message.SentAt,
            summary.Id,
            message.Id,
            message.SenderId,
            message.SentAt);
    }

    private async Task<List<Message>> LoadSearchMessagesAsync(
        IReadOnlyCollection<Guid> chatIds,
        string query,
        SearchFilters filters,
        int skip,
        int limit,
        CancellationToken cancellationToken)
    {
        if (chatIds.Count == 0)
        {
            return [];
        }

        var messagesQuery = dbContext.Messages
            .Where(message => chatIds.Contains(message.ChatId) && message.DeletedAt == null);

        if (!string.IsNullOrWhiteSpace(query))
        {
            var queryPattern = BuildLikePattern(query);
            messagesQuery = messagesQuery.Where(message =>
                EF.Functions.Like(message.Text, queryPattern, "\\") ||
                message.Attachments.Any(attachment => EF.Functions.Like(attachment.OriginalFileName, queryPattern, "\\")));
        }

        if (filters.DateFrom is not null)
        {
            messagesQuery = messagesQuery.Where(message => message.SentAt >= filters.DateFrom.Value);
        }

        if (filters.DateTo is not null)
        {
            messagesQuery = messagesQuery.Where(message => message.SentAt <= filters.DateTo.Value);
        }

        if (filters.AuthorId is not null)
        {
            messagesQuery = messagesQuery.Where(message => message.SenderId == filters.AuthorId.Value);
        }

        if (!string.IsNullOrWhiteSpace(filters.AuthorQuery))
        {
            var authorPattern = BuildLikePattern(filters.AuthorQuery);
            messagesQuery = messagesQuery.Where(message =>
                EF.Functions.Like(message.Sender.DisplayName, authorPattern, "\\") ||
                EF.Functions.Like(message.Sender.Username, authorPattern, "\\") ||
                (message.Sender.PhoneNumber != null && EF.Functions.Like(message.Sender.PhoneNumber, authorPattern, "\\")));
        }

        messagesQuery = filters.Type switch
        {
            "text" => messagesQuery.Where(message => message.Text != ""),
            "attachments" => messagesQuery.Where(message => message.Attachments.Any()),
            "media" => messagesQuery.Where(message => message.Attachments.Any(attachment => attachment.ContentType.StartsWith("image/") || attachment.ContentType.StartsWith("video/"))),
            "files" => messagesQuery.Where(message => message.Attachments.Any(attachment =>
                !attachment.ContentType.StartsWith("image/") &&
                !attachment.ContentType.StartsWith("video/") &&
                !attachment.ContentType.StartsWith("audio/"))),
            "links" => messagesQuery.Where(message => message.Text.Contains("http://") || message.Text.Contains("https://")),
            "voice" => messagesQuery.Where(message => message.Attachments.Any(attachment => attachment.ContentType.StartsWith("audio/"))),
            _ => messagesQuery
        };

        return await messagesQuery
            .OrderByDescending(message => message.SentAt)
            .Skip(skip)
            .Take(limit)
            .Include(message => message.Chat)
                .ThenInclude(chat => chat.Members)
                    .ThenInclude(member => member.User)
            .Include(message => message.Sender)
            .Include(message => message.PinnedByUser)
            .Include(message => message.Attachments)
            .Include(message => message.Reactions)
            .Include(message => message.Views)
            .Include(message => message.ReplyToMessage)
                .ThenInclude(message => message!.Sender)
            .Include(message => message.ReplyToMessage)
                .ThenInclude(message => message!.Attachments)
            .AsNoTracking()
            .ToListAsync(cancellationToken);
    }

    private static bool MatchesChatSearch(Chat chat, string query)
    {
        if (!string.IsNullOrWhiteSpace(chat.Title) && chat.Title.Contains(query, StringComparison.OrdinalIgnoreCase))
        {
            return true;
        }

        if (!string.IsNullOrWhiteSpace(chat.PublicUsername) &&
            chat.PublicUsername.Contains(query, StringComparison.OrdinalIgnoreCase))
        {
            return true;
        }

        var usernameQuery = NormalizePublicSearchUsername(query);
        if (!string.IsNullOrWhiteSpace(usernameQuery) &&
            !string.IsNullOrWhiteSpace(chat.PublicUsername) &&
            chat.PublicUsername.Contains(usernameQuery, StringComparison.OrdinalIgnoreCase))
        {
            return true;
        }

        if (!string.IsNullOrWhiteSpace(chat.Description) &&
            chat.Description.Contains(query, StringComparison.OrdinalIgnoreCase))
        {
            return true;
        }

        return chat.Members.Any(member =>
            member.User.DisplayName.Contains(query, StringComparison.OrdinalIgnoreCase) ||
            member.User.Username.Contains(query, StringComparison.OrdinalIgnoreCase) ||
            (member.User.PhoneNumber?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false));
    }

    private static string BuildSearchMatchText(Message message)
    {
        if (!string.IsNullOrWhiteSpace(message.Text))
        {
            return message.Text.Length <= 160 ? message.Text : message.Text[..160];
        }

        var attachment = message.Attachments
            .OrderBy(x => x.SortOrder)
            .ThenBy(x => x.OriginalFileName)
            .FirstOrDefault();

        return attachment is null ? "Message" : attachment.OriginalFileName;
    }

    private static SearchFilters BuildSearchFilters(
        string? legacyFilter,
        string? type,
        DateTimeOffset? date,
        DateTimeOffset? dateFrom,
        DateTimeOffset? dateTo,
        Guid? authorId,
        string? author)
    {
        var normalizedType = NormalizeSearchType(type) ?? NormalizeSearchType(legacyFilter);
        var effectiveDateFrom = dateFrom;
        var effectiveDateTo = dateTo;
        if (date is not null)
        {
            var dateStart = new DateTimeOffset(date.Value.Date, date.Value.Offset);
            effectiveDateFrom ??= dateStart;
            effectiveDateTo ??= dateStart.AddDays(1).AddTicks(-1);
        }

        return new SearchFilters(
            normalizedType,
            effectiveDateFrom,
            effectiveDateTo,
            authorId,
            string.IsNullOrWhiteSpace(author) ? null : author.Trim());
    }

    private static string? NormalizeSearchType(string? value)
    {
        var normalized = value?.Trim().ToLowerInvariant();
        return normalized switch
        {
            "attachment" or "attachments" => "attachments",
            "file" or "files" or "document" or "documents" => "files",
            "image" or "images" or "video" or "videos" or "media" => "media",
            "link" or "links" or "url" or "urls" => "links",
            "audio" or "voice" or "voice-note" or "voice_note" or "voice-notes" or "voice_notes" => "voice",
            "message" or "messages" or "text" => "text",
            _ => null
        };
    }

    private static int NormalizeSearchPage(int page) => Math.Clamp(page, 1, 10_000);

    private static int NormalizeSearchLimit(int limit) => Math.Clamp(limit, 1, 100);

    private static string BuildLikePattern(string value)
    {
        var escaped = value
            .Replace("\\", "\\\\", StringComparison.Ordinal)
            .Replace("%", "\\%", StringComparison.Ordinal)
            .Replace("_", "\\_", StringComparison.Ordinal);
        return $"%{escaped}%";
    }

    private static string? NormalizePublicSearchUsername(string value)
    {
        var normalized = value.Trim().TrimStart('@');
        return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
    }

    private void WriteSearchPageHeaders(int page, int limit, bool hasMore)
    {
        Response.Headers["X-Search-Page"] = page.ToString(System.Globalization.CultureInfo.InvariantCulture);
        Response.Headers["X-Search-Limit"] = limit.ToString(System.Globalization.CultureInfo.InvariantCulture);
        Response.Headers["X-Search-Has-More"] = hasMore ? "true" : "false";
    }

    private sealed record SearchFilters(
        string? Type,
        DateTimeOffset? DateFrom,
        DateTimeOffset? DateTo,
        Guid? AuthorId,
        string? AuthorQuery)
    {
        public bool HasMessageFilter =>
            Type is not null ||
            DateFrom is not null ||
            DateTo is not null ||
            AuthorId is not null ||
            !string.IsNullOrWhiteSpace(AuthorQuery);
    }

    private static string? NormalizeFolderKey(string? value)
    {
        var normalized = value?.Trim().ToLowerInvariant();
        if (string.IsNullOrWhiteSpace(normalized))
        {
            return null;
        }

        return new string(normalized
            .Where(ch => char.IsLetterOrDigit(ch) || ch is '-' or '_')
            .Take(48)
            .ToArray());
    }

    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("Public username must contain at least 5 characters.");
        }

        return normalized;
    }

    private ChatInviteLinkDto ToInviteLinkDto(ChatInviteLink link) =>
        new(
            link.Id,
            link.ChatId,
            link.Token,
            $"{Request.Scheme}://{Request.Host}/join/{Uri.EscapeDataString(link.Token)}",
            link.Title,
            link.CreatesJoinRequest,
            link.IsRevoked,
            link.CreatedAt,
            link.RevokedAt);

    private ChatJoinRequestDto ToJoinRequestDto(ChatJoinRequest request) =>
        new(
            request.Id,
            request.ChatId,
            request.InviteLinkId,
            request.User.ToDto(presenceTracker),
            request.Status,
            request.RequestedAt,
            request.ResolvedAt,
            request.ResolvedByUser?.ToDto(presenceTracker));

    private async Task AddModerationLogAsync(
        Guid actorUserId,
        Guid? chatId,
        ModerationLogTargetType targetType,
        Guid? targetId,
        string action,
        string? reason,
        CancellationToken cancellationToken)
    {
        var actorExists = await dbContext.Users.AnyAsync(x => x.Id == actorUserId, cancellationToken);
        if (!actorExists)
        {
            return;
        }

        dbContext.ModerationLogEntries.Add(new ModerationLogEntry
        {
            ActorUserId = actorUserId,
            ChatId = chatId,
            TargetType = targetType,
            TargetId = targetId,
            Action = action,
            Reason = TrimToNull(reason, 256),
            CreatedAt = DateTimeOffset.UtcNow
        });
    }

    private static int? NormalizeTtlSeconds(int? ttlSeconds, bool allowNull)
    {
        if (ttlSeconds is null || ttlSeconds <= 0)
        {
            return allowNull ? null : 86400;
        }

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

    private async Task<bool> IsBlockedByPrivacyAsync(Guid ownerUserId, Guid candidateUserId, CancellationToken cancellationToken)
    {
        var rawBlocked = await dbContext.UserPrivacySettings
            .AsNoTracking()
            .Where(x => x.UserId == ownerUserId)
            .Select(x => x.BlockedUserIds)
            .SingleOrDefaultAsync(cancellationToken);

        return ContainsSerializedUserId(rawBlocked, candidateUserId);
    }

    private static bool ContainsSerializedUserId(string? value, Guid userId) =>
        !string.IsNullOrWhiteSpace(value) &&
        value
            .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
            .Any(x => Guid.TryParse(x, out var parsed) && parsed == userId);

    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? TrimToNull(string? value, int maxLength)
    {
        var trimmed = value?.Trim();
        if (string.IsNullOrWhiteSpace(trimmed))
        {
            return null;
        }

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

    private async Task<List<ChatTopicDto>> LoadChatTopicDtosAsync(Guid chatId, CancellationToken cancellationToken)
    {
        var messageCounts = await dbContext.Messages
            .AsNoTracking()
            .Where(x => x.ChatId == chatId && x.TopicId != null && x.DeletedAt == null)
            .GroupBy(x => x.TopicId!.Value)
            .Select(group => new { TopicId = group.Key, Count = group.Count() })
            .ToDictionaryAsync(x => x.TopicId, x => x.Count, cancellationToken);

        var topics = await dbContext.ChatTopics
            .AsNoTracking()
            .Where(x => x.ChatId == chatId)
            .OrderByDescending(x => x.IsPinned)
            .ThenByDescending(x => x.LastActivityAt)
            .ToListAsync(cancellationToken);

        return topics
            .Select(x => new ChatTopicDto(
                x.Id,
                x.ChatId,
                x.Title,
                x.Icon,
                x.IsPinned,
                x.IsClosed,
                x.CreatedAt,
                x.LastActivityAt,
                messageCounts.GetValueOrDefault(x.Id)))
            .ToList();
    }

    private static ChatPermissionsDto ResolvePermissions(Chat chat, Guid userId) =>
        DtoMapper.BuildPermissions(chat.Members.FirstOrDefault(member => member.UserId == userId), chat.Type);

    private static bool CanManageMembers(Chat chat, Guid userId) =>
        ResolvePermissions(chat, userId).CanManageMembers;

    private static bool CanUserPublish(Chat chat, Guid userId) =>
        ResolvePermissions(chat, userId).CanPostMessages;

    private static bool ShouldPostAsChannel(Chat chat) =>
        chat.Type == ChatType.Channel;

    private static string? ResolveChannelAuthorSignature(Chat chat, User sender)
    {
        if (chat.Type != ChatType.Channel || !chat.ChannelSignaturesEnabled)
        {
            return null;
        }

        return string.IsNullOrWhiteSpace(sender.DisplayName) ? sender.Username : sender.DisplayName;
    }

    private static ChatMember CreateSubscriberMembership(Chat chat, Guid userId)
    {
        var isChannel = chat.Type == ChatType.Channel;
        return new ChatMember
        {
            ChatId = chat.Id,
            UserId = userId,
            Role = ChatMemberRole.Member,
            CanPinMessages = !isChannel,
            CanDeleteMessages = false,
            CanInviteUsers = !isChannel,
            CanPostMessages = !isChannel,
            JoinedAt = DateTimeOffset.UtcNow
        };
    }

    private static string ResolveForwardedFromDisplayName(Message sourceMessage)
    {
        var forwardedFromDisplayName = sourceMessage.ForwardedFromDisplayName;
        if (!string.IsNullOrWhiteSpace(forwardedFromDisplayName))
        {
            return forwardedFromDisplayName;
        }

        if (sourceMessage.Chat.Type == ChatType.Channel && sourceMessage.PostedAsChannel)
        {
            return string.IsNullOrWhiteSpace(sourceMessage.Chat.Title) ? "Channel" : sourceMessage.Chat.Title!;
        }

        return string.IsNullOrWhiteSpace(sourceMessage.Sender.DisplayName)
            ? sourceMessage.Sender.Username
            : sourceMessage.Sender.DisplayName;
    }

    private async Task NotifyMessageCreatedAsync(Chat chat, Message message, CancellationToken cancellationToken)
    {
        foreach (var member in chat.Members)
        {
            var dto = message.ToDto(presenceTracker, member.UserId);
            await hubContext.Clients.User(member.UserId.ToString())
                .SendAsync("MessageCreated", dto, cancellationToken);
        }

        await NotifyChatsChangedAsync(chat.Members.Select(member => member.UserId), cancellationToken);
    }

    private async Task NotifyMessageUpdatedAsync(Chat chat, IReadOnlyCollection<Message> messages, CancellationToken cancellationToken)
    {
        foreach (var member in chat.Members)
        {
            foreach (var message in messages.OrderBy(x => x.SentAt))
            {
                var dto = message.ToDto(presenceTracker, member.UserId);
                await hubContext.Clients.User(member.UserId.ToString())
                    .SendAsync("MessageUpdated", dto, cancellationToken);
            }
        }
    }

    private async Task NotifyChatCreatedAsync(Chat chat, CancellationToken cancellationToken)
    {
        foreach (var member in chat.Members)
        {
            var summary = chat.ToSummaryDto(member.UserId, presenceTracker);
            await hubContext.Clients.User(member.UserId.ToString())
                .SendAsync("ChatCreated", summary, cancellationToken);
        }

        await hubContext.Clients.Users(chat.Members.Select(x => x.UserId.ToString()))
            .SendAsync("ChatsChanged", cancellationToken);
    }

    private Task NotifyChatsChangedAsync(IEnumerable<Guid> userIds, CancellationToken cancellationToken) =>
        hubContext.Clients.Users(userIds.Select(x => x.ToString()).Distinct())
            .SendAsync("ChatsChanged", cancellationToken);

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